chore(repo): iOS-App als ios/ ins Hauptrepo uebernehmen

Calendarr-IOS wird mit vollstaendiger Historie als Unterverzeichnis
ios/ eingebunden. Damit liegen Server, Web, Android und iOS in einem
gemeinsamen Repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-14 09:20:12 +02:00
co-authored by Claude Opus 5
107 changed files with 19373 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,15 @@
{
"originHash" : "478d8419d86ca78c1539d3775396b811ce835c77379f2af98e26eefd35ee2311",
"pins" : [
{
"identity" : "appauth-ios",
"kind" : "remoteSourceControl",
"location" : "https://github.com/openid/AppAuth-iOS.git",
"state" : {
"revision" : "2781038865a80e2c425a1da12cc1327bcd56501f",
"version" : "1.7.6"
}
}
],
"version" : 3
}
@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2650"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C0000A01FB4E10100AB5001"
BuildableName = "Calendarr iOS.app"
BlueprintName = "Calendarr iOS"
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3927C7BB2FB99D0E00EAD8ED"
BuildableName = "Calendarr iOSTests.xctest"
BlueprintName = "Calendarr iOSTests"
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
queueDebuggingEnableBacktraceRecording = "Yes">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C0000A01FB4E10100AB5001"
BuildableName = "Calendarr iOS.app"
BlueprintName = "Calendarr iOS"
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C0000A01FB4E10100AB5001"
BuildableName = "Calendarr iOS.app"
BlueprintName = "Calendarr iOS"
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,111 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2700"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3934CF1930544C01003958B8"
BuildableName = "CalendarrWatch.app"
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C0000A01FB4E10100AB5001"
BuildableName = "Calendarr iOS.app"
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3934CF2530544C02003958B8"
BuildableName = "CalendarrWatchTests.xctest"
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3934CF2F30544C02003958B8"
BuildableName = "CalendarrWatchUITests.xctest"
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
queueDebuggingEnabled = "No">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3934CF1930544C01003958B8"
BuildableName = "CalendarrWatch.app"
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3934CF1930544C01003958B8"
BuildableName = "CalendarrWatch.app"
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,122 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2700"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3934CF4230544C2E003958B8"
BuildableName = "CalendarrWatchComplications.appex"
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C0000A01FB4E10100AB5001"
BuildableName = "Calendarr iOS.app"
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3934CF1930544C01003958B8"
BuildableName = "CalendarrWatch.app"
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2"
queueDebuggingEnabled = "No">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C0000A01FB4E10100AB5001"
BuildableName = "Calendarr iOS.app"
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "_XCWidgetKind"
value = ""
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "_XCWidgetDefaultView"
value = "timeline"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "_XCWidgetFamily"
value = "systemMedium"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "3934CF4230544C2E003958B8"
BuildableName = "CalendarrWatchComplications.appex"
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,111 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2700"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D0001A01ABCDEF0100AB5001"
BuildableName = "CalendarrWidgets.appex"
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C0000A01FB4E10100AB5001"
BuildableName = "Calendarr iOS.app"
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2"
queueDebuggingEnabled = "No">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C0000A01FB4E10100AB5001"
BuildableName = "Calendarr iOS.app"
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "_XCWidgetKind"
value = ""
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "_XCWidgetDefaultView"
value = "timeline"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "_XCWidgetFamily"
value = "systemMedium"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
askForAppToLaunch = "Yes"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C0000A01FB4E10100AB5001"
BuildableName = "Calendarr iOS.app"
ReferencedContainer = "container:Calendarr iOS.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>Calendarr iOS.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
<key>CalendarrWatch Watch App.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>3</integer>
</dict>
<key>CalendarrWatch.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
</dict>
<key>CalendarrWatchComplications.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>3</integer>
</dict>
<key>CalendarrWatchExtension.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>4</integer>
</dict>
<key>CalendarrWidgets.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>4</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>3927C7BB2FB99D0E00EAD8ED</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>C0000A01FB4E10100AB5001</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict>
</plist>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512" x="0" y="0" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve" class=""><g><path fill="#eceff1" d="M490.668 171.52H21.332C9.559 171.52 0 181.078 0 192.852V448c0 35.285 28.715 64 64 64h384c35.285 0 64-28.715 64-64V192.852c0-11.774-9.559-21.332-21.332-21.332zm0 0" opacity="1" data-original="#eceff1" data-darkreader-inline-fill="" style="--darkreader-inline-fill: var(--darkreader-background-eceff1, #212425);" class=""></path><path fill="#009003" d="M448 64H64C28.652 64 0 92.652 0 128v64.852h512V128c0-35.348-28.652-64-64-64zm0 0" opacity="1" data-original="#2196f3" data-darkreader-inline-fill="" style="--darkreader-inline-fill: var(--darkreader-background-2196f3, #0a6ab6);" class=""></path><path fill="#075d00" d="M128 0h-21.332C94.891 0 85.332 9.559 85.332 21.332v85.336c0 11.773 9.559 21.332 21.336 21.332H128c11.777 0 21.332-9.559 21.332-21.332V21.332C149.332 9.559 139.777 0 128 0zM405.332 0H384c-11.777 0-21.332 9.559-21.332 21.332v85.336c0 11.773 9.555 21.332 21.332 21.332h21.332c11.777 0 21.336-9.559 21.336-21.332V21.332C426.668 9.559 417.109 0 405.332 0zm0 0" opacity="1" data-original="#1976d2" data-darkreader-inline-fill="" style="--darkreader-inline-fill: var(--darkreader-background-1976d2, #145ea8);" class=""></path><g fill="#2196f3" data-darkreader-inline-fill="" style="--darkreader-inline-fill: var(--darkreader-background-2196f3, #0a6ab6);"><path d="M133.332 234.668H80c-8.832 0-16 7.168-16 16V304c0 8.832 7.168 16 16 16h53.332c8.832 0 16-7.168 16-16v-53.332c0-8.832-7.168-16-16-16zM133.332 362.668H80c-8.832 0-16 7.168-16 16V432c0 8.832 7.168 16 16 16h53.332c8.832 0 16-7.168 16-16v-53.332c0-8.832-7.168-16-16-16zM282.668 234.668h-53.336c-8.832 0-16 7.168-16 16V304c0 8.832 7.168 16 16 16h53.336c8.832 0 16-7.168 16-16v-53.332c0-8.832-7.168-16-16-16zm0 0" fill="#009003" opacity="1" data-original="#2196f3" data-darkreader-inline-fill="" style="--darkreader-inline-fill: var(--darkreader-background-2196f3, #0a6ab6);" class=""></path></g><path fill="#90f992" d="M282.668 362.668h-53.336c-8.832 0-16 7.168-16 16V432c0 8.832 7.168 16 16 16h53.336c8.832 0 16-7.168 16-16v-53.332c0-8.832-7.168-16-16-16zm0 0" opacity="1" data-original="#90caf9" data-darkreader-inline-fill="" style="--darkreader-inline-fill: var(--darkreader-background-90caf9, #064273);" class=""></path><path fill="#009003" d="M432 234.668h-53.332c-8.832 0-16 7.168-16 16V304c0 8.832 7.168 16 16 16H432c8.832 0 16-7.168 16-16v-53.332c0-8.832-7.168-16-16-16zm0 0" opacity="1" data-original="#2196f3" data-darkreader-inline-fill="" style="--darkreader-inline-fill: var(--darkreader-background-2196f3, #0a6ab6);" class=""></path></g></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

+46
View File
@@ -0,0 +1,46 @@
{
"fill" : {
"linear-gradient" : [
"display-p3:0.00000,0.33992,0.04269,1.00000",
"display-p3:0.10339,0.31029,0.00000,1.00000"
],
"orientation" : {
"start" : {
"x" : 0.5,
"y" : 0
},
"stop" : {
"x" : 0.5,
"y" : 0.7
}
}
},
"groups" : [
{
"layers" : [
{
"image-name" : "calendar 2.svg",
"name" : "calendar 2",
"position" : {
"scale" : 1.3,
"translation-in-points" : [
0,
0
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"squares" : "shared"
}
}
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.314",
"green" : "0.627",
"red" : "0.125"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -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>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.scarriffleservices.calendarr</string>
</array>
<!-- 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>
</plist>
+131
View File
@@ -0,0 +1,131 @@
import SwiftUI
@main
struct CalendarrApp: App {
@State private var appState = AppState()
init() {
// Registration has to happen before the app finishes launching, which
// is why it is here and not in a .task modifier.
BackgroundRefresh.register()
WatchSyncService.shared.activate()
}
var body: some Scene {
WindowGroup {
RootView()
.environment(appState)
.task { BackgroundRefresh.schedule() }
}
}
}
@Observable
class AppState {
var serverURL: String = ""
var authToken: String = ""
var username: String = ""
var isAdmin: Bool = false
var isConfigured: Bool { !serverURL.isEmpty }
var isLoggedIn: Bool { !authToken.isEmpty }
init() {
serverURL = UserDefaults.standard.string(forKey: "serverURL") ?? ""
authToken = Self.loadToken()
username = UserDefaults.standard.string(forKey: "username") ?? ""
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) {
serverURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
if serverURL.hasSuffix("/") { serverURL = String(serverURL.dropLast()) }
UserDefaults.standard.set(serverURL, forKey: "serverURL")
}
func saveLogin(token: String, user: String, admin: Bool) {
authToken = token
username = user
isAdmin = admin
try? KeychainStore.set(token, for: "authToken") // secret → Keychain, not UserDefaults
UserDefaults.standard.set(user, forKey: "username")
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() {
authToken = ""
username = ""
isAdmin = false
try? KeychainStore.set(nil, for: "authToken")
try? KeychainStore.set(nil, for: "authToken", accessGroup: nil) // pre-entitlement copy
// Drop the SSO session too, otherwise a stale provider refresh token
// outlives the account it belonged to.
OIDCAuthenticator.clearStoredSession()
UserDefaults.standard.removeObject(forKey: "authToken") // pre-Keychain copy
UserDefaults.standard.removeObject(forKey: "username")
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()
// The watch keeps its own copy of the snapshot, so clearing ours is not
// enough — it has to be told, or it keeps rendering this user's events.
WatchSyncService.shared.pushSignedOut()
}
func resetServer() {
logout()
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()
}
}
+347
View File
@@ -0,0 +1,347 @@
import SwiftUI
struct AppSettings: Codable {
var defaultView: String = "month"
var weekStartDay: String = "monday"
var primaryColor: String = "#4285f4"
var accentColor: String = "#ea4335"
var todayColor: String = "#4285f4"
var dimPastEvents: Bool = false
var textContrast: Int = 3
var lineContrast: Int = 3
var hourHeight: Int = 60
var language: String = "de"
var monthDividerColor: String = "#7090c0"
var monthLabelColor: String = "#7090c0"
var textColor: String = "#FFFFFF"
var backgroundColor: String = "#000000"
var lineColor: String = "#3A3A52"
var privateEventVisibility: String = "busy" // 'hidden' | 'busy'
var groupVisibleCalendarId: Int? = nil
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 {
case defaultView = "default_view"
case weekStartDay = "week_start_day"
case primaryColor = "primary_color"
case accentColor = "accent_color"
case todayColor = "today_color"
case dimPastEvents = "dim_past_events"
case textContrast = "text_contrast"
case lineContrast = "line_contrast"
case hourHeight = "hour_height"
case language
case monthDividerColor = "month_divider_color"
case monthLabelColor = "month_label_color"
case textColor = "text_color"
case backgroundColor = "bg_color"
case lineColor = "line_color"
case privateEventVisibility = "private_event_visibility"
case groupVisibleCalendarId = "group_visible_calendar_id"
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() {}
/// Resilient decoding: a server may omit some keys. Using `decodeIfPresent`
/// with the property defaults means a missing key no longer aborts the whole
/// decode — otherwise the entire settings sync silently breaks. (Note:
/// `background_color` was previously mis-mapped; the server column is
/// `bg_color`, now corrected above.)
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
let d = AppSettings()
defaultView = try c.decodeIfPresent(String.self, forKey: .defaultView) ?? d.defaultView
weekStartDay = try c.decodeIfPresent(String.self, forKey: .weekStartDay) ?? d.weekStartDay
primaryColor = try c.decodeIfPresent(String.self, forKey: .primaryColor) ?? d.primaryColor
accentColor = try c.decodeIfPresent(String.self, forKey: .accentColor) ?? d.accentColor
todayColor = try c.decodeIfPresent(String.self, forKey: .todayColor) ?? d.todayColor
dimPastEvents = try c.decodeIfPresent(Bool.self, forKey: .dimPastEvents) ?? d.dimPastEvents
textContrast = try c.decodeIfPresent(Int.self, forKey: .textContrast) ?? d.textContrast
lineContrast = try c.decodeIfPresent(Int.self, forKey: .lineContrast) ?? d.lineContrast
hourHeight = try c.decodeIfPresent(Int.self, forKey: .hourHeight) ?? d.hourHeight
language = try c.decodeIfPresent(String.self, forKey: .language) ?? d.language
monthDividerColor = try c.decodeIfPresent(String.self, forKey: .monthDividerColor) ?? d.monthDividerColor
monthLabelColor = try c.decodeIfPresent(String.self, forKey: .monthLabelColor) ?? d.monthLabelColor
textColor = try c.decodeIfPresent(String.self, forKey: .textColor) ?? d.textColor
backgroundColor = try c.decodeIfPresent(String.self, forKey: .backgroundColor) ?? d.backgroundColor
lineColor = try c.decodeIfPresent(String.self, forKey: .lineColor) ?? d.lineColor
privateEventVisibility = try c.decodeIfPresent(String.self, forKey: .privateEventVisibility) ?? d.privateEventVisibility
groupVisibleCalendarId = try c.decodeIfPresent(Int.self, forKey: .groupVisibleCalendarId)
defaultReminderMinutes = try c.decodeIfPresent(Int.self, forKey: .defaultReminderMinutes)
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)
}
}
struct CalDAVAccount: Codable, Identifiable {
let id: Int
var name: String
var url: String
var username: String
var color: String
var enabled: Bool
var calendars: [CalDAVCalendar]?
enum CodingKeys: String, CodingKey {
case id, name, url, username, color, enabled, calendars
}
}
struct CalDAVCalendar: Codable, Identifiable {
let id: Int
var name: String
var color: String?
var enabled: Bool
var sidebarHidden: Bool
var remindersEnabled: Bool?
enum CodingKeys: String, CodingKey {
case id, name, color, enabled
case sidebarHidden = "sidebar_hidden"
case remindersEnabled = "reminders_enabled"
}
}
struct LocalCalendar: Codable, Identifiable {
let id: Int
var name: String
var color: String
var enabled: Bool
var owned: Bool = true
var sharedBy: String? = nil
var permission: String? = nil
var group: Bool = false
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 {
case id, name, color, enabled, owned, permission, group
case sharedBy = "shared_by"
case remindersEnabled = "reminders_enabled"
case isBirthday = "is_birthday"
case birthdayNotifyDaysBefore = "birthday_notify_days_before"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(Int.self, forKey: .id)
name = try c.decodeIfPresent(String.self, forKey: .name) ?? ""
color = try c.decodeIfPresent(String.self, forKey: .color) ?? "#34a853"
enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? true
owned = try c.decodeIfPresent(Bool.self, forKey: .owned) ?? true
sharedBy = try c.decodeIfPresent(String.self, forKey: .sharedBy)
permission = try c.decodeIfPresent(String.self, forKey: .permission)
group = try c.decodeIfPresent(Bool.self, forKey: .group) ?? false
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)
}
}
struct ICalSubscription: Codable, Identifiable {
let id: Int
var name: String
var url: String
var color: String
var enabled: Bool
var refreshMinutes: Int
var lastFetched: String?
var remindersEnabled: Bool?
enum CodingKeys: String, CodingKey {
case id, name, url, color, enabled
case refreshMinutes = "refresh_minutes"
case lastFetched = "last_fetched"
case remindersEnabled = "reminders_enabled"
}
}
struct GoogleAccount: Codable, Identifiable {
let id: Int
var email: String
var calendars: [GoogleCalendar]?
}
struct GoogleCalendar: Codable, Identifiable {
let id: Int
var name: String
var color: String?
var enabled: Bool
var sidebarHidden: Bool
var remindersEnabled: Bool?
enum CodingKeys: String, CodingKey {
case id, name, color, enabled
case sidebarHidden = "sidebar_hidden"
case remindersEnabled = "reminders_enabled"
}
}
struct HomeAssistantAccount: Codable, Identifiable {
let id: Int
var name: String
var url: String
var authMethod: String
var calendars: [HACalendar]?
enum CodingKeys: String, CodingKey {
case id, name, url, calendars
case authMethod = "auth_method"
}
}
struct HACalendar: Codable, Identifiable {
let id: Int
var name: String
var entityId: String
var color: String?
var enabled: Bool
var sidebarHidden: Bool
var remindersEnabled: Bool = true
enum CodingKeys: String, CodingKey {
case id, name, color, enabled
case entityId = "entity_id"
case sidebarHidden = "sidebar_hidden"
case remindersEnabled = "reminders_enabled"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(Int.self, forKey: .id)
name = try c.decode(String.self, forKey: .name)
entityId = try c.decodeIfPresent(String.self, forKey: .entityId) ?? ""
color = try c.decodeIfPresent(String.self, forKey: .color)
enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? true
sidebarHidden = try c.decodeIfPresent(Bool.self, forKey: .sidebarHidden) ?? false
remindersEnabled = try c.decodeIfPresent(Bool.self, forKey: .remindersEnabled) ?? true
}
}
struct UserProfile: Codable {
let id: Int
let username: String
var displayName: String?
var email: String?
let isAdmin: Bool
let hasAvatar: Bool
let totpEnabled: Bool
var directoryHidden: Bool = false
enum CodingKeys: String, CodingKey {
case id, username, email
case displayName = "display_name"
case isAdmin = "is_admin"
case hasAvatar = "has_avatar"
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
}
}
// MARK: - Sharing & groups
struct DirectoryUser: Codable, Identifiable {
let id: Int
let displayName: String
enum CodingKeys: String, CodingKey { case id; case displayName = "display_name" }
}
struct CalendarShare: Codable, Identifiable {
let userId: Int
let displayName: String?
var permission: String
var id: Int { userId }
enum CodingKeys: String, CodingKey {
case userId = "user_id"
case displayName = "display_name"
case permission
}
}
struct GroupMember: Codable, Identifiable {
let id: Int
let displayName: String?
var role: String
var color: String?
var sharesCalendar: Bool = true
enum CodingKeys: String, CodingKey {
case id, role, color
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
}
}
struct CalGroup: Codable, Identifiable {
let id: Int
var name: String
var icon: String?
var role: String?
var memberCount: Int?
var groupCalendarId: Int?
var groupCalendarColor: String?
var members: [GroupMember]?
enum CodingKeys: String, CodingKey {
case id, name, icon, role, members
case memberCount = "member_count"
case groupCalendarId = "group_calendar_id"
case groupCalendarColor = "group_calendar_color"
}
}
extension Color {
init(hex: String) {
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: hex).scanHexInt64(&int)
let r, g, b: UInt64
switch hex.count {
case 6:
(r, g, b) = ((int >> 16) & 0xFF, (int >> 8) & 0xFF, int & 0xFF)
default:
(r, g, b) = (0, 0, 0)
}
self.init(red: Double(r) / 255, green: Double(g) / 255, blue: Double(b) / 255)
}
func toHex() -> String {
let uiColor = UIColor(self)
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
uiColor.getRed(&r, green: &g, blue: &b, alpha: &a)
return String(format: "#%02X%02X%02X", Int(r * 255), Int(g * 255), Int(b * 255))
}
}
+223
View File
@@ -0,0 +1,223 @@
import Foundation
import SwiftUI
/// Creator (or owner, in the group combined view) of an event.
/// `id` is nil for imported events.
struct EventPerson: Hashable {
let id: Int?
let displayName: String
static func from(_ json: Any?) -> EventPerson? {
guard let obj = json as? [String: Any],
let name = obj["display_name"] as? String, !name.isEmpty else { return nil }
let id: Int?
if let n = obj["id"] as? Int { id = n }
else if let s = obj["id"] as? String { id = Int(s) }
else { id = nil }
return EventPerson(id: id, displayName: name)
}
}
/// 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 {
let id: String
let url: String
var title: String
var startDate: Date
var endDate: Date
var isAllDay: Bool
var location: String
var notes: String
var color: String?
var calendarId: String
var calendarName: String
var calendarColor: String
var source: String
var creator: EventPerson? = nil
var isPrivate: Bool = false
// Only set in the group combined view:
var owner: EventPerson? = nil
var isGroupEvent: Bool = false
var displayColor: String? = nil
// Server-decorated title for the group combined view (group icon / owner
// prefix); rendered in group mode while `title` stays raw for editing.
var displayTitle: String? = nil
// Reminder offsets in minutes-before-start (0 = at start). Local events only.
var reminders: [Int] = []
// 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.
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? {
guard
let title = json["title"] as? String,
let startStr = json["start"] as? String,
let endStr = json["end"] as? String
else { return nil }
// id can be String (local UUID) or Int (CalDAV numeric)
let id: String
if let s = json["id"] as? String { id = s }
else if let n = json["id"] as? Int { id = String(n) }
else { return nil }
// A date-only string is an all-day event whether or not the flag says
// so — `parseDate` already treats it that way. Trusting the flag alone
// is what put "00:00" in front of birthdays everywhere they are shown.
let startIsDateOnly: Bool = {
let clean = startStr.trimmingCharacters(in: .whitespaces)
return clean.count == 10 && !clean.contains("T")
}()
let declaredAllDay = (json["allDay"] as? Bool ?? false) || startIsDateOnly
let startDate = parseDate(startStr, allDay: declaredAllDay)
let endDate = parseDate(endStr, allDay: declaredAllDay)
guard let s = startDate, let e = endDate else { return nil }
// An event running midnight to midnight across one or more whole days
// is all-day whatever the payload claims. Without this, a six-day
// holiday sent without the flag renders as "00:00 – 00:00" — a time
// range that is technically true and tells the reader nothing.
let cal = Calendar.current
// Requiring the end to land exactly on midnight too was too strict: a
// holiday stored as 16th 00:00 to 21st 01:00 slipped through and showed
// up as a one-hour slot. Starting on a day boundary and running into a
// later day is enough.
let spansWholeDays = e > s
&& cal.startOfDay(for: s) == s
&& cal.startOfDay(for: e) > cal.startOfDay(for: s)
let isAllDay = declaredAllDay || spansWholeDays
return CalEvent(
id: id,
url: json["url"] as? String ?? "",
title: title,
startDate: s,
endDate: e,
isAllDay: isAllDay,
location: json["location"] as? String ?? "",
notes: json["description"] as? String ?? "",
color: (json["color"] as? String).flatMap { $0.isEmpty ? nil : $0 },
calendarId: json["calendar_id"].map { "\($0)" } ?? "",
calendarName: json["calendar_name"] as? String ?? "",
calendarColor: json["calendarColor"] as? String ?? "#4285f4",
source: json["source"] as? String ?? "local",
creator: EventPerson.from(json["creator"]),
isPrivate: json["private"] as? Bool ?? false,
owner: EventPerson.from(json["owner"]),
isGroupEvent: json["is_group_event"] as? Bool ?? false,
displayColor: (json["display_color"] as? String).flatMap { $0.isEmpty ? nil : $0 },
displayTitle: (json["display_title"] as? String).flatMap { $0.isEmpty ? nil : $0 },
reminders: (json["reminders"] as? [Int]) ?? (json["reminders"] as? [Any])?.compactMap { ($0 as? Int) ?? Int("\($0)") } ?? [],
readOnly: json["read_only"] as? Bool ?? false,
isBirthday: json["is_birthday"] as? Bool ?? false
)
}
}
private let isoFull: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
private let isoBasic: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f
}()
private let dateOnly: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
f.timeZone = .current
return f
}()
// Handles all date formats the backend may produce:
// "2026-05-17" "2026-05-17T10:00:00Z" "2026-05-17T10:00:00+02:00"
// "2026-05-17T10:00:00.000Z" "2026-05-17T10:00:00" "2026-05-17 10:00:00+00:00"
private let noTZFormatter: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
f.timeZone = TimeZone(abbreviation: "UTC")
return f
}()
private let spaceSepFormatter: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "yyyy-MM-dd HH:mm:ssZ"
return f
}()
func parseDate(_ s: String, allDay: Bool) -> Date? {
let clean = s.trimmingCharacters(in: .whitespaces)
if allDay || (clean.count == 10 && !clean.contains("T")) {
return dateOnly.date(from: String(clean.prefix(10)))
}
// Try each formatter in order of likelihood
if let d = isoFull.date(from: clean) { return d }
if let d = isoBasic.date(from: clean) { return d }
// Python isoformat uses space separator: "2026-05-17 10:00:00+00:00"
if let d = spaceSepFormatter.date(from: clean) { return d }
// No timezone → treat as UTC
if let d = noTZFormatter.date(from: String(clean.prefix(19))) { return d }
// Last resort: just parse the date part
return dateOnly.date(from: String(clean.prefix(10)))
}
func formatISO(_ date: Date, allDay: Bool) -> String {
if allDay {
return dateOnly.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)
}
}
}
@@ -0,0 +1,745 @@
import Foundation
import SwiftUI
import CalendarrCore
extension Notification.Name {
/// Posted whenever the persistent "banished calendars" set is mutated from
/// outside the active `CalendarStore` (e.g. by `AccountsView`). The store
/// listens for this in `CalendarHostView` and refreshes its filter.
static let banishedCalendarsChanged = Notification.Name("banishedCalendarsChanged")
/// Posted when the user taps the manual "sync with server" button in the
/// menu. `CalendarHostView` responds by invalidating the cache and
/// re-fetching events from the server.
static let manualSyncRequested = Notification.Name("manualSyncRequested")
}
enum CalViewType: String, CaseIterable {
case month, week, day, quarter, agenda
func label(_ lang: String) -> String {
switch self {
case .month: return L10n.t("view.month", lang)
case .week: return L10n.t("view.week", lang)
case .day: return L10n.t("view.day", lang)
case .quarter: return L10n.t("view.quarter", lang)
case .agenda: return L10n.t("view.agenda", lang)
}
}
var systemImage: String {
switch self {
case .month: return "calendar"
case .week: return "calendar.day.timeline.leading"
case .day: return "sun.max"
case .quarter: return "calendar.badge.clock"
case .agenda: return "list.bullet"
}
}
}
struct WritableCalendar: Identifiable {
let id: String
let name: String
let color: String
let source: String
let numericId: Int
}
@Observable
class CalendarStore {
// Visible state
var events: [CalEvent] = []
var viewType: CalViewType = .month
var currentDate: Date = .now
// The month currently scrolled into view (month view). Lives in the store so
// the Liquid-Glass navigation title — read in the system toolbar — updates
// via @Observable tracking (a plain @State did not refresh the toolbar).
var visibleMonth: Date = .now
var isLoading = false
var isCachingBackground = false
var lastError: String? = nil
/// 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 writableCalendars: [WritableCalendar] = []
// When set, the calendar shows the group's combined overlay instead of the
// user's own events. nil = personal view.
var activeGroup: CalGroup? = nil
/// Set of `"source:calendarId"` keys the user has chosen to hide from the
/// calendar views. Persisted in UserDefaults as a JSON array. Events whose
/// key matches one of these are filtered out before being rendered.
var hiddenCalendarKeys: Set<String> = CalendarStore.loadHiddenKeys()
/// "Banished" calendars – like `hiddenCalendarKeys` but expressing a
/// stronger user intent: the calendar should not even appear in the quick
/// show/hide list. Re-activation happens in AccountsView.
var banishedCalendarKeys: Set<String> = CalendarStore.loadBanishedKeys()
/// Set of `"source:calendarId"` keys whose events must NOT generate reminder
/// notifications (mirrors the server `reminders_enabled=false` flag). Persisted
/// in UserDefaults; reconciled from the server in the filter sheet.
var reminderDisabledKeys: Set<String> = CalendarStore.loadReminderDisabledKeys()
/// Group-overlay visibility: which members' calendars (`gm:<userId>`) and the
/// group calendar (`gc`) are hidden in the combined view — like hiding
/// individual people in Outlook. In-memory; resets when leaving/switching a
/// group (the per-calendar hide/banish sets are for the personal view only).
var hiddenGroupKeys: Set<String> = []
static func groupMemberKey(_ ownerId: Int) -> String { "gm:\(ownerId)" }
static let groupCalendarKey = "gc"
// Cache bookkeeping
private var cachedStart: Date? = nil
private var cachedEnd: Date? = nil
private var allCachedEvents: [CalEvent] = []
// MARK: – Hidden-calendar persistence
private static let hiddenKeysDefaultsKey = "hiddenCalendarKeys"
private static func loadHiddenKeys() -> Set<String> {
guard let raw = UserDefaults.standard.string(forKey: hiddenKeysDefaultsKey),
let data = raw.data(using: .utf8),
let arr = try? JSONDecoder().decode([String].self, from: data)
else { return [] }
return Set(arr)
}
private func saveHiddenKeys() {
let arr = Array(hiddenCalendarKeys)
if let data = try? JSONEncoder().encode(arr),
let s = String(data: data, encoding: .utf8) {
UserDefaults.standard.set(s, forKey: Self.hiddenKeysDefaultsKey)
}
}
/// Toggle visibility of a single calendar and immediately refresh the
/// visible event list + widget snapshot.
func setCalendarHidden(_ key: String, hidden: Bool) {
if hidden { hiddenCalendarKeys.insert(key) } else { hiddenCalendarKeys.remove(key) }
saveHiddenKeys()
let (s, e) = rangeForCurrentView()
refreshFromCache(start: s, end: e)
publishWidgetSnapshot()
}
/// Replace the entire set (used by the filter sheet's bulk show/hide).
func setHiddenCalendars(_ keys: Set<String>) {
hiddenCalendarKeys = keys
saveHiddenKeys()
let (s, e) = rangeForCurrentView()
refreshFromCache(start: s, end: e)
publishWidgetSnapshot()
}
/// Toggle / replace group-overlay visibility (members or the group calendar).
func setGroupKeyHidden(_ key: String, hidden: Bool) {
if hidden { hiddenGroupKeys.insert(key) } else { hiddenGroupKeys.remove(key) }
let (s, e) = rangeForCurrentView()
refreshFromCache(start: s, end: e)
}
func setHiddenGroupKeys(_ keys: Set<String>) {
hiddenGroupKeys = keys
let (s, e) = rangeForCurrentView()
refreshFromCache(start: s, end: e)
}
static func calendarKey(source: String, calendarId: String) -> String {
// The events API returns `calendar_id` inconsistently: a raw numeric for
// CalDAV, but "<source>-<id>" for local / ical / google / homeassistant
// (e.g. "local-3", "google-5"). The filter UI keys off the numeric DB id,
// so strip any leading "<source>-" prefix to make event keys and filter
// keys comparable — otherwise local hiding/banishing silently does nothing
// for those sources.
var id = calendarId
let prefix = "\(source)-"
if id.hasPrefix(prefix) { id = String(id.dropFirst(prefix.count)) }
return "\(source):\(id)"
}
// MARK: – 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
private static let banishedKeysDefaultsKey = "banishedCalendarKeys"
static func loadBanishedKeys() -> Set<String> {
guard let raw = UserDefaults.standard.string(forKey: banishedKeysDefaultsKey),
let data = raw.data(using: .utf8),
let arr = try? JSONDecoder().decode([String].self, from: data)
else { return [] }
return Set(arr)
}
static func saveBanishedKeys(_ keys: Set<String>) {
let arr = Array(keys)
if let data = try? JSONEncoder().encode(arr),
let s = String(data: data, encoding: .utf8) {
UserDefaults.standard.set(s, forKey: banishedKeysDefaultsKey)
}
}
/// Move a calendar to / out of the banished set. Also clears any quick
/// hidden flag for that key – once banished, the dual state is redundant.
/// Posts `.banishedCalendarsChanged` so other views in the navigation
/// stack (e.g. AccountsView) stay in sync.
func setCalendarBanished(_ key: String, banished: Bool) {
if banished {
banishedCalendarKeys.insert(key)
hiddenCalendarKeys.remove(key)
} else {
banishedCalendarKeys.remove(key)
}
Self.saveBanishedKeys(banishedCalendarKeys)
saveHiddenKeys()
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
let (s, e) = rangeForCurrentView()
refreshFromCache(start: s, end: e)
publishWidgetSnapshot()
}
/// Replace the whole banished set (used when reconciling with the server's
/// `sidebar_hidden` flags). Persists, notifies, refreshes.
func setBanishedCalendars(_ keys: Set<String>) {
guard keys != banishedCalendarKeys else { return }
banishedCalendarKeys = keys
Self.saveBanishedKeys(keys)
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
let (s, e) = rangeForCurrentView()
refreshFromCache(start: s, end: e)
publishWidgetSnapshot()
}
/// Re-read the banished set from UserDefaults – called when an external
/// view (AccountsView) mutated it. Refreshes visible events + widgets.
func syncBanishedFromDefaults() {
banishedCalendarKeys = Self.loadBanishedKeys()
let (s, e) = rangeForCurrentView()
refreshFromCache(start: s, end: e)
publishWidgetSnapshot()
}
/// 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
private static let reminderDisabledKeysDefaultsKey = "reminderDisabledCalendarKeys"
static func loadReminderDisabledKeys() -> Set<String> {
guard let raw = UserDefaults.standard.string(forKey: reminderDisabledKeysDefaultsKey),
let data = raw.data(using: .utf8),
let arr = try? JSONDecoder().decode([String].self, from: data)
else { return [] }
return Set(arr)
}
static func saveReminderDisabledKeys(_ keys: Set<String>) {
if let data = try? JSONEncoder().encode(Array(keys)),
let s = String(data: data, encoding: .utf8) {
UserDefaults.standard.set(s, forKey: reminderDisabledKeysDefaultsKey)
}
}
/// Toggle whether a single calendar's events generate reminders, then
/// reschedule notifications from the cache.
func setReminderDisabled(_ key: String, disabled: Bool) {
if disabled { reminderDisabledKeys.insert(key) } else { reminderDisabledKeys.remove(key) }
Self.saveReminderDisabledKeys(reminderDisabledKeys)
rescheduleNotifications()
}
/// Replace the whole set (used when reconciling with the server's
/// `reminders_enabled` flags in the filter sheet).
func setReminderDisabledKeys(_ keys: Set<String>) {
guard keys != reminderDisabledKeys else { return }
reminderDisabledKeys = keys
Self.saveReminderDisabledKeys(keys)
rescheduleNotifications()
}
/// Split a `"source:calendarId"` key back into its parts.
static func parseCalendarKey(_ key: String) -> (source: String, id: Int)? {
guard let colon = key.firstIndex(of: ":") else { return nil }
let source = String(key[..<colon])
guard let id = Int(key[key.index(after: colon)...]) else { return nil }
return (source, id)
}
/// Sources whose visibility is backed by the server's `sidebar_hidden`.
static let serverManagedSources: Set<String> = ["caldav", "google", "homeassistant"]
var userCalendar: Calendar {
var cal = Calendar.current
cal.firstWeekday = weekStartsOnMonday ? 2 : 1
return cal
}
// MARK: – Cache helpers
func isCached(start: Date, end: Date) -> Bool {
guard let cs = cachedStart, let ce = cachedEnd else { return false }
return cs <= start && ce >= end
}
/// Republish the full cached event set, applying only visibility filters
/// (hidden + banished). We deliberately do NOT slice by the current view's
/// date window: the user's chosen cache range is already loaded, and
/// scrolling within it must not make events vanish. Per-day / per-range
/// rendering is the responsibility of `events(on:)` / `events(in:)`.
/// `start` / `end` are kept in the signature for call-site clarity.
func refreshFromCache(start: Date, end: Date) {
_ = (start, end)
// In group overlay mode the per-calendar hide/banish toggles don't apply;
// instead honour the per-member / group-calendar toggles (hiddenGroupKeys).
if activeGroup != nil {
if hiddenGroupKeys.isEmpty {
events = allCachedEvents
} else {
events = allCachedEvents.filter { ev in
if ev.isGroupEvent { return !hiddenGroupKeys.contains(Self.groupCalendarKey) }
if let o = ev.owner { return !hiddenGroupKeys.contains(Self.groupMemberKey(o.id ?? -1)) }
return true
}
}
return
}
events = allCachedEvents.filter { ev in
let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId)
return !hiddenCalendarKeys.contains(key)
&& !banishedCalendarKeys.contains(key)
}
// Personal events drive local reminder notifications.
NotificationScheduler.reschedule(events: allCachedEvents, disabledCalendarKeys: reminderDisabledKeys)
}
/// Recompute scheduled reminder notifications from the personal cache
/// (skipped while a group overlay is active).
func rescheduleNotifications() {
guard activeGroup == nil else { return }
NotificationScheduler.reschedule(events: allCachedEvents, disabledCalendarKeys: reminderDisabledKeys)
}
/// Optimistically drop a just-deleted event from the cache so it disappears
/// from the UI immediately, without waiting for a server round-trip (HA
/// deletes can lag several seconds, and an immediate refetch could even
/// re-add it before the source propagated the deletion).
func removeCachedEvent(id: String) {
allCachedEvents.removeAll { $0.id == id }
events.removeAll { $0.id == id }
publishWidgetSnapshot()
}
// MARK: – Network loading
/// Load events for a specific range. Skips the network if already cached,
/// unless `force` is set (used after create/edit to pull fresh server data
/// for the visible range, bypassing the cache).
func loadEvents(api: CalendarrAPI, start: Date, end: Date, force: Bool = false) async {
if !force, isCached(start: start, end: end) {
refreshFromCache(start: start, end: end)
return
}
isLoading = true
lastError = nil
defer { isLoading = false }
do {
let (fetched, errors) = try await fetchForMode(api: api, start: start, end: 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)
} 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
}
}
/// Fetch events for the current mode (personal vs. group overlay). Group
/// events go through the same cache/prefetch/refresh path as personal ones,
/// so the whole visible grid is covered (no "only the middle weeks" gaps).
/// 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 {
let combined = try await api.fetchGroupCombined(groupId: g.id, start: start, end: end)
return (combined.map { decorateGroupEvent($0) }, [])
}
return try await api.fetchEvents(start: start, end: end)
}
/// Prefix a combined-view event with its owner (others) or 👥 + creator
/// (group calendar). Colour comes from the server's display_color.
private func decorateGroupEvent(_ ev: CalEvent) -> CalEvent {
// Prefer the server-decorated title (group icon + owner prefix) so web,
// iOS and Android render group events identically. `title` stays raw.
if let dt = ev.displayTitle, !dt.isEmpty {
var e = ev
e.title = dt
return e
}
// Fallback for older servers without display_title.
var e = ev
let me = UserDefaults.standard.integer(forKey: "userId")
let groupIcon = activeGroup?.icon ?? "👥"
func first(_ s: String) -> String { s.split(separator: " ").first.map(String.init) ?? s }
if ev.isGroupEvent {
if let c = ev.creator, c.id != me { e.title = "\(groupIcon) \(first(c.displayName)): \(ev.title)" }
else { e.title = "\(groupIcon) \(ev.title)" }
} else if let o = ev.owner, o.id != me {
e.title = "\(first(o.displayName)): \(ev.title)"
}
return e
}
/// Background prefetch for ±months around today – called once on startup.
func prefetchBackground(api: CalendarrAPI, months: Int) async {
let cal = userCalendar
let now = Date()
let start = cal.date(byAdding: .month, value: -months, to: cal.startOfDay(for: now))!
let end = cal.date(byAdding: .month, value: months + 1, to: cal.startOfDay(for: now))!
guard !isCached(start: start, end: end) else { return }
isCachingBackground = true
defer { isCachingBackground = false }
do {
let (fetched, errors) = try await fetchForMode(api: api, start: start, end: 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
let (vs, ve) = rangeForCurrentView()
refreshFromCache(start: vs, end: ve)
} catch {
// Background fetch failure is silent
}
}
/// Trigger a full cache reload (e.g. when cache-range setting changes).
/// Intentionally keeps `events` intact so the UI stays populated while
/// the network fetch runs; `refreshFromCache` will swap in fresh data
/// atomically once it arrives.
func invalidateCache() {
cachedStart = nil
cachedEnd = nil
allCachedEvents = []
}
/// Calendars / sources that reported a sync error on the last fetch. Events
/// 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 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
// 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 {
cachedStart = min(cs, rangeStart)
cachedEnd = max(ce, rangeEnd)
} else {
cachedStart = rangeStart
cachedEnd = rangeEnd
}
publishWidgetSnapshot()
}
/// Write a slim snapshot of the next ~6 weeks into the App-Group container
/// so the widget extension can render without a network call. 42 days
/// covers the worst-case month grid (6 rows × 7 cols) for the calendar
/// widget. Also asks the system to refresh the widget timeline.
private func publishWidgetSnapshot() {
// 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 now = Date()
// Include the week before today so widgets that show the current week
// (e.g. "This Week", "Up Next + Calendar") have data for Monday–today.
// The window is published in the snapshot as coverageStart/coverageEnd:
// 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)
}
}
let calendars = Array(calendarMap.values).sorted { $0.name < $1.name }
WidgetStore.writeCalendars(calendars)
let visible = allCachedEvents
.filter { ev in
let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId)
return ev.startDate < to && ev.endDate > from
&& !hiddenCalendarKeys.contains(key)
&& !banishedCalendarKeys.contains(key)
}
.sorted { $0.startDate < $1.startDate }
.prefix(SnapshotCoverage.maxEvents)
.map { ev in
WidgetEvent(id: ev.id,
title: ev.title,
start: ev.startDate,
end: ev.endDate,
isAllDay: ev.isAllDay,
colorHex: ev.effectiveColor,
location: ev.location,
calendarKey: Self.calendarKey(source: ev.source, calendarId: ev.calendarId))
}
let snapshot = WidgetStore.makeSnapshot(events: Array(visible),
coverageStart: from,
coverageEnd: to)
WidgetStore.write(snapshot)
WidgetTimelineNotifier.reload()
// The watch has its own container and cannot read the file we just
// wrote, so it is handed the data explicitly.
WatchSyncService.shared.push(snapshot: snapshot, calendars: calendars)
}
// MARK: – Writable calendars
func loadWritableCalendars(api: CalendarrAPI) async {
async let localCals = (try? await api.getLocalCalendars()) ?? []
async let caldavAccs = (try? await api.getCalDAVAccounts()) ?? []
async let googleCals = (try? await api.getGoogleCalendars()) ?? []
async let haCals = (try? await api.getHACalendars()) ?? []
var result: [WritableCalendar] = []
// 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))
}
for acc in await caldavAccs where acc.enabled {
for cal in acc.calendars ?? [] where cal.enabled {
result.append(WritableCalendar(id: "caldav-\(cal.id)", name: "\(acc.name) – \(cal.name)", color: cal.color ?? acc.color, source: "caldav", numericId: cal.id))
}
}
for (email, id, name, color) in await googleCals {
result.append(WritableCalendar(id: "google-\(id)", name: "\(email) – \(name)", color: color, source: "google", numericId: id))
}
for (accName, id, name, color) in await haCals {
result.append(WritableCalendar(id: "ha-\(id)", name: "\(accName) – \(name)", color: color, source: "homeassistant", numericId: id))
}
writableCalendars = result
}
// MARK: – Query helpers
func events(on date: Date) -> [CalEvent] {
let cal = userCalendar
let dayStart = cal.startOfDay(for: date)
let dayEnd = cal.date(byAdding: .day, value: 1, to: dayStart)!
return events.filter { ev in ev.startDate < dayEnd && ev.endDate > dayStart }
.sorted { $0.startDate < $1.startDate }
}
func events(in start: Date, end: Date) -> [CalEvent] {
events.filter { ev in ev.startDate < end && ev.endDate > start }
.sorted { $0.startDate < $1.startDate }
}
// MARK: – Navigation
func moveToToday() { currentDate = .now }
func navigatePrev() {
currentDate = userCalendar.date(byAdding: navComponent, value: navAmount * -1, to: currentDate) ?? currentDate
}
func navigateNext() {
currentDate = userCalendar.date(byAdding: navComponent, value: navAmount, to: currentDate) ?? currentDate
}
private var navComponent: Calendar.Component {
switch viewType {
case .week: return .weekOfYear
case .day: return .day
default: return .month
}
}
private var navAmount: Int { viewType == .quarter ? 3 : 1 }
func rangeForCurrentView() -> (Date, Date) {
let cal = userCalendar
switch viewType {
case .month:
let start = cal.date(from: cal.dateComponents([.year, .month], from: currentDate))!
return (cal.date(byAdding: .month, value: -1, to: start)!,
cal.date(byAdding: .month, value: 2, to: start)!)
case .quarter:
let start = cal.date(from: cal.dateComponents([.year, .month], from: currentDate))!
return (start, cal.date(byAdding: .month, value: 4, to: start)!)
case .week:
let weekStart = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: currentDate))!
return (weekStart, cal.date(byAdding: .day, value: 8, to: weekStart)!)
case .day:
let dayStart = cal.startOfDay(for: currentDate)
return (dayStart, cal.date(byAdding: .day, value: 1, to: dayStart)!)
case .agenda:
let start = cal.startOfDay(for: .now)
return (start, cal.date(byAdding: .day, value: 90, to: start)!)
}
}
func titleForCurrentView(language: String) -> String {
let cal = userCalendar
let loc = L10n.locale(language)
let fmt = DateFormatter(); fmt.locale = loc
switch viewType {
case .month:
fmt.dateFormat = "LLLL yyyy"
return fmt.string(from: currentDate).capitalized(with: loc)
case .quarter:
fmt.dateFormat = "LLL yyyy"
let m3 = cal.date(byAdding: .month, value: 2, to: currentDate) ?? currentDate
return "\(fmt.string(from: currentDate)) – \(fmt.string(from: m3))"
case .week:
let weekStart = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: currentDate))!
let weekEnd = cal.date(byAdding: .day, value: 6, to: weekStart)!
fmt.dateFormat = "d. MMM"
let ef = DateFormatter(); ef.locale = loc; ef.dateFormat = "d. MMM yyyy"
return "\(fmt.string(from: weekStart)) – \(ef.string(from: weekEnd))"
case .day:
fmt.dateFormat = "EEEE, d. MMMM yyyy"
return fmt.string(from: currentDate)
case .agenda:
return L10n.t("view.agenda", language)
}
}
}
+826
View File
@@ -0,0 +1,826 @@
import Foundation
import SwiftUI
enum AppLanguage: String, CaseIterable {
case system, de, en
var displayKey: String {
switch self {
case .system: return "lang.system"
case .de: return "lang.german"
case .en: return "lang.english"
}
}
}
enum L10n {
static func resolved(_ stored: String) -> String {
if stored == "de" || stored == "en" { return stored }
let pref = Locale.preferredLanguages.first ?? "en"
return pref.lowercased().hasPrefix("de") ? "de" : "en"
}
static func t(_ key: String, _ stored: String) -> String {
let lang = resolved(stored)
return strings[lang]?[key] ?? strings["en"]?[key] ?? key
}
static func locale(_ stored: String) -> Locale {
Locale(identifier: resolved(stored))
}
}
private let strings: [String: [String: String]] = [
"de": [
// Top bar / navigation
"nav.today": "Heute",
"nav.menu": "Menü",
"nav.done": "Fertig",
// View types
"view.month": "Monat",
"view.week": "Woche",
"view.day": "Tag",
"view.quarter": "Quartal",
"view.agenda": "Termine",
"view.change": "Ansicht",
// Calendar misc
"cal.cw": "KW",
"cal.allday": "Ganztägig",
"cal.no_events_title": "Keine Termine",
"cal.no_events_body": "In den nächsten 90 Tagen sind keine Termine vorhanden.",
"cal.loading_more": "Lade weitere Wochen…",
"cal.new_event": "Neues Ereignis",
"cal.show_in_day_view": "In Tagesansicht öffnen",
"cal.show_in_week_view": "In Wochenansicht öffnen",
"cal.show_in_month_view": "In Monatsansicht öffnen",
// Menu sheet
"menu.section.settings": "Einstellungen",
"menu.profile": "Profil",
"menu.appearance": "Darstellung",
"menu.accounts": "Konten & Kalender",
"menu.server": "Server",
"menu.logout": "Abmelden",
"menu.admin": "Admin",
"menu.sync": "Mit Server synchronisieren",
"menu.sync.section": "Synchronisierung",
// Settings – chrome
"settings.title": "Darstellung",
"settings.loading": "Lade Einstellungen…",
"settings.save": "Speichern",
"settings.saved": "Gespeichert",
// Settings – sections
"settings.appdesign": "App-Design",
"settings.liquidglass": "Liquid Glass",
"settings.liquidglass.desc": "Verwendet die neue iOS\u{202F}26 Glasoptik mit transparenter Navigationsleiste",
"settings.liquidglass.footer": "Änderung wirkt sofort – kein Neustart nötig.",
"settings.sync": "Einstellungen synchronisieren",
"settings.sync.desc": "Darstellung mit dem Server abgleichen",
"settings.sync.footer": "Wenn aktiv, werden Farben, Kontraste und Stundenhöhe mit dem Server abgeglichen (der Server hat Vorrang). Ansicht, erster Wochentag und das Ausgrauen vergangener Termine werden immer synchronisiert – auch wenn der Schalter aus ist.",
"settings.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.title": "Vorladen",
"settings.cache.desc": "Events werden beim Start im Hintergrund für diesen Zeitraum geladen, danach ist Wischen sofort.",
"settings.cache.range": "Zeitraum",
"settings.cache.1m": "±1 Monat",
"settings.cache.3m": "±3 Monate",
"settings.cache.6m": "±6 Monate",
"settings.cache.1y": "±1 Jahr",
"settings.cache.footer": "Mehr Monate = längerer initialer Ladevorgang, danach komplett ohne Wartezeiten navigierbar.",
"settings.language": "Sprache",
"lang.system": "Systemstandard",
"lang.german": "Deutsch",
"lang.english": "English",
"settings.colors": "Farben",
"settings.color.primary": "Primärfarbe",
"settings.color.accent": "Akzentfarbe",
"settings.color.today": "Heutige-Tag-Farbe",
"settings.color.divider": "Monatswechsel-Linie",
"settings.color.label": "Monatskürzel",
"settings.color.text": "Schriftfarbe",
"settings.color.background": "Hintergrundfarbe",
"settings.color.line": "Linienfarbe",
"settings.textcontrast": "Schriftkontrast",
"settings.textcontrast.desc": "Helligkeit der Beschriftungen und Texte",
"settings.contrast.dark": "Dunkel",
"settings.contrast.medium": "Mittel",
"settings.contrast.bright": "Hell",
"settings.contrast.max": "Maximum",
"settings.linecontrast": "Linienkontrast",
"settings.linecontrast.desc": "Sichtbarkeit von Trennlinien und Rahmen",
"settings.linecontrast.barely": "Kaum",
"settings.linecontrast.subtle": "Subtil",
"settings.linecontrast.normal": "Normal",
"settings.linecontrast.strong": "Stark",
"settings.calview": "Kalenderansicht",
"settings.defaultview": "Standardansicht",
"settings.firstweekday": "Erster Wochentag",
"settings.monday": "Montag",
"settings.sunday": "Sonntag",
"settings.dimpast": "Vergangene Termine ausgrauen",
"settings.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.privacy": "Privatsphäre",
"settings.private_visibility": "Private Termine für Gruppen",
"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.hidden": "Ausblenden",
"settings.calendars": "Geteilter Kalender",
"settings.group_visible": "Für Gruppen sichtbar",
"settings.group_visible.desc": "Wähle, welcher deiner Kalender für Gruppenmitglieder sichtbar ist",
"group.visible.none": "Keiner",
"profile.display_name": "Anzeigename",
"profile.login_name": "Login-Name",
"accounts.shared_by": "geteilt von %@",
"share.title": "Teilen",
"share.current": "Aktuelle Freigaben",
"share.none": "Noch nicht geteilt",
"share.add": "Benutzer hinzufügen",
"share.search": "Benutzer suchen…",
"share.permission": "Berechtigung",
"perm.read": "Nur lesen",
"perm.read_write": "Lesen & schreiben",
"ics.import": "Importieren",
"ics.export": "Exportieren",
"ics.import_result": "%d importiert, %d übersprungen",
"common.info": "Info",
"common.done": "Fertig",
"groups.title": "Gruppen",
"groups.personal": "Persönlich",
"groups.view_label": "Gruppenansicht",
"groups.exit": "Verlassen",
"groups.none": "Noch keine Gruppen",
"groups.combined_empty": "Keine Termine in diesem Zeitraum",
"group.create": "Gruppe erstellen",
"group.manage": "Gruppe verwalten",
"group.name": "Name",
"group.icon": "Icon",
"group.members": "Mitglieder",
"group.calendar": "Gruppenkalender",
"group.member_colors": "Farben der Mitglieder",
"group.delete": "Gruppe löschen",
"settings.hourheight": "Stundenhöhe",
"settings.hourheight.desc": "Platz pro Stunde in der Wochen- & Tagesansicht",
"settings.hourheight.compact": "Kompakt",
"settings.hourheight.normal": "Normal",
"settings.hourheight.comfort": "Komfort",
"settings.hourheight.large": "Gross",
// Common buttons
"common.cancel": "Abbrechen",
"common.close": "Schliessen",
"common.ok": "OK",
"common.error": "Fehler",
// Server view
"server.title": "Server",
"server.connected": "Verbundener Server",
"server.switch": "Server wechseln",
"server.switch_msg": "Verbindung zu %@ wird getrennt und alle lokalen Anmeldedaten werden gelöscht.",
"server.logout_title": "Abmelden",
"server.logout_msg": "Du wirst von %@ abgemeldet.",
"server.info": "Info",
"server.imprint": "Impressum",
"server.version": "Version",
// Imprint
"imprint.company": "Scarriffleservices",
"imprint.role": "Software & Webentwicklung",
"imprint.copyright": "Diese Software wurde von Scarriffleservices mit grösster Sorgfalt entwickelt und bereitgestellt. Alle Rechte vorbehalten © 2026 Scarriffleservices.",
"imprint.storage.title": "Datenspeicherung",
"imprint.storage.body": "Alle Anwendungsdaten werden auf dem Server gespeichert und verarbeitet, auf dem diese Calendarr-Instanz betrieben wird. Der Speicherort hängt damit vom Betreiber des jeweiligen Servers ab. Bei Nutzung der Google Kalender-Anbindung werden Daten über die Google API ausgetauscht; für diese Daten gelten die Datenschutzbestimmungen von Google. Bei Nutzung der Home Assistant-Anbindung werden Daten mit der jeweiligen Home Assistant-Instanz ausgetauscht. Home Assistant ist ein Projekt der Open Home Foundation.",
"imprint.disclaimer.title": "Haftungsausschluss",
"imprint.disclaimer.body": "Trotz sorgfältiger Erstellung wird keine Haftung für die Richtigkeit, Vollständigkeit oder Aktualität der bereitgestellten Inhalte übernommen. Die Nutzung erfolgt auf eigene Verantwortung.",
"imprint.contact.title": "Kontakt",
// Profile view
"profile.title": "Profil",
"profile.loading": "Lade Profil…",
"profile.account": "Konto",
"profile.username": "Benutzername",
"profile.role": "Rolle",
"profile.role.admin": "Administrator",
"profile.role.user": "Benutzer",
"profile.email": "E-Mail",
"profile.no_email": "Keine E-Mail",
"profile.save_email": "E-Mail speichern",
"profile.email_saved": "E-Mail gespeichert",
"profile.change_password": "Passwort ändern",
"profile.current_password": "Aktuelles Passwort",
"profile.new_password": "Neues Passwort",
"profile.new_password_repeat": "Neues Passwort wiederholen",
"profile.password_mismatch": "Passwörter stimmen nicht überein",
"profile.password_changed": "Passwort geändert",
"profile.twofa": "Zwei-Faktor-Authentifizierung",
"profile.twofa.active": "2FA ist aktiviert",
"profile.twofa.inactive": "2FA ist deaktiviert",
"profile.twofa.enable": "2FA einrichten",
"profile.twofa.disable": "2FA deaktivieren",
"profile.twofa.enabled_toast": "2FA aktiviert",
"profile.twofa.disabled_toast": "2FA deaktiviert",
"twofa.setup_title": "2FA einrichten",
"twofa.scan_hint": "Scanne den QR-Code mit deiner Authenticator-App (z.B. Bitwarden, Google Authenticator).",
"twofa.qr_section": "QR-Code / Manueller Schlüssel",
"twofa.confirmation": "Bestätigung",
"twofa.code_placeholder": "6-stelliger Code",
"twofa.activate": "Aktivieren",
"twofa.disable_title": "2FA deaktivieren",
"twofa.password_section": "Passwort zum Deaktivieren",
"twofa.password_placeholder": "Passwort",
"twofa.disable": "Deaktivieren",
// Event editor
"event.title_placeholder": "Titel",
"event.allday": "Ganztägig",
"event.private": "Privat",
"event.start": "Start",
"event.end": "Ende",
"event.location": "Ort",
"event.description": "Beschreibung",
"event.calendar_section": "Kalender",
"event.no_writable": "Keine beschreibbaren Kalender vorhanden",
"event.calendar_picker": "Kalender",
"event.color_section": "Farbe",
"event.color": "Terminfarbe",
"event.reset_color": "Zurücksetzen",
"event.edit_title": "Termin bearbeiten",
"event.new_title": "Neuer Termin",
"event.copy_title": "Termin kopieren",
"event.copy_to": "In Kalender kopieren",
"event.save": "Sichern",
"event.add": "Hinzufügen",
// Event detail
"detail.title": "Termin",
"detail.source": "Quelle",
"detail.created_by": "Erstellt von",
"detail.delete": "Termin löschen",
"detail.edit": "Bearbeiten",
"detail.delete_confirm_title": "Termin löschen?",
"detail.delete_msg_suffix": "wird dauerhaft gelöscht.",
"common.delete": "Löschen",
// Login / server setup
"login.username": "Benutzername",
"login.password": "Passwort",
"login.totp": "2FA-Code",
"login.totp_placeholder": "6-stelliger Code",
"login.remember": "Angemeldet bleiben",
"login.signin": "Anmelden",
"login.choose_server": "Anderen Server wählen",
// Single sign-on (OpenID Connect)
"sso.or": "oder",
"sso.login_with": "Mit %@ anmelden",
"sso.err.generic": "Anmeldung fehlgeschlagen",
"sso.err.oidc_failed": "Anmeldung fehlgeschlagen",
"sso.err.oidc_provider_unreachable": "Anmeldedienst nicht erreichbar",
"sso.err.oidc_guided_access": "Bei aktiviertem geführten Zugriff ist die SSO-Anmeldung nicht möglich",
"sso.err.oidc_refresh_failed": "Sitzung konnte nicht erneuert werden",
"sso.err.oidc_no_presenter": "Anmeldefenster konnte nicht geöffnet werden",
"sso.err.oidc_bad_issuer": "Der Anbieter ist falsch konfiguriert",
"sso.err.oidc_account_not_linked": "Kein verknüpftes Calendarr-Konto. Bitte lokal anmelden und in den Einstellungen verknüpfen.",
"sso.err.oidc_signup_disabled": "Automatische Kontoerstellung ist deaktiviert",
"sso.err.oidc_domain_not_allowed": "Diese E-Mail-Domain ist nicht zugelassen",
"sso.err.oidc_email_conflict": "Diese E-Mail-Adresse gehört bereits zu einem anderen Konto",
"sso.err.oidc_email_not_verified": "E-Mail-Adresse beim Anbieter nicht bestätigt",
"sso.err.oidc_no_email": "Der Anbieter hat keine E-Mail-Adresse übermittelt",
"sso.err.oidc_unknown_client": "Diese App ist beim Anbieter nicht registriert",
"sso.err.oidc_invalid_token": "Das Anmelde-Token ist ungültig",
"sso.err.oidc_no_id_token": "Der Anbieter hat kein ID-Token geliefert",
"server.connect_title": "Server verbinden",
"server.url": "Server-URL",
"server.connect": "Verbinden",
"server.unreachable": "Server nicht erreichbar. URL prüfen.",
"settings.email": "E-Mail",
// Accounts
"accounts.title": "Konten",
"accounts.loading": "Lade Konten…",
"accounts.add.caldav": "CalDAV-Konto",
"accounts.add.local": "Lokaler Kalender",
"accounts.add.birthday": "Geburtstagskalender",
"accounts.add.ical": "iCal-URL abonnieren",
"accounts.add.ha": "Home Assistant",
"accounts.caldav.header": "CalDAV-Konten",
"accounts.caldav.empty": "Keine CalDAV-Konten",
"accounts.caldav.add": "CalDAV hinzufügen",
"accounts.local.header": "Lokale Kalender",
"accounts.local.empty": "Keine lokalen Kalender",
"accounts.local.add": "Lokalen Kalender erstellen",
"accounts.ical.header": "iCal-Abonnements",
"accounts.ical.empty": "Keine Abonnements",
"accounts.ical.every": "Alle %d Min.",
"accounts.ical.add": "iCal-URL abonnieren",
"accounts.google.header": "Google-Konten",
"accounts.google.empty": "Keine Google-Konten",
"accounts.google.hint": "Google-Konten werden über den Browser verknüpft",
"accounts.ha.header": "Home Assistant",
"accounts.ha.empty": "Keine Home Assistant-Konten",
"accounts.ha.add": "Home Assistant hinzufügen",
"profile.admin_note": "Hinweis: Die Benutzerverwaltung – sowohl das Erstellen als auch das Löschen von Benutzerkonten – erfolgt ausschließlich durch den Administrator des Servers.",
// Kalender-Filter (Sidebar)
"filter.title": "Kalender",
"filter.loading": "Lade Kalender…",
"filter.empty": "Keine Kalender vorhanden",
"filter.show_all": "Alle anzeigen",
"filter.hide_all": "Alle ausblenden",
"filter.sort": "Sortieren",
"filter.done": "Fertig",
"filter.button": "Kalender ein-/ausblenden",
"filter.banish": "Dauerhaft ausblenden",
"filter.reminders_on": "Benachrichtigungen an",
"filter.reminders_off": "Benachrichtigungen aus",
"filter.banished_footer": "Dauerhaft ausgeblendete Kalender erscheinen unter »Konten & Kalender« und können dort wieder eingeblendet werden.",
"accounts.banished_header": "Ausgeblendete Kalender",
"accounts.banished_unhide": "Wieder einblenden",
"accounts.banished_unknown": "Unbekannter Kalender",
// CalDAV add sheet
"caldav.section": "Konto-Details",
"caldav.display_name": "Anzeigename",
"caldav.url": "CalDAV-URL",
"caldav.username": "Benutzername",
"caldav.password": "Passwort",
"caldav.color": "Farbe",
"caldav.color_label": "Farbe",
"caldav.color_section": "Farbe",
"caldav.connect": "Verbinden",
"caldav.title": "CalDAV-Konto",
// Local cal add sheet
"local.title": "Lokaler Kalender",
"local.name": "Name",
"local.color": "Farbe",
"local.create": "Erstellen",
// 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.title": "iCal abonnieren",
"ical.subscription": "Abonnement",
"ical.name": "Name",
"ical.url": "iCal-URL",
"ical.color": "Farbe",
"ical.refresh_section": "Aktualisierung",
"ical.interval": "Intervall",
"ical.subscribe": "Abonnieren",
"ical.refresh.15m": "Alle 15 Min.",
"ical.refresh.30m": "Alle 30 Min.",
"ical.refresh.1h": "Stündlich",
"ical.refresh.6h": "Alle 6 Std.",
"ical.refresh.1d": "Täglich",
// HA add sheet
"ha.section": "Home Assistant",
"ha.display_name": "Anzeigename",
"ha.url_placeholder": "URL (z.B. http://homeassistant.local:8123)",
"ha.auth_section": "Authentifizierung",
"ha.token": "Long-Lived Access Token",
"ha.token_hint": "Token erstellen unter: Profil → Sicherheit → Long-Lived Access Tokens",
"ha.connect": "Verbinden"
],
"en": [
"nav.today": "Today",
"nav.menu": "Menu",
"nav.done": "Done",
"view.month": "Month",
"view.week": "Week",
"view.day": "Day",
"view.quarter": "Quarter",
"view.agenda": "Agenda",
"view.change": "View",
"cal.cw": "W",
"cal.allday": "All-day",
"cal.no_events_title": "No events",
"cal.no_events_body": "No events in the next 90 days.",
"cal.loading_more": "Loading more weeks…",
"cal.new_event": "New event",
"cal.show_in_day_view": "Open in day view",
"cal.show_in_week_view": "Open in week view",
"cal.show_in_month_view": "Open in month view",
"menu.section.settings": "Settings",
"menu.profile": "Profile",
"menu.appearance": "Appearance",
"menu.accounts": "Accounts & Calendars",
"menu.server": "Server",
"menu.logout": "Sign out",
"menu.admin": "Admin",
"menu.sync": "Sync with server",
"menu.sync.section": "Synchronization",
"settings.title": "Appearance",
"settings.loading": "Loading settings…",
"settings.save": "Save",
"settings.saved": "Saved",
"settings.appdesign": "App design",
"settings.liquidglass": "Liquid Glass",
"settings.liquidglass.desc": "Uses the new iOS\u{202F}26 glass look with a translucent navigation bar",
"settings.liquidglass.footer": "Takes effect immediately – no restart required.",
"settings.sync": "Sync settings",
"settings.sync.desc": "Keep appearance in sync with the server",
"settings.sync.footer": "When on, colors, contrasts and hour height sync with the server (the server wins). View, first weekday and dimming past events always sync – even when the switch is off.",
"settings.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.title": "Preloading",
"settings.cache.desc": "Events are loaded in the background for this range on launch, so swiping is instant afterwards.",
"settings.cache.range": "Range",
"settings.cache.1m": "±1 month",
"settings.cache.3m": "±3 months",
"settings.cache.6m": "±6 months",
"settings.cache.1y": "±1 year",
"settings.cache.footer": "More months = longer initial load, but then fully wait-free navigation.",
"settings.language": "Language",
"lang.system": "System default",
"lang.german": "Deutsch",
"lang.english": "English",
"settings.colors": "Colors",
"settings.color.primary": "Primary color",
"settings.color.accent": "Accent color",
"settings.color.today": "Today color",
"settings.color.divider": "Month divider line",
"settings.color.label": "Month abbreviation",
"settings.color.text": "Text color",
"settings.color.background": "Background color",
"settings.color.line": "Line color",
"settings.textcontrast": "Text contrast",
"settings.textcontrast.desc": "Brightness of labels and text",
"settings.contrast.dark": "Dark",
"settings.contrast.medium": "Medium",
"settings.contrast.bright": "Bright",
"settings.contrast.max": "Maximum",
"settings.linecontrast": "Line contrast",
"settings.linecontrast.desc": "Visibility of dividers and borders",
"settings.linecontrast.barely": "Barely",
"settings.linecontrast.subtle": "Subtle",
"settings.linecontrast.normal": "Normal",
"settings.linecontrast.strong": "Strong",
"settings.calview": "Calendar view",
"settings.defaultview": "Default view",
"settings.firstweekday": "First day of week",
"settings.monday": "Monday",
"settings.sunday": "Sunday",
"settings.dimpast": "Dim past events",
"settings.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.privacy": "Privacy",
"settings.private_visibility": "Private events for groups",
"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.hidden": "Hide",
"settings.calendars": "Shared calendar",
"settings.group_visible": "Visible to groups",
"settings.group_visible.desc": "Choose which of your calendars group members can see",
"group.visible.none": "None",
"profile.display_name": "Display name",
"profile.login_name": "Login name",
"accounts.shared_by": "shared by %@",
"share.title": "Share",
"share.current": "Current shares",
"share.none": "Not shared yet",
"share.add": "Add user",
"share.search": "Search users…",
"share.permission": "Permission",
"perm.read": "Read only",
"perm.read_write": "Read & write",
"ics.import": "Import",
"ics.export": "Export",
"ics.import_result": "%d imported, %d skipped",
"common.info": "Info",
"common.done": "Done",
"groups.title": "Groups",
"groups.personal": "Personal",
"groups.view_label": "Group view",
"groups.exit": "Exit",
"groups.none": "No groups yet",
"groups.combined_empty": "No events in this period",
"group.create": "Create group",
"group.manage": "Manage group",
"group.name": "Name",
"group.icon": "Icon",
"group.members": "Members",
"group.calendar": "Group calendar",
"group.member_colors": "Member colours",
"group.delete": "Delete group",
"settings.hourheight": "Hour height",
"settings.hourheight.desc": "Space per hour in week & day view",
"settings.hourheight.compact": "Compact",
"settings.hourheight.normal": "Normal",
"settings.hourheight.comfort": "Comfort",
"settings.hourheight.large": "Large",
// Common buttons
"common.cancel": "Cancel",
"common.close": "Close",
"common.ok": "OK",
"common.error": "Error",
// Server view
"server.title": "Server",
"server.connected": "Connected server",
"server.switch": "Switch server",
"server.switch_msg": "The connection to %@ will be closed and all local credentials will be removed.",
"server.logout_title": "Sign out",
"server.logout_msg": "You will be signed out of %@.",
"server.info": "Info",
"server.imprint": "Legal notice",
"server.version": "Version",
// Imprint
"imprint.company": "Scarriffleservices",
"imprint.role": "Software & web development",
"imprint.copyright": "This software was carefully developed and provided by Scarriffleservices. All rights reserved © 2026 Scarriffleservices.",
"imprint.storage.title": "Data storage",
"imprint.storage.body": "All application data is stored and processed on the server on which this Calendarr instance is hosted. The storage location therefore depends on the operator of the respective server. When using the Google Calendar integration, data is exchanged via the Google API; Google's privacy policy applies to that data. When using the Home Assistant integration, data is exchanged with the respective Home Assistant instance. Home Assistant is a project of the Open Home Foundation.",
"imprint.disclaimer.title": "Disclaimer",
"imprint.disclaimer.body": "Despite careful preparation, no liability is assumed for the accuracy, completeness or topicality of the content provided. Use is at your own risk.",
"imprint.contact.title": "Contact",
// Profile view
"profile.title": "Profile",
"profile.loading": "Loading profile…",
"profile.account": "Account",
"profile.username": "Username",
"profile.role": "Role",
"profile.role.admin": "Administrator",
"profile.role.user": "User",
"profile.email": "Email",
"profile.no_email": "No email",
"profile.save_email": "Save email",
"profile.email_saved": "Email saved",
"profile.change_password": "Change password",
"profile.current_password": "Current password",
"profile.new_password": "New password",
"profile.new_password_repeat": "Repeat new password",
"profile.password_mismatch": "Passwords don't match",
"profile.password_changed": "Password changed",
"profile.twofa": "Two-factor authentication",
"profile.twofa.active": "2FA is enabled",
"profile.twofa.inactive": "2FA is disabled",
"profile.twofa.enable": "Set up 2FA",
"profile.twofa.disable": "Disable 2FA",
"profile.twofa.enabled_toast": "2FA enabled",
"profile.twofa.disabled_toast": "2FA disabled",
"twofa.setup_title": "Set up 2FA",
"twofa.scan_hint": "Scan the QR code with your authenticator app (e.g. Bitwarden, Google Authenticator).",
"twofa.qr_section": "QR code / Manual key",
"twofa.confirmation": "Verification",
"twofa.code_placeholder": "6-digit code",
"twofa.activate": "Activate",
"twofa.disable_title": "Disable 2FA",
"twofa.password_section": "Password to disable",
"twofa.password_placeholder": "Password",
"twofa.disable": "Disable",
// Event editor
"event.title_placeholder": "Title",
"event.allday": "All-day",
"event.private": "Private",
"event.start": "Start",
"event.end": "End",
"event.location": "Location",
"event.description": "Description",
"event.calendar_section": "Calendar",
"event.no_writable": "No writable calendars available",
"event.calendar_picker": "Calendar",
"event.color_section": "Color",
"event.color": "Event color",
"event.reset_color": "Reset",
"event.edit_title": "Edit event",
"event.new_title": "New event",
"event.copy_title": "Copy event",
"event.copy_to": "Copy to calendar",
"event.save": "Save",
"event.add": "Add",
// Event detail
"detail.title": "Event",
"detail.source": "Source",
"detail.created_by": "Created by",
"detail.delete": "Delete event",
"detail.edit": "Edit",
"detail.delete_confirm_title": "Delete event?",
"detail.delete_msg_suffix": "will be permanently deleted.",
"common.delete": "Delete",
// Login / server setup
"login.username": "Username",
"login.password": "Password",
"login.totp": "2FA code",
"login.totp_placeholder": "6-digit code",
"login.remember": "Stay signed in",
"login.signin": "Sign in",
"login.choose_server": "Choose another server",
// Single sign-on (OpenID Connect)
"sso.or": "or",
"sso.login_with": "Sign in with %@",
"sso.err.generic": "Sign-in failed",
"sso.err.oidc_failed": "Sign-in failed",
"sso.err.oidc_provider_unreachable": "Sign-in service unreachable",
"sso.err.oidc_guided_access": "SSO sign-in is not available while Guided Access is on",
"sso.err.oidc_refresh_failed": "Could not renew the session",
"sso.err.oidc_no_presenter": "Could not open the sign-in window",
"sso.err.oidc_bad_issuer": "The provider is misconfigured",
"sso.err.oidc_account_not_linked": "No linked Calendarr account. Sign in locally and link it in Settings.",
"sso.err.oidc_signup_disabled": "Automatic account creation is disabled",
"sso.err.oidc_domain_not_allowed": "This email domain is not allowed",
"sso.err.oidc_email_conflict": "That email address already belongs to another account",
"sso.err.oidc_email_not_verified": "Email address not verified at the provider",
"sso.err.oidc_no_email": "The provider did not supply an email address",
"sso.err.oidc_unknown_client": "This app is not registered with the provider",
"sso.err.oidc_invalid_token": "The sign-in token is invalid",
"sso.err.oidc_no_id_token": "The provider did not return an ID token",
"server.connect_title": "Connect server",
"server.url": "Server URL",
"server.connect": "Connect",
"server.unreachable": "Server unreachable. Check the URL.",
"settings.email": "Email",
// Accounts
"accounts.title": "Accounts",
"accounts.loading": "Loading accounts…",
"accounts.add.caldav": "CalDAV account",
"accounts.add.local": "Local calendar",
"accounts.add.birthday": "Birthday calendar",
"accounts.add.ical": "Subscribe to iCal URL",
"accounts.add.ha": "Home Assistant",
"accounts.caldav.header": "CalDAV accounts",
"accounts.caldav.empty": "No CalDAV accounts",
"accounts.caldav.add": "Add CalDAV",
"accounts.local.header": "Local calendars",
"accounts.local.empty": "No local calendars",
"accounts.local.add": "Create local calendar",
"accounts.ical.header": "iCal subscriptions",
"accounts.ical.empty": "No subscriptions",
"accounts.ical.every": "Every %d min",
"accounts.ical.add": "Subscribe to iCal URL",
"accounts.google.header": "Google accounts",
"accounts.google.empty": "No Google accounts",
"accounts.google.hint": "Google accounts are linked via the browser",
"accounts.ha.header": "Home Assistant",
"accounts.ha.empty": "No Home Assistant accounts",
"accounts.ha.add": "Add Home Assistant",
"profile.admin_note": "Note: User management — both the creation and deletion of user accounts — is handled exclusively by the server administrator.",
// Calendar filter (sidebar)
"filter.title": "Calendars",
"filter.loading": "Loading calendars…",
"filter.empty": "No calendars available",
"filter.show_all": "Show all",
"filter.hide_all": "Hide all",
"filter.sort": "Sort",
"filter.done": "Done",
"filter.button": "Show/hide calendars",
"filter.banish": "Hide permanently",
"filter.reminders_on": "Reminders on",
"filter.reminders_off": "Reminders off",
"filter.banished_footer": "Permanently hidden calendars appear under “Accounts & Calendars”, where you can show them again.",
"accounts.banished_header": "Hidden calendars",
"accounts.banished_unhide": "Show again",
"accounts.banished_unknown": "Unknown calendar",
// CalDAV add sheet
"caldav.section": "Account details",
"caldav.display_name": "Display name",
"caldav.url": "CalDAV URL",
"caldav.username": "Username",
"caldav.password": "Password",
"caldav.color": "Color",
"caldav.color_label": "Color",
"caldav.color_section": "Color",
"caldav.connect": "Connect",
"caldav.title": "CalDAV account",
// Local cal add sheet
"local.title": "Local calendar",
"local.name": "Name",
"local.color": "Color",
"local.create": "Create",
// 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.title": "Subscribe to iCal",
"ical.subscription": "Subscription",
"ical.name": "Name",
"ical.url": "iCal URL",
"ical.color": "Color",
"ical.refresh_section": "Refresh",
"ical.interval": "Interval",
"ical.subscribe": "Subscribe",
"ical.refresh.15m": "Every 15 min",
"ical.refresh.30m": "Every 30 min",
"ical.refresh.1h": "Hourly",
"ical.refresh.6h": "Every 6 hours",
"ical.refresh.1d": "Daily",
// HA add sheet
"ha.section": "Home Assistant",
"ha.display_name": "Display name",
"ha.url_placeholder": "URL (e.g. http://homeassistant.local:8123)",
"ha.auth_section": "Authentication",
"ha.token": "Long-Lived Access Token",
"ha.token_hint": "Create a token under: Profile → Security → Long-Lived Access Tokens",
"ha.connect": "Connect"
]
]
@@ -0,0 +1,103 @@
import Foundation
/// Reminder offset options (minutes before an event's start; 0 = at start) and
/// their localized labels. Shared by the event editor, the settings default
/// picker, and the notification scheduler so the choices stay consistent.
enum ReminderOptions {
/// Selectable offsets in minutes-before-start.
static let all: [Int] = [0, 5, 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 {
if appLang == "en" { return true }
if appLang == "de" { return false }
return (Locale.current.language.languageCode?.identifier ?? "de").hasPrefix("en")
}
static func label(_ minutes: Int, _ appLang: String) -> String {
let en = isEnglish(appLang)
if minutes <= 0 { return en ? "At start time" : "Zur Startzeit" }
if minutes < 60 {
return en ? "\(minutes) minutes before" : "\(minutes) Minuten vorher"
}
if minutes < 1440 {
let h = minutes / 60
if en { return h == 1 ? "1 hour before" : "\(h) hours before" }
return h == 1 ? "1 Stunde vorher" : "\(h) Stunden vorher"
}
if minutes < 10080 {
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 addLabel(_ l: String) -> String { isEnglish(l) ? "Add reminder" : "Benachrichtigung hinzufügen" }
static func off(_ l: String) -> String { isEnglish(l) ? "Off" : "Aus" }
static func defaultTitle(_ l: String) -> String { isEnglish(l) ? "Default reminder" : "Standardbenachrichtigung" }
static func defaultFooter(_ l: String) -> String {
isEnglish(l)
? "Applies to all events unless an event has its own reminders."
: "Gilt für alle Termine, sofern ein Termin keine eigenen Benachrichtigungen hat."
}
}
@@ -0,0 +1,82 @@
import BackgroundTasks
import Foundation
import CalendarrCore
/// Keeps the shared snapshot fresh while the app is not in the foreground.
///
/// Without this, `publishWidgetSnapshot()` only ever runs from a user action,
/// so an event created on the web or on Android does not reach the widgets — or
/// the watch — until the app is opened again. iOS decides when this actually
/// runs, based on how the user uses the app: it improves freshness, it does not
/// guarantee it.
@MainActor
enum BackgroundRefresh {
static let taskIdentifier = "com.scarriffleservices.calendarr.ios.refresh"
/// Minimum gap between runs we ask for. iOS treats this as the earliest
/// acceptable time, not a promise.
private static let interval: TimeInterval = 30 * 60
/// Call once at launch, before the app finishes starting up.
static func register() {
BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier,
using: nil) { task in
guard let refresh = task as? BGAppRefreshTask else {
task.setTaskCompleted(success: false)
return
}
Task { @MainActor in await handle(refresh) }
}
}
/// Ask for the next run. Safe to call repeatedly; a duplicate submission
/// replaces the pending request rather than queueing a second one.
static func schedule() {
let request = BGAppRefreshTaskRequest(identifier: taskIdentifier)
request.earliestBeginDate = Date(timeIntervalSinceNow: interval)
do { try BGTaskScheduler.shared.submit(request) }
catch {
// Submission fails on a simulator and when the user has disabled
// background refresh. Neither is worth interrupting anyone over.
print("[BackgroundRefresh] submit failed: \(error.localizedDescription)")
}
}
private static func handle(_ task: BGAppRefreshTask) async {
// Reschedule first: if the work below throws or is killed, the chain
// must not end here.
schedule()
let work = Task { @MainActor in await refreshSnapshot() }
task.expirationHandler = { work.cancel() }
let succeeded = await work.value
task.setTaskCompleted(success: succeeded)
}
/// Fetch the snapshot window and republish. Returns false when there was
/// nothing to do or the fetch failed.
private static func refreshSnapshot() async -> Bool {
let state = AppState()
guard state.isConfigured, state.isLoggedIn else { return false }
// An SSO session can lapse while the app is closed; renewing first
// avoids spending the whole background window on a 401.
await OIDCSessionRefresher.refreshIfNeeded(state)
let api = CalendarrAPI(baseURL: state.serverURL, token: state.authToken)
let store = CalendarStore()
let calendar = Calendar(identifier: .gregorian)
let dayStart = calendar.startOfDay(for: Date())
guard let from = calendar.date(byAdding: .day, value: -SnapshotCoverage.daysBehind, to: dayStart),
let to = calendar.date(byAdding: .day, value: SnapshotCoverage.daysAhead, to: dayStart)
else { return false }
// force: a freshly created store has an empty in-memory cache, so
// without this the "already cached" short-circuit would skip the fetch.
await store.loadEvents(api: api, start: from, end: to, force: true)
return store.lastError == nil
}
}
@@ -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)
}
}
@@ -0,0 +1,708 @@
import Foundation
enum APIError: LocalizedError {
case invalidURL
case unauthorized
case twoFactorRequired
case serverError(String)
case decodingError
var errorDescription: String? {
switch self {
case .invalidURL: return "Ungültige Server-URL"
case .unauthorized: return "Benutzername oder Passwort falsch"
case .twoFactorRequired: return "2FA-Code erforderlich"
case .serverError(let msg): return msg
case .decodingError: return "Antwort konnte nicht verarbeitet werden"
}
}
}
class CalendarrAPI {
let baseURL: String
let token: String
init(baseURL: String, token: String) {
self.baseURL = baseURL
self.token = token
}
private func request(_ path: String, method: String = "GET", body: [String: Any]? = nil) async throws -> Data {
guard let url = URL(string: baseURL + path) else { throw APIError.invalidURL }
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
if let body {
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
}
let (data, response) = try await URLSession.shared.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
if status == 401 { throw APIError.unauthorized }
if status >= 400 {
let msg = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["detail"] as? String ?? "Fehler \(status)"
throw APIError.serverError(msg)
}
return data
}
static func login(baseURL: String, username: String, password: String, totpCode: String? = nil, rememberMe: Bool = false) async throws -> (token: String, username: String, isAdmin: Bool) {
guard let url = URL(string: baseURL + "/api/auth/login") else { throw APIError.invalidURL }
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
var body: [String: Any] = ["username": username, "password": password, "remember_me": rememberMe]
if let code = totpCode { body["totp_code"] = code }
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
let (data, response) = try await URLSession.shared.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
if status == 401 {
let detail = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["detail"] as? String ?? ""
if detail == "2fa_required" { throw APIError.twoFactorRequired }
throw APIError.unauthorized
}
if status >= 400 {
let msg = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["detail"] as? String ?? "Fehler"
throw APIError.serverError(msg)
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let token = json["access_token"] as? String,
let user = json["user"] as? [String: Any],
let uname = user["username"] as? String else {
throw APIError.decodingError
}
let admin = user["is_admin"] as? Bool ?? false
// Persist id + display name for creator/owner comparisons and display.
UserDefaults.standard.set(user["id"] as? Int ?? 0, forKey: "userId")
UserDefaults.standard.set(user["display_name"] as? String ?? uname, forKey: "displayName")
return (token, uname, admin)
}
// MARK: - Single sign-on (OpenID Connect)
/// Providers configured on the server. Empty when SSO is off or the server
/// predates it — the password form is unaffected either way.
static func oidcProviders(baseURL: String) async throws -> [OIDCProvider] {
guard let url = URL(string: baseURL + "/api/auth/oidc/providers") else {
throw APIError.invalidURL
}
let (data, response) = try await URLSession.shared.data(from: url)
guard (response as? HTTPURLResponse)?.statusCode == 200,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
json["enabled"] as? Bool == true,
let list = json["providers"] as? [[String: Any]] else { return [] }
return list.compactMap { entry in
// Without a mobile client id the app cannot start a flow.
guard let key = entry["key"] as? String,
let clientID = entry["mobile_client_id"] as? String, !clientID.isEmpty,
let issuer = entry["issuer"] as? String else { return nil }
return OIDCProvider(
key: key,
name: (entry["name"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? key,
issuer: issuer,
// mobile_scopes is what the server wants the apps to request
// (it knows whether the provider grants offline_access).
scopes: (entry["mobile_scopes"] as? String)
?? (entry["scopes"] as? String)
?? "openid profile email",
mobileClientID: clientID
)
}
}
/// Trade a provider ID token for a Calendarr token.
///
/// The app is a public client: this request carries no secret, only the ID
/// token and the nonce AppAuth bound into the flow.
static func exchangeOIDC(baseURL: String,
provider: String,
clientID: String,
idToken: String,
accessToken: String?,
nonce: String?) async throws -> (token: String, username: String, isAdmin: Bool) {
guard let url = URL(string: baseURL + "/api/auth/oidc/exchange") else { throw APIError.invalidURL }
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
var body: [String: Any] = ["provider": provider, "client_id": clientID, "id_token": idToken]
if let accessToken { body["access_token"] = accessToken }
if let nonce { body["nonce"] = nonce }
req.httpBody = try? JSONSerialization.data(withJSONObject: body)
let (data, response) = try await URLSession.shared.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
if status >= 400 {
// The server answers with a stable slug the login screen translates.
let detail = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["detail"] as? String
throw OIDCError(slug: detail?.isEmpty == false ? detail! : "oidc_failed")
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let token = json["access_token"] as? String,
let user = json["user"] as? [String: Any],
let uname = user["username"] as? String else {
throw APIError.decodingError
}
let admin = user["is_admin"] as? Bool ?? false
// Same side effect as login(): creator/owner comparisons elsewhere read
// these back, so an SSO user must not be left with a zero userId.
UserDefaults.standard.set(user["id"] as? Int ?? 0, forKey: "userId")
UserDefaults.standard.set(user["display_name"] as? String ?? uname, forKey: "displayName")
return (token, uname, admin)
}
static func checkSetupRequired(baseURL: String) async throws -> Bool {
guard let url = URL(string: baseURL + "/api/auth/setup-required") else { throw APIError.invalidURL }
let (data, _) = try await URLSession.shared.data(from: url)
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
return json?["required"] as? Bool ?? false
}
func getSettings() async throws -> AppSettings {
let data = try await request("/api/settings/")
guard let settings = try? JSONDecoder().decode(AppSettings.self, from: data) else { throw APIError.decodingError }
return settings
}
func updateSettings(_ settings: AppSettings) async throws {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
guard let body = try? encoder.encode(settings),
let dict = try? JSONSerialization.jsonObject(with: body) as? [String: Any] else { return }
_ = try await request("/api/settings/", method: "PUT", body: dict)
}
func getProfile() async throws -> UserProfile {
let data = try await request("/api/auth/me")
guard let profile = try? JSONDecoder().decode(UserProfile.self, from: data) else { throw APIError.decodingError }
return profile
}
func updateEmail(_ email: String) async throws {
_ = try await request("/api/profile/", method: "PATCH", body: ["email": email])
}
func changePassword(current: String, new: String) async throws {
_ = try await request("/api/profile/password", method: "POST", body: ["current_password": current, "new_password": new])
}
func getCalDAVAccounts() async throws -> [CalDAVAccount] {
let data = try await request("/api/caldav/accounts")
return (try? JSONDecoder().decode([CalDAVAccount].self, from: data)) ?? []
}
func addCalDAVAccount(name: String, url: String, username: String, password: String, color: String) async throws -> CalDAVAccount {
let data = try await request("/api/caldav/accounts", method: "POST", body: [
"name": name, "url": url, "username": username, "password": password, "color": color
])
guard let acc = try? JSONDecoder().decode(CalDAVAccount.self, from: data) else { throw APIError.decodingError }
return acc
}
func deleteCalDAVAccount(id: Int) async throws {
_ = try await request("/api/caldav/accounts/\(id)", method: "DELETE")
}
func getLocalCalendars() async throws -> [LocalCalendar] {
let data = try await request("/api/local/calendars")
return (try? JSONDecoder().decode([LocalCalendar].self, from: data)) ?? []
}
func addLocalCalendar(name: String, color: String,
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 }
return cal
}
func deleteLocalCalendar(id: Int) async throws {
_ = 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] {
let data = try await request("/api/ical/subscriptions")
return (try? JSONDecoder().decode([ICalSubscription].self, from: data)) ?? []
}
func addICalSubscription(name: String, url: String, color: String, refreshMinutes: Int) async throws -> ICalSubscription {
let data = try await request("/api/ical/subscriptions", method: "POST", body: [
"name": name, "url": url, "color": color, "refresh_minutes": refreshMinutes
])
guard let sub = try? JSONDecoder().decode(ICalSubscription.self, from: data) else { throw APIError.decodingError }
return sub
}
func deleteICalSubscription(id: Int) async throws {
_ = try await request("/api/ical/subscriptions/\(id)", method: "DELETE")
}
func getGoogleAccounts() async throws -> [GoogleAccount] {
let data = try await request("/api/google/accounts")
return (try? JSONDecoder().decode([GoogleAccount].self, from: data)) ?? []
}
func deleteGoogleAccount(id: Int) async throws {
_ = try await request("/api/google/accounts/\(id)", method: "DELETE")
}
func getHomeAssistantAccounts() async throws -> [HomeAssistantAccount] {
let data = try await request("/api/homeassistant/accounts")
return (try? JSONDecoder().decode([HomeAssistantAccount].self, from: data)) ?? []
}
func addHomeAssistantAccount(name: String, url: String, token: String) async throws -> HomeAssistantAccount {
let data = try await request("/api/homeassistant/accounts", method: "POST", body: [
"name": name, "url": url, "token": token, "auth_method": "token"
])
guard let acc = try? JSONDecoder().decode(HomeAssistantAccount.self, from: data) else { throw APIError.decodingError }
return acc
}
func deleteHomeAssistantAccount(id: Int) async throws {
_ = try await request("/api/homeassistant/accounts/\(id)", method: "DELETE")
}
func setup2FA() async throws -> (secret: String, qrUrl: String) {
let data = try await request("/api/profile/2fa/setup", method: "POST")
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let secret = json["secret"] as? String,
let qr = json["qr_url"] as? String else { throw APIError.decodingError }
return (secret, qr)
}
func enable2FA(code: String) async throws {
_ = try await request("/api/profile/2fa/enable", method: "POST", body: ["code": code])
}
func disable2FA(password: String) async throws {
_ = try await request("/api/profile/2fa/disable", method: "POST", body: ["password": password])
}
// MARK: – Events
/// 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
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime]
iso.timeZone = TimeZone(abbreviation: "UTC")
let s = iso.string(from: start) // e.g. "2026-05-01T00:00:00Z"
let e = iso.string(from: end)
let sEnc = s.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? s
let eEnc = e.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? e
let data = try await request("/api/caldav/events?start=\(sEnc)&end=\(eEnc)")
// Server returns {"events": [...], "errors": [...]}
guard
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let arr = root["events"] as? [[String: Any]]
else {
let preview = String(data: data, encoding: .utf8).map { String($0.prefix(200)) } ?? "no data"
throw APIError.serverError("Unerwartete Antwort: \(preview)")
}
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,
isAllDay: Bool, location: String, description: String, color: String?,
isPrivate: Bool = false, reminders: [Int]? = nil,
rrule: String? = nil, externalUid: String? = nil,
birthYear: Int? = nil) async throws -> CalEvent {
var body: [String: Any] = [
"calendar_id": calendarId,
"title": title,
"start": formatISO(start, allDay: isAllDay),
"end": formatISO(end, allDay: isAllDay),
"allDay": isAllDay,
"location": location,
"description": description,
"private": isPrivate
]
if let c = color, !c.isEmpty { body["color"] = c }
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)
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let ev = CalEvent.from(json: json) else { throw APIError.decodingError }
return ev
}
func updateLocalEvent(uid: String, title: String, start: Date, end: Date,
isAllDay: Bool, location: String, description: String, color: String?,
isPrivate: Bool = false, reminders: [Int]? = nil,
rrule: String? = nil, externalUid: String? = nil,
birthYear: Int? = nil) async throws {
var body: [String: Any] = [
"title": title,
"start": formatISO(start, allDay: isAllDay),
"end": formatISO(end, allDay: isAllDay),
"allDay": isAllDay,
"location": location,
"description": description,
"private": isPrivate
]
if let c = color { body["color"] = c }
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)
}
func deleteLocalEvent(uid: String) async throws {
_ = try await request("/api/local/events/\(uid)", method: "DELETE")
}
func createCalDAVEvent(calendarId: Int, title: String, start: Date, end: Date,
isAllDay: Bool, location: String, description: String, color: String?) async throws {
var body: [String: Any] = [
"calendar_id": calendarId,
"title": title,
"start": formatISO(start, allDay: isAllDay),
"end": formatISO(end, allDay: isAllDay),
"allDay": isAllDay,
"location": location,
"description": description
]
if let c = color, !c.isEmpty { body["color"] = c }
_ = try await request("/api/caldav/events", method: "POST", body: body)
}
func updateCalDAVEvent(uid: String, url: String, calendarId: Int?, title: String,
start: Date, end: Date, isAllDay: Bool,
location: String, description: String, color: String?) async throws {
var body: [String: Any] = [
"title": title,
"start": formatISO(start, allDay: isAllDay),
"end": formatISO(end, allDay: isAllDay),
"allDay": isAllDay,
"location": location,
"description": description
]
if let c = color { body["color"] = c }
let encURL = url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? url
var path = "/api/caldav/events/\(uid)?event_url=\(encURL)"
if let cid = calendarId { path += "&calendar_id=\(cid)" }
_ = try await request(path, method: "PUT", body: body)
}
func deleteCalDAVEvent(uid: String, url: String, calendarId: Int?) async throws {
let encURL = url.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? url
var path = "/api/caldav/events/\(uid)?event_url=\(encURL)"
if let cid = calendarId { path += "&calendar_id=\(cid)" }
_ = try await request(path, method: "DELETE")
}
// MARK: – Google Calendar events
func getGoogleCalendars() async throws -> [(accountEmail: String, calId: Int, name: String, color: String)] {
let data = try await request("/api/google/accounts")
guard let accounts = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] }
var result: [(String, Int, String, String)] = []
for acc in accounts {
let email = acc["email"] as? String ?? "Google"
let cals = acc["calendars"] as? [[String: Any]] ?? []
for cal in cals where (cal["enabled"] as? Bool ?? true) {
if let id = cal["id"] as? Int, let name = cal["name"] as? String {
let color = cal["color"] as? String ?? "#4285f4"
result.append((email, id, name, color))
}
}
}
return result
}
func createGoogleEvent(calendarDbId: Int, title: String, start: Date, end: Date,
isAllDay: Bool, location: String, description: String) async throws {
let body: [String: Any] = [
"calendar_db_id": calendarDbId,
"title": title,
"start": formatISO(start, allDay: isAllDay),
"end": formatISO(end, allDay: isAllDay),
"allDay": isAllDay,
"location": location,
"description": description
]
_ = try await request("/api/google/events", method: "POST", body: body)
}
// MARK: – Home Assistant events
func getHACalendars() async throws -> [(accountName: String, calId: Int, name: String, color: String)] {
let data = try await request("/api/homeassistant/accounts")
guard let accounts = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] }
var result: [(String, Int, String, String)] = []
for acc in accounts {
let aName = acc["name"] as? String ?? "Home Assistant"
let cals = acc["calendars"] as? [[String: Any]] ?? []
for cal in cals where (cal["enabled"] as? Bool ?? true) {
if let id = cal["id"] as? Int, let name = cal["name"] as? String {
let color = cal["color"] as? String ?? "#46bdc6"
result.append((aName, id, name, color))
}
}
}
return result
}
func createHAEvent(calendarId: Int, title: String, start: Date, end: Date,
isAllDay: Bool, location: String, description: String) async throws {
let body: [String: Any] = [
"calendar_id": calendarId,
"title": title,
"start": formatISO(start, allDay: isAllDay),
"end": formatISO(end, allDay: isAllDay),
"allDay": isAllDay,
"location": location,
"description": description
]
_ = try await request("/api/homeassistant/events", method: "POST", body: body)
}
/// Delete a Home Assistant calendar event.
/// `calendarId` is the numeric HA-calendar DB id; `uid` is the HA event uid.
func deleteHAEvent(calendarId: Int, uid: String) async throws {
// uid is a path segment and may contain "/" or other reserved chars.
let allowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/"))
let encUid = uid.addingPercentEncoding(withAllowedCharacters: allowed) ?? uid
_ = try await request("/api/homeassistant/events/\(calendarId)/\(encUid)", method: "DELETE")
}
// MARK: – Calendar visibility (sidebar_hidden)
/// Toggle a calendar's server-side visibility. Mirrors the web: hiding sets
/// `enabled=false, sidebar_hidden=true` (server then omits its events);
/// showing sets `enabled=true, sidebar_hidden=false`. Only CalDAV / Google /
/// Home Assistant have this flag; `local` / `ical` are a no-op.
func setCalendarSidebarHidden(source: String, calendarId: Int, hidden: Bool) async throws {
let path: String
switch source {
case "caldav": path = "/api/caldav/calendars/\(calendarId)"
case "google": path = "/api/google/calendars/\(calendarId)"
case "homeassistant": path = "/api/homeassistant/calendars/\(calendarId)"
default: return
}
_ = try await request(path, method: "PUT",
body: ["enabled": !hidden, "sidebar_hidden": hidden])
}
/// Toggle a calendar's server-side `reminders_enabled` flag. Supported for
/// all source types (caldav/google/homeassistant/local/ical).
func setCalendarRemindersEnabled(source: String, calendarId: Int, enabled: Bool) async throws {
let path: String
switch source {
case "caldav": path = "/api/caldav/calendars/\(calendarId)"
case "google": path = "/api/google/calendars/\(calendarId)"
case "homeassistant": path = "/api/homeassistant/calendars/\(calendarId)"
case "local": path = "/api/local/calendars/\(calendarId)"
case "ical": path = "/api/ical/subscriptions/\(calendarId)"
default: return
}
_ = try await request(path, method: "PUT", body: ["reminders_enabled": enabled])
}
// 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 {
_ = try await request("/api/local/calendars/\(id)/color", method: "PUT", body: ["color": color])
}
func updateICalColor(id: Int, color: String) async throws {
_ = try await request("/api/ical/subscriptions/\(id)", method: "PUT", body: ["color": color])
}
/// Set a per-calendar colour for server-managed sources (caldav/google/homeassistant).
func setCalendarColor(source: String, calendarId: Int, color: String) async throws {
let path: String
switch source {
case "caldav": path = "/api/caldav/calendars/\(calendarId)"
case "google": path = "/api/google/calendars/\(calendarId)"
case "homeassistant": path = "/api/homeassistant/calendars/\(calendarId)"
default: return
}
_ = try await request(path, method: "PUT", body: ["color": color])
}
// MARK: – Profile (display name / login name / email)
/// Update profile fields. A login-name change returns a fresh token (the old
/// one becomes invalid) — the caller must store the returned token.
func updateProfile(displayName: String?, username: String?, email: String?,
directoryHidden: Bool? = nil) async throws -> String? {
var body: [String: Any] = [:]
if let d = displayName { body["display_name"] = d }
if let u = username { body["username"] = u }
if let e = email { body["email"] = e } else { body["email"] = NSNull() }
if let h = directoryHidden { body["directory_hidden"] = h }
let data = try await request("/api/profile/", method: "PUT", body: body)
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
return json?["access_token"] as? String
}
// MARK: – Targeted settings (avoid overwriting the whole AppSettings)
func updatePrivateVisibility(_ value: String) async throws {
_ = try await request("/api/settings/", method: "PUT", body: ["private_event_visibility": value])
}
func updateGroupVisibleCalendar(_ calendarId: Int?) async throws {
_ = try await request("/api/settings/", method: "PUT",
body: ["group_visible_calendar_id": calendarId as Any? ?? NSNull()])
}
// MARK: – Sharing
func getUserDirectory() async throws -> [DirectoryUser] {
let data = try await request("/api/users/directory")
return (try? JSONDecoder().decode([DirectoryUser].self, from: data)) ?? []
}
func getShares(calendarId: Int) async throws -> [CalendarShare] {
let data = try await request("/api/local/calendars/\(calendarId)/shares")
return (try? JSONDecoder().decode([CalendarShare].self, from: data)) ?? []
}
func addShare(calendarId: Int, userId: Int, permission: String) async throws {
_ = try await request("/api/local/calendars/\(calendarId)/shares", method: "POST",
body: ["user_id": userId, "permission": permission])
}
func removeShare(calendarId: Int, userId: Int) async throws {
_ = try await request("/api/local/calendars/\(calendarId)/shares/\(userId)", method: "DELETE")
}
// MARK: – Groups
func getGroups() async throws -> [CalGroup] {
let data = try await request("/api/groups/")
return (try? JSONDecoder().decode([CalGroup].self, from: data)) ?? []
}
func getGroup(id: Int) async throws -> CalGroup {
let data = try await request("/api/groups/\(id)")
guard let g = try? JSONDecoder().decode(CalGroup.self, from: data) else { throw APIError.decodingError }
return g
}
func createGroup(name: String, memberIds: [Int], icon: String?) async throws -> CalGroup {
var body: [String: Any] = ["name": name, "member_ids": memberIds]
if let icon { body["icon"] = icon }
let data = try await request("/api/groups/", method: "POST", body: body)
guard let g = try? JSONDecoder().decode(CalGroup.self, from: data) else { throw APIError.decodingError }
return g
}
func updateGroup(id: Int, name: String?, icon: String?) async throws {
var body: [String: Any] = [:]
if let name { body["name"] = name }
if let icon { body["icon"] = icon }
_ = try await request("/api/groups/\(id)", method: "PUT", body: body)
}
func deleteGroup(id: Int) async throws {
_ = try await request("/api/groups/\(id)", method: "DELETE")
}
func addGroupMember(groupId: Int, userId: Int) async throws {
_ = try await request("/api/groups/\(groupId)/members", method: "POST", body: ["user_id": userId])
}
func removeGroupMember(groupId: Int, userId: Int) async throws {
_ = try await request("/api/groups/\(groupId)/members/\(userId)", method: "DELETE")
}
func setGroupMemberColor(groupId: Int, userId: Int, color: String) async throws {
_ = try await request("/api/groups/\(groupId)/members/\(userId)/color", method: "PUT",
body: ["color": color])
}
func fetchGroupCombined(groupId: Int, start: Date, end: Date) async throws -> [CalEvent] {
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime]
iso.timeZone = TimeZone(abbreviation: "UTC")
let s = iso.string(from: start)
let e = iso.string(from: end)
let sEnc = s.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? s
let eEnc = e.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? e
let data = try await request("/api/groups/\(groupId)/combined?start=\(sEnc)&end=\(eEnc)")
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let arr = root["events"] as? [[String: Any]] else { return [] }
return arr.compactMap { CalEvent.from(json: $0) }
}
// MARK: – iCal import / export
/// Import a .ics file into a local calendar. Returns (imported, skipped, errors).
func importICS(calendarId: Int, fileURL: URL) async throws -> (imported: Int, skipped: Int, errors: [String]) {
guard let url = URL(string: baseURL + "/api/local/calendars/\(calendarId)/import") else { throw APIError.invalidURL }
let fileData = try Data(contentsOf: fileURL)
let boundary = "Boundary-\(UUID().uuidString)"
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
var bodyData = Data()
let filename = fileURL.lastPathComponent.isEmpty ? "import.ics" : fileURL.lastPathComponent
bodyData.append("--\(boundary)\r\n".data(using: .utf8)!)
bodyData.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
bodyData.append("Content-Type: text/calendar\r\n\r\n".data(using: .utf8)!)
bodyData.append(fileData)
bodyData.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
req.httpBody = bodyData
let (data, response) = try await URLSession.shared.data(for: req)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
if status == 401 { throw APIError.unauthorized }
if status >= 400 {
let msg = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["detail"] as? String ?? "Fehler \(status)"
throw APIError.serverError(msg)
}
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
let errs = (json?["errors"] as? [String]) ?? []
return (json?["imported"] as? Int ?? 0, json?["skipped"] as? Int ?? 0, errs)
}
/// Export a local calendar as raw .ics bytes.
func exportICS(calendarId: Int) async throws -> Data {
return try await request("/api/local/calendars/\(calendarId)/export")
}
}
@@ -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)"
}
}
}
@@ -0,0 +1,83 @@
import Foundation
import UserNotifications
extension Notification.Name {
/// Posted when the default-reminder setting changes, so the calendar host
/// can recompute the scheduled local notifications from its cached events.
static let rescheduleReminders = Notification.Name("rescheduleReminders")
}
/// Schedules local OS notifications for upcoming events. Per-event reminders
/// (local events) take precedence; otherwise the user's default reminder applies
/// to every event (incl. external). Re-run whenever events or the default change.
enum NotificationScheduler {
static func requestAuthorizationIfNeeded() {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in }
}
/// Recompute and (re)schedule notifications from the given events. The iOS
/// pending-notification cap is 64, so only the soonest are scheduled.
/// `disabledCalendarKeys` ("source:id") are calendars with reminders turned
/// off — their events never generate notifications.
static func reschedule(events: [CalEvent], disabledCalendarKeys: Set<String> = []) {
let center = UNUserNotificationCenter.current()
center.getNotificationSettings { settings in
guard settings.authorizationStatus == .authorized
|| settings.authorizationStatus == .provisional else { return }
let defaultMin = (UserDefaults.standard.object(forKey: "defaultReminderMinutes") as? Int) ?? -1
let now = Date()
var pending: [(fire: Date, event: CalEvent)] = []
for ev in events {
// Skip calendars the user muted for reminders.
if !disabledCalendarKeys.isEmpty {
let key = CalendarStore.calendarKey(source: ev.source, calendarId: ev.calendarId)
if disabledCalendarKeys.contains(key) { continue }
}
let offsets = ev.reminders.isEmpty
? (defaultMin >= 0 ? [defaultMin] : [])
: ev.reminders
for m in offsets {
let fire = ev.startDate.addingTimeInterval(-Double(m) * 60)
if fire > now { pending.append((fire, ev)) }
}
}
pending.sort { $0.fire < $1.fire }
let limited = pending.prefix(60) // stay safely under the 64 system cap
center.removeAllPendingNotificationRequests()
for item in limited {
let content = UNMutableNotificationContent()
content.title = item.event.title
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
let comps = Calendar.current.dateComponents(
[.year, .month, .day, .hour, .minute, .second], from: item.fire)
let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: false)
center.add(UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger))
}
}
}
private static func 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 {
var parts: [String] = []
if !ev.isAllDay {
let f = DateFormatter(); f.timeStyle = .short; f.dateStyle = .none
parts.append(f.string(from: ev.startDate))
}
if !ev.location.isEmpty { parts.append(ev.location) }
return parts.joined(separator: " · ")
}
}
@@ -0,0 +1,259 @@
import AppAuth
import Foundation
import UIKit
/// Redirect target for the SSO round-trip. Reverse-DNS on the bundle id, per
/// RFC 8252; the same value has to be registered in the identity provider.
///
/// It is deliberately not server-configurable: the app can only ever receive a
/// scheme it owns, so the provider has to be registered with this exact value.
///
/// No `CFBundleURLTypes` entry is needed on the normal path — AppAuth's iOS
/// user agent uses `ASWebAuthenticationSession`, which intercepts the callback
/// itself. See `guidedAccessBlocksLogin` for the one exception.
private let redirectURI = "com.scarriffleservices.calendarr.ios:/oauth2redirect"
/// Keychain key for the serialised `OIDAuthState` (contains the refresh token).
private let authStateKey = "oidcAuthState"
/// UserDefaults key for the provider that SSO session belongs to.
let oidcProviderDefaultsKey = "oidcProvider"
/// A provider offered by the Calendarr server (`GET /api/auth/oidc/providers`).
struct OIDCProvider: Identifiable, Equatable {
let key: String
let name: String
let issuer: String
/// Exactly what the server told us to request. The app must not add scopes
/// of its own — one the provider does not know makes it reject the whole
/// authorization request.
let scopes: String
let mobileClientID: String
var id: String { key }
}
/// An SSO failure carrying the server's stable slug, which `LoginView` maps to
/// a translated message. `underlying` is for the log, never for the user.
struct OIDCError: LocalizedError {
let slug: String
var underlying: String?
var errorDescription: String? { slug }
}
/// Result of a completed provider flow, ready to be exchanged with Calendarr.
struct OIDCTokens {
let idToken: String
let accessToken: String?
let nonce: String?
/// Serialised `OIDAuthState`, including the refresh token.
let serialisedState: String?
}
/// Drives the OpenID Connect Authorization Code Flow with PKCE.
///
/// The login page always opens in `ASWebAuthenticationSession` (AppAuth's
/// default external user agent on iOS) — never in an embedded web view, so the
/// credentials stay in the browser's security context and the provider's own
/// session cookies keep working.
@MainActor
final class OIDCAuthenticator {
/// AppAuth requires the in-flight session to stay alive for the round-trip.
private var session: OIDExternalUserAgentSession?
/// Under Guided Access, AppAuth falls back to `SFSafariViewController`,
/// whose redirect can only come back through a registered URL scheme — and
/// the app target generates its Info.plist, which has no way to declare
/// `CFBundleURLTypes`. Rather than hang forever waiting for a callback that
/// cannot arrive, refuse up front.
var guidedAccessBlocksLogin: Bool { UIAccessibility.isGuidedAccessEnabled }
func discover(issuer: String) async throws -> OIDServiceConfiguration {
guard let url = URL(string: issuer) else { throw OIDCError(slug: "oidc_bad_issuer") }
return try await withCheckedThrowingContinuation { cont in
OIDAuthorizationService.discoverConfiguration(forIssuer: url) { config, error in
if let config {
cont.resume(returning: config)
} else {
cont.resume(throwing: OIDCError(slug: "oidc_provider_unreachable",
underlying: Self.describe(error)))
}
}
}
}
/// Run the full flow and return the provider's tokens.
///
/// `OIDAuthorizationRequest` generates state, nonce and the PKCE verifier
/// itself. The nonce has to travel to our server with the ID token so it
/// can verify the binding — this is the app's replay protection.
func authorize(provider: OIDCProvider) async throws -> OIDCTokens {
if guidedAccessBlocksLogin { throw OIDCError(slug: "oidc_guided_access") }
let config = try await discover(issuer: provider.issuer)
var scopes = provider.scopes
.split(separator: " ")
.map(String.init)
.filter { !$0.isEmpty }
if !scopes.contains(OIDScopeOpenID) { scopes.append(OIDScopeOpenID) }
guard let redirectURL = URL(string: redirectURI) else {
throw OIDCError(slug: "oidc_bad_redirect")
}
let request = OIDAuthorizationRequest(
configuration: config,
clientId: provider.mobileClientID,
clientSecret: nil, // public client — never send a secret
scopes: scopes,
redirectURL: redirectURL,
responseType: OIDResponseTypeCode,
additionalParameters: nil
)
guard let presenter = Self.topViewController() else {
throw OIDCError(slug: "oidc_no_presenter")
}
// Clear the retained session on every exit, not just the happy one.
defer { session = nil }
let authState: OIDAuthState = try await withCheckedThrowingContinuation { cont in
session = OIDAuthState.authState(byPresenting: request,
presenting: presenter) { state, error in
if let state {
cont.resume(returning: state)
} else if Self.isCancellation(error) {
cont.resume(throwing: OIDCError(slug: "oidc_cancelled"))
} else {
cont.resume(throwing: OIDCError(slug: "oidc_failed",
underlying: Self.describe(error)))
}
}
}
guard let idToken = authState.lastTokenResponse?.idToken else {
throw OIDCError(slug: "oidc_no_id_token")
}
return OIDCTokens(
idToken: idToken,
accessToken: authState.lastTokenResponse?.accessToken,
nonce: request.nonce,
serialisedState: Self.serialise(authState)
)
}
// MARK: - Silent renewal
/// Ask the provider for a fresh ID token using the stored refresh token.
///
/// Returns nil when there is no SSO session to renew. The refreshed state
/// is written back, because the provider may have rotated the refresh
/// token.
func renewIDToken() async throws -> (idToken: String, accessToken: String?, nonce: String?)? {
guard let stored = try? KeychainStore.get(authStateKey),
let state = Self.deserialise(stored) else { return nil }
let tokens: (String?, String?) = try await withCheckedThrowingContinuation { cont in
state.performAction { accessToken, idToken, error in
if let error {
cont.resume(throwing: OIDCError(slug: "oidc_refresh_failed",
underlying: Self.describe(error)))
} else {
cont.resume(returning: (idToken, accessToken))
}
}
}
guard let idToken = tokens.0 else { return nil }
if let refreshed = Self.serialise(state) {
try? KeychainStore.set(refreshed, for: authStateKey)
}
// The original request travels inside the stored state, so the nonce
// is still available. A refreshed token often drops the claim, which
// the server tolerates — but when it keeps it, it has to match.
let nonce = state.lastAuthorizationResponse.request.nonce
return (idToken, tokens.1, nonce)
}
/// Persist the SSO session so it can be renewed without another browser trip.
static func store(state: String?, providerKey: String) {
guard let state else { return }
try? KeychainStore.set(state, for: authStateKey)
UserDefaults.standard.set(providerKey, forKey: oidcProviderDefaultsKey)
}
/// The provider key of the active SSO session, if any.
static var storedProviderKey: String? {
UserDefaults.standard.string(forKey: oidcProviderDefaultsKey)
}
static func clearStoredSession() {
try? KeychainStore.set(nil, for: authStateKey)
// Mirror the authToken handling: a pre-entitlement copy may exist in
// the app's own keychain group.
try? KeychainStore.set(nil, for: authStateKey, accessGroup: nil)
UserDefaults.standard.removeObject(forKey: oidcProviderDefaultsKey)
}
// MARK: - Helpers
private static func serialise(_ state: OIDAuthState) -> String? {
do {
let data = try NSKeyedArchiver.archivedData(withRootObject: state,
requiringSecureCoding: true)
return data.base64EncodedString()
} catch {
log("archiving OIDAuthState failed: \(error)")
return nil
}
}
private static func deserialise(_ raw: String) -> OIDAuthState? {
guard let data = Data(base64Encoded: raw) else { return nil }
return try? NSKeyedUnarchiver.unarchivedObject(ofClass: OIDAuthState.self, from: data)
}
/// AppAuth reports both a user-initiated and a programmatic cancel; neither
/// is a failure worth showing. Checking the domain as well as the code
/// avoids matching an unrelated error that happens to share the number.
private static func isCancellation(_ error: Error?) -> Bool {
guard let ns = error as NSError? else { return false }
guard ns.domain == OIDGeneralErrorDomain else { return false }
return ns.code == OIDErrorCode.userCanceledAuthorizationFlow.rawValue
|| ns.code == OIDErrorCode.programCanceledAuthorizationFlow.rawValue
}
/// Detail for the log only — never shown to the user.
private static func describe(_ error: Error?) -> String? {
guard let ns = error as NSError? else { return nil }
let oauth = ns.userInfo[OIDOAuthErrorResponseErrorKey] as? [String: Any]
let detail = (oauth?["error"] as? String).map { " oauth=\($0)" } ?? ""
let text = "\(ns.domain)/\(ns.code): \(ns.localizedDescription)\(detail)"
log(text)
return text
}
private static func log(_ message: String) {
#if DEBUG
print("[OIDC] \(message)")
#endif
}
/// Topmost view controller to present the browser sheet from. Works under
/// Mac Catalyst too, where a presentation anchor is likewise required.
private static func topViewController() -> UIViewController? {
let scene = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first { $0.activationState == .foregroundActive }
?? UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first
var top = scene?.keyWindow?.rootViewController
while let presented = top?.presentedViewController {
top = presented
}
return top
}
}
@@ -0,0 +1,65 @@
import Foundation
/// Keeps an SSO session alive without sending the user back through the browser.
///
/// Calendarr's own token has no refresh mechanism — it simply expires. What the
/// `offline_access` scope buys is a refresh token at the *identity provider*, so
/// renewal means: get a fresh ID token from the provider, then trade it for a
/// fresh Calendarr token at `/api/auth/oidc/exchange`.
@MainActor
enum OIDCSessionRefresher {
/// Renew when less than this much of the Calendarr token's life is left.
private static let renewWithin: TimeInterval = 24 * 60 * 60
/// Best-effort renewal at launch. Any failure is swallowed: the existing
/// token is still valid, and a hard failure would only surface later as a
/// normal 401.
static func refreshIfNeeded(_ appState: AppState) async {
guard !appState.authToken.isEmpty,
let providerKey = OIDCAuthenticator.storedProviderKey,
expiresSoon(appState.authToken) else { return }
do {
let authenticator = OIDCAuthenticator()
guard let renewed = try await authenticator.renewIDToken() else { return }
let providers = try await CalendarrAPI.oidcProviders(baseURL: appState.serverURL)
guard let provider = providers.first(where: { $0.key == providerKey }) else { return }
let result = try await CalendarrAPI.exchangeOIDC(
baseURL: appState.serverURL,
provider: provider.key,
clientID: provider.mobileClientID,
idToken: renewed.idToken,
accessToken: renewed.accessToken,
nonce: renewed.nonce
)
appState.saveLogin(token: result.token, user: result.username, admin: result.isAdmin)
} catch {
// Nothing to do — keep using the token we have.
}
}
/// True when the Calendarr JWT expires within [renewWithin].
///
/// The payload is read without verifying the signature: this only decides
/// *whether* to renew, and the server validates the token on every request
/// regardless. A token we cannot parse is left alone.
private static func expiresSoon(_ jwt: String) -> Bool {
let parts = jwt.split(separator: ".")
guard parts.count == 3 else { return false }
var payload = String(parts[1])
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
// Base64url drops the padding that Data(base64Encoded:) insists on.
while payload.count % 4 != 0 { payload += "=" }
guard let data = Data(base64Encoded: payload),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let exp = json["exp"] as? Double else { return false }
return Date(timeIntervalSince1970: exp).timeIntervalSinceNow < renewWithin
}
}
@@ -0,0 +1,221 @@
import Foundation
extension Notification.Name {
/// Posted after a successful pull applied new settings to UserDefaults, so
/// views holding live state (CalendarHostView → store, widgets) can react.
static let settingsDidChange = Notification.Name("settingsDidChange")
}
/// Per-setting cross-device synchronisation. The server is the sole authority for
/// WHICH settings sync (its `sync_flags` map); this client keeps a cached copy of
/// that map and only sends/applies the keys whose flag is on. See
/// backend/SETTINGS_SYNC.md for the shared contract.
///
/// - Pull: for every syncable key whose flag is on, the server value wins.
/// - Push (debounced, read-modify-write): start from the server snapshot,
/// overwrite only the on-flagged keys with the local value, PUT.
enum SettingsSync {
/// 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",
]
static let syncableKeys: [String] = Array(keyToDefaults.keys)
/// Fallback flags used only until the first server pull populates the real map
/// (mirrors the server's DEFAULT_SYNC for the iOS-managed keys).
static let defaultSync: [String: Bool] = [
"default_view": true, "week_start_day": true, "dim_past_events": true,
"hour_height": true, "default_event_duration_minutes": true,
"default_reminder_minutes": true, "primary_color": true, "accent_color": true,
"today_color": true, "text_color": true, "bg_color": true, "line_color": true,
"month_divider_color": true, "month_label_color": true,
"cache_months": false, "month_view_paged": false,
]
// MARK: – Flag storage (UserDefaults JSON, mirrors the server's resolved map)
private static let flagsKey = "settingsSyncFlags"
/// Effective flags: stored overrides on top of the fallback defaults.
static func flags() -> [String: Bool] {
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 func isSynced(_ key: String) -> Bool { flags()[key] ?? false }
private static func writeFlags(_ f: [String: Bool]) {
if let data = try? JSONEncoder().encode(f) {
UserDefaults.standard.set(data, forKey: flagsKey)
}
}
/// 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 {
UserDefaults.standard.string(forKey: key) ?? fallback
}
private static func int(_ key: String, _ fallback: Int) -> Int {
UserDefaults.standard.object(forKey: key) as? Int ?? fallback
}
/// Build an AppSettings snapshot from local UserDefaults.
static func currentSettings() -> AppSettings {
var s = AppSettings()
s.primaryColor = str("primaryColor", "#4285f4")
s.accentColor = str("accentColor", "#ea4335")
s.todayColor = str("todayColor", "#4285f4")
s.textColor = str("textColor", "#FFFFFF")
s.backgroundColor = str("backgroundColor", "#000000")
s.lineColor = str("lineColor", "#3A3A52")
s.monthDividerColor = str("monthDividerColor", "#7090c0")
s.monthLabelColor = str("monthLabelColor", "#7090c0")
s.hourHeight = int("hourHeight", 60)
s.defaultView = str("defaultView", "month")
s.weekStartDay = str("weekStartDay", "monday")
s.dimPastEvents = UserDefaults.standard.bool(forKey: "dimPastEvents")
s.cacheMonths = int("cacheMonths", 3)
s.monthViewPaged = UserDefaults.standard.bool(forKey: "monthViewPaged")
let rem = int("defaultReminderMinutes", -1)
s.defaultReminderMinutes = rem < 0 ? nil : rem
s.defaultEventDurationMinutes = int("defaultEventDurationMinutes", 60)
return s
}
/// 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
}
}
/// Write one synced field from a server snapshot into local UserDefaults.
private static func applyField(_ key: String, from s: AppSettings) {
let d = UserDefaults.standard
switch key {
case "default_view": d.set(s.defaultView, forKey: "defaultView")
case "week_start_day": d.set(s.weekStartDay, forKey: "weekStartDay")
case "dim_past_events": d.set(s.dimPastEvents, forKey: "dimPastEvents")
case "hour_height": d.set(s.hourHeight, forKey: "hourHeight")
case "default_event_duration_minutes": d.set(s.defaultEventDurationMinutes, forKey: "defaultEventDurationMinutes")
case "default_reminder_minutes": d.set(s.defaultReminderMinutes ?? -1, forKey: "defaultReminderMinutes")
case "primary_color": d.set(s.primaryColor, forKey: "primaryColor")
case "accent_color": d.set(s.accentColor, forKey: "accentColor")
case "today_color": d.set(s.todayColor, forKey: "todayColor")
case "text_color": d.set(s.textColor, forKey: "textColor")
case "bg_color": d.set(s.backgroundColor, forKey: "backgroundColor")
case "line_color": d.set(s.lineColor, forKey: "lineColor")
case "month_divider_color": d.set(s.monthDividerColor, forKey: "monthDividerColor")
case "month_label_color": d.set(s.monthLabelColor, forKey: "monthLabelColor")
case "cache_months": d.set(s.cacheMonths, forKey: "cacheMonths")
case "month_view_paged": d.set(s.monthViewPaged, forKey: "monthViewPaged")
default: break
}
}
// MARK: – Pull
/// 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 {
guard let server = try? await api.getSettings() else { return }
storeServerFlags(server.syncFlags)
let f = flags()
for key in syncableKeys where f[key] == true {
applyField(key, from: server)
}
await MainActor.run {
NotificationCenter.default.post(name: .settingsDidChange, object: nil)
}
}
// MARK: – Push (debounced)
private static var pushTask: Task<Void, Never>?
/// Schedule a debounced push. Repeated calls (e.g. while dragging a colour
/// slider) collapse into a single network write ~1.2 s after the last edit.
static func push(api: CalendarrAPI) {
pushTask?.cancel()
pushTask = Task {
try? await Task.sleep(for: .milliseconds(1200))
if Task.isCancelled { return }
await performPush(api: api)
}
}
/// Read-modify-write: start from the server's current settings so unsynced
/// fields stay intact, overwrite only the on-flagged keys with local values,
/// and send the current flag map so the account-wide config stays in sync.
private static func performPush(api: CalendarrAPI) async {
guard var merged = try? await api.getSettings() else { return }
let local = currentSettings()
let f = flags()
for key in syncableKeys where f[key] == true {
copyField(key, from: local, into: &merged)
}
merged.syncFlags = f
try? await api.updateSettings(merged)
}
}
@@ -0,0 +1,156 @@
import Foundation
import WatchConnectivity
import CalendarrCore
/// Feeds the watch. App Groups are per-device, so the watch cannot read the
/// file this app just wrote — it has to be handed the data.
///
/// Three delivery paths, because each one alone leaves a gap:
///
/// 1. `updateApplicationContext` carries only the latest state and has no queue
/// to overflow. It is delivered when the watch app next launches or wakes.
/// 2. `transferCurrentComplicationUserInfo` is the only path that wakes the
/// watch app while it is not running. Its daily budget is small, so it is
/// spent only when the *next* appointment changed.
/// 3. A reply to `sendMessage` covers the watch app asking while it is open.
@MainActor
final class WatchSyncService: NSObject {
static let shared = WatchSyncService()
private let sessions = SharedSessionStore()
private static let lastIdentityKey = "watchLastPushedNextEvent"
private static let pushTimesKey = "watchComplicationPushTimes"
/// The system budget is roughly 50 a day; this keeps a burst of edits from
/// spending it in an hour.
private static let maxPushesPerHour = 4
private var session: WCSession? {
WCSession.isSupported() ? WCSession.default : nil
}
/// Call once at launch.
func activate() {
guard let session else { return }
session.delegate = self
session.activate()
}
/// Publish the current calendar to the watch.
func push(snapshot: CalendarrSnapshot, calendars: [SnapshotCalendar]) {
guard let session, session.activationState == .activated else { return }
let trimmed = snapshot.trimmedForWatch()
let payload = WatchTransportPayload(snapshot: trimmed,
calendars: calendars,
session: sessions.read())
guard let dict = try? payload.encoded() else { return }
try? session.updateApplicationContext(dict)
guard session.isComplicationEnabled else { return }
let current = WatchPushPolicy.nextEventIdentity(in: trimmed, at: Date())
guard WatchPushPolicy.shouldPushComplicationUpdate(previous: loadLastIdentity(),
current: current),
consumePushBudget()
else { return }
session.transferCurrentComplicationUserInfo(dict)
storeLastIdentity(current)
}
/// Tell the watch the user signed out, so it stops showing their calendar.
/// The watch keeps its own copy of the snapshot, so clearing ours is not
/// enough.
func pushSignedOut() {
guard let session, session.activationState == .activated else { return }
let now = Date()
let empty = CalendarrSnapshot(
writtenAt: now, coverageStart: now, coverageEnd: now,
isLoggedIn: false, writerVersion: "signout", events: [],
theme: SnapshotTheme(today: "#4285f4", text: "#FFFFFF", background: "#000000",
line: "#3A3A52", primary: "#4285f4", accent: "#ea4335"),
language: "system")
let payload = WatchTransportPayload(snapshot: empty, calendars: [], session: sessions.read())
guard let dict = try? payload.encoded() else { return }
try? session.updateApplicationContext(dict)
if session.isComplicationEnabled, consumePushBudget() {
session.transferCurrentComplicationUserInfo(dict)
}
storeLastIdentity(nil)
}
/// The payload for a watch that asked directly. Built from the file we
/// already wrote, so an on-demand pull cannot disagree with a push.
fileprivate func currentPayloadDictionary() -> [String: Any]? {
let store = SnapshotStore()
guard case .ok(let snapshot) = store.read() else { return nil }
let payload = WatchTransportPayload(snapshot: snapshot.trimmedForWatch(),
calendars: store.readCalendars(),
session: sessions.read())
return try? payload.encoded()
}
// MARK: – Push budget
private func consumePushBudget() -> Bool {
let cutoff = Date().addingTimeInterval(-3600)
var times = (UserDefaults.standard.array(forKey: Self.pushTimesKey) as? [Double] ?? [])
.map { Date(timeIntervalSince1970: $0) }
.filter { $0 > cutoff }
guard times.count < Self.maxPushesPerHour else {
UserDefaults.standard.set(times.map(\.timeIntervalSince1970), forKey: Self.pushTimesKey)
return false
}
times.append(Date())
UserDefaults.standard.set(times.map(\.timeIntervalSince1970), forKey: Self.pushTimesKey)
return true
}
// MARK: – Last pushed identity
private func loadLastIdentity() -> NextEventIdentity? {
guard let data = UserDefaults.standard.data(forKey: Self.lastIdentityKey) else { return nil }
let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601
return try? decoder.decode(NextEventIdentity.self, from: data)
}
private func storeLastIdentity(_ identity: NextEventIdentity?) {
guard let identity else {
UserDefaults.standard.removeObject(forKey: Self.lastIdentityKey)
return
}
let encoder = JSONEncoder(); encoder.dateEncodingStrategy = .iso8601
UserDefaults.standard.set(try? encoder.encode(identity), forKey: Self.lastIdentityKey)
}
}
extension WatchSyncService: WCSessionDelegate {
nonisolated func session(_ session: WCSession,
activationDidCompleteWith state: WCSessionActivationState,
error: Error?) {
if let error {
print("[WatchSync] activation failed: \(error.localizedDescription)")
}
}
/// The watch asking for the current state, which is what covers "it is open
/// in front of me and looks stale".
nonisolated func session(_ session: WCSession,
didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void) {
Task { @MainActor in
replyHandler(self.currentPayloadDictionary() ?? [:])
}
}
// Required on iOS: the session is torn down and re-activated when the user
// switches to a different paired watch.
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
nonisolated func sessionDidDeactivate(_ session: WCSession) {
WCSession.default.activate()
}
}
+922
View File
@@ -0,0 +1,922 @@
import SwiftUI
import UniformTypeIdentifiers
struct AccountsView: View {
let api: CalendarrAPI
@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 showAddCalDAV = false
@State private var showAddLocal = false
@State private var showAddICal = false
@State private var showAddHA = false
@State private var errorAlert: String?
@State private var banishedKeys: Set<String> = CalendarStore.loadBanishedKeys()
// Sharing / import / export
@State private var shareCalId: Int?
@State private var showImporter = false
@State private var importTargetCalId: Int?
@State private var exportDoc: ExportedICS?
@State private var infoMessage: String?
// 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"
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView(L10n.t("accounts.loading", appLang))
} else {
List {
if !banishedKeys.isEmpty { banishedSection }
caldavSection
localSection
birthdayContactsSection
icalSection
googleSection
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))
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu {
Button(L10n.t("accounts.add.caldav", appLang)) { showAddCalDAV = 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.ha", appLang)) { showAddHA = true }
} label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showAddCalDAV) {
AddCalDAVSheet(api: api) { await load() }
}
.sheet(isPresented: $showAddLocal) {
AddLocalCalSheet(api: api) { await load() }
}
.sheet(isPresented: $showAddICal) {
AddICalSheet(api: api) { await load() }
}
.sheet(isPresented: $showAddHA) {
AddHASheet(api: api) { await load() }
}
.alert(L10n.t("common.error", appLang), isPresented: .constant(errorAlert != nil), actions: {
Button(L10n.t("common.ok", appLang)) { errorAlert = nil }
}, message: {
Text(errorAlert ?? "")
})
.sheet(item: Binding(get: { shareCalId.map { IdentifiableInt(id: $0) } },
set: { shareCalId = $0?.id })) { wrap in
SharingView(api: api, calendarId: wrap.id)
}
.sheet(item: $exportDoc) { doc in
ActivityView(items: [doc.url])
}
.fileImporter(isPresented: $showImporter,
allowedContentTypes: [UTType(filenameExtension: "ics") ?? .data, .data],
allowsMultipleSelection: false) { result in
Task { await handleImport(result) }
}
.alert(L10n.t("common.info", appLang), isPresented: .constant(infoMessage != nil), actions: {
Button(L10n.t("common.ok", appLang)) { infoMessage = nil }
}, message: { Text(infoMessage ?? "") })
}
.task { await load() }
}
private func exportCalendar(_ cal: LocalCalendar) async {
do {
let data = try await api.exportICS(calendarId: cal.id)
let safe = cal.name.components(separatedBy: CharacterSet.alphanumerics.inverted).joined(separator: "_")
let url = FileManager.default.temporaryDirectory.appendingPathComponent("\(safe.isEmpty ? "calendar" : safe).ics")
try data.write(to: url)
exportDoc = ExportedICS(url: url)
} catch {
errorAlert = error.localizedDescription
}
}
private func handleImport(_ result: Result<[URL], Error>) async {
guard let calId = importTargetCalId else { return }
switch result {
case .success(let urls):
guard let url = urls.first else { return }
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
do {
let r = try await api.importICS(calendarId: calId, fileURL: url)
infoMessage = String(format: L10n.t("ics.import_result", appLang), r.imported, r.skipped)
} catch {
errorAlert = error.localizedDescription
}
case .failure(let err):
errorAlert = err.localizedDescription
}
}
// MARK: – Sections
var caldavSection: some View {
Section {
if caldavAccounts.isEmpty {
Text(L10n.t("accounts.caldav.empty", appLang))
.foregroundStyle(.secondary)
} else {
ForEach(caldavAccounts) { acc in
VStack(alignment: .leading, spacing: 8) {
HStack {
Circle().fill(Color(hex: acc.color)).frame(width: 12, height: 12)
VStack(alignment: .leading, spacing: 2) {
Text(acc.name).font(.body)
Text(acc.url).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
}
ForEach(acc.calendars ?? []) { cal in
HStack {
CalendarColorDot(hex: cal.color ?? acc.color) { hex in
try? await api.setCalendarColor(source: "caldav", calendarId: cal.id, color: hex)
}
Text(cal.name).font(.callout)
}
.padding(.leading, 8)
}
}
}
.onDelete { offsets in
Task { await deleteCalDAV(offsets: offsets) }
}
}
Button(L10n.t("accounts.caldav.add", appLang)) { showAddCalDAV = true }
.foregroundStyle(Color.accentColor)
} header: {
Text(L10n.t("accounts.caldav.header", appLang))
}
}
var localSection: some View {
Section {
if localCalendars.isEmpty {
Text(L10n.t("accounts.local.empty", appLang))
.foregroundStyle(.secondary)
} else {
ForEach(localCalendars) { cal in
HStack {
// 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)
}
Text(cal.name)
if cal.group {
Image(systemName: "person.2.fill").font(.caption2).foregroundStyle(.secondary)
}
Spacer()
if !cal.owned, let by = cal.sharedBy {
Text(String(format: L10n.t("accounts.shared_by", appLang), by))
.font(.caption2).foregroundStyle(.secondary)
}
Menu {
if cal.owned && !cal.group {
Button { shareCalId = cal.id } label: {
Label(L10n.t("share.title", appLang), systemImage: "person.crop.circle.badge.plus")
}
}
if cal.owned || cal.permission == "read_write" {
Button { importTargetCalId = cal.id; showImporter = true } label: {
Label(L10n.t("ics.import", appLang), systemImage: "square.and.arrow.down")
}
}
Button { Task { await exportCalendar(cal) } } label: {
Label(L10n.t("ics.export", appLang), systemImage: "square.and.arrow.up")
}
} label: {
Image(systemName: "ellipsis.circle").foregroundStyle(.secondary)
}
}
}
.onDelete { offsets in
Task { await deleteLocal(offsets: offsets) }
}
}
Button(L10n.t("accounts.local.add", appLang)) { showAddLocal = true }
.foregroundStyle(Color.accentColor)
} header: {
Text(L10n.t("accounts.local.header", appLang))
}
}
/// 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 {
Section {
if icalSubs.isEmpty {
Text(L10n.t("accounts.ical.empty", appLang))
.foregroundStyle(.secondary)
} else {
ForEach(icalSubs) { sub in
HStack {
CalendarColorDot(hex: sub.color) { hex in
try? await api.updateICalColor(id: sub.id, color: hex)
}
VStack(alignment: .leading, spacing: 2) {
Text(sub.name).font(.body)
Text(String(format: L10n.t("accounts.ical.every", appLang), sub.refreshMinutes))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.onDelete { offsets in
Task { await deleteICal(offsets: offsets) }
}
}
Button(L10n.t("accounts.ical.add", appLang)) { showAddICal = true }
.foregroundStyle(Color.accentColor)
} header: {
Text(L10n.t("accounts.ical.header", appLang))
}
}
var googleSection: some View {
Section {
if googleAccounts.isEmpty {
Text(L10n.t("accounts.google.empty", appLang))
.foregroundStyle(.secondary)
} else {
ForEach(googleAccounts) { acc in
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "g.circle.fill").foregroundStyle(.red)
Text(acc.email)
}
ForEach(acc.calendars ?? []) { cal in
HStack {
CalendarColorDot(hex: cal.color ?? "#4285f4") { hex in
try? await api.setCalendarColor(source: "google", calendarId: cal.id, color: hex)
}
Text(cal.name).font(.callout)
}
.padding(.leading, 8)
}
}
}
.onDelete { offsets in
Task { await deleteGoogle(offsets: offsets) }
}
}
Text(L10n.t("accounts.google.hint", appLang))
.font(.caption)
.foregroundStyle(.secondary)
} header: {
Text(L10n.t("accounts.google.header", appLang))
}
}
var banishedSection: some View {
Section {
ForEach(Array(banishedKeys).sorted(), id: \.self) { key in
let info = resolveBanished(key)
HStack(spacing: 10) {
Circle()
.fill(Color(hex: info.colorHex))
.frame(width: 12, height: 12)
.opacity(0.5)
Text(info.name)
.foregroundStyle(.secondary)
Spacer()
Button(L10n.t("accounts.banished_unhide", appLang)) {
unbanish(key)
}
.font(.callout)
.foregroundStyle(Color.accentColor)
}
}
} header: {
Text(L10n.t("accounts.banished_header", appLang))
}
}
/// Re-show a banished calendar. For server-backed sources this clears the
/// server's sidebar_hidden (re-enabling the calendar); for local/ical it's
/// just the local set.
private func unbanish(_ key: String) {
banishedKeys.remove(key)
CalendarStore.saveBanishedKeys(banishedKeys)
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
if let parsed = CalendarStore.parseCalendarKey(key),
CalendarStore.serverManagedSources.contains(parsed.source) {
// The server excluded this calendar's events while hidden, so they
// aren't in the cache. Re-enable on the server, then force a refetch
// so the events actually reappear without a manual sync.
Task {
try? await api.setCalendarSidebarHidden(source: parsed.source, calendarId: parsed.id, hidden: false)
NotificationCenter.default.post(name: .manualSyncRequested, object: nil)
}
}
}
private func resolveBanished(_ key: String) -> (name: String, colorHex: String) {
let parts = key.split(separator: ":", maxSplits: 1).map(String.init)
guard parts.count == 2, let id = Int(parts[1]) else {
return (L10n.t("accounts.banished_unknown", appLang), "#888888")
}
switch parts[0] {
case "local":
if let c = localCalendars.first(where: { $0.id == id }) {
return (c.name, c.color)
}
case "caldav":
for acc in caldavAccounts {
if let c = acc.calendars?.first(where: { $0.id == id }) {
return ("\(acc.name) – \(c.name)", c.color ?? acc.color)
}
}
case "ical":
if let s = icalSubs.first(where: { $0.id == id }) {
return (s.name, s.color)
}
case "google":
for acc in googleAccounts {
if let c = acc.calendars?.first(where: { $0.id == id }) {
return ("\(acc.email) – \(c.name)", c.color ?? "#4285f4")
}
}
case "homeassistant":
for acc in haAccounts {
if let c = acc.calendars?.first(where: { $0.id == id }) {
return ("\(acc.name) – \(c.name)", c.color ?? "#46bdc6")
}
}
default: break
}
return (L10n.t("accounts.banished_unknown", appLang), "#888888")
}
var haSection: some View {
Section {
if haAccounts.isEmpty {
Text(L10n.t("accounts.ha.empty", appLang))
.foregroundStyle(.secondary)
} else {
ForEach(haAccounts) { acc in
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text(acc.name).font(.body)
Text(acc.url).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
ForEach(acc.calendars ?? []) { cal in
HStack {
CalendarColorDot(hex: cal.color ?? "#03a9f4") { hex in
try? await api.setCalendarColor(source: "homeassistant", calendarId: cal.id, color: hex)
}
Text(cal.name).font(.callout)
}
.padding(.leading, 8)
}
}
}
.onDelete { offsets in
Task { await deleteHA(offsets: offsets) }
}
}
Button(L10n.t("accounts.ha.add", appLang)) { showAddHA = true }
.foregroundStyle(Color.accentColor)
} header: {
Text(L10n.t("accounts.ha.header", appLang))
}
}
// MARK: – Actions
private func load() async {
isLoading = true
banishedKeys = CalendarStore.loadBanishedKeys()
async let c = (try? await api.getCalDAVAccounts()) ?? []
async let l = (try? await api.getLocalCalendars()) ?? []
async let i = (try? await api.getICalSubscriptions()) ?? []
async let g = (try? await api.getGoogleAccounts()) ?? []
async let h = (try? await api.getHomeAssistantAccounts()) ?? []
(caldavAccounts, localCalendars, icalSubs, googleAccounts, haAccounts) = await (c, l, i, g, h)
// Reconcile banished list with the server's sidebar_hidden (server wins
// for CalDAV/Google/HA; local/ical keep their local state).
var b = banishedKeys
func applyServerHidden(_ source: String, _ id: Int, _ hidden: Bool) {
let key = CalendarStore.calendarKey(source: source, calendarId: "\(id)")
if hidden { b.insert(key) } else { b.remove(key) }
}
for acc in caldavAccounts { for cal in acc.calendars ?? [] { applyServerHidden("caldav", cal.id, cal.sidebarHidden) } }
for acc in googleAccounts { for cal in acc.calendars ?? [] { applyServerHidden("google", cal.id, cal.sidebarHidden) } }
for acc in haAccounts { for cal in acc.calendars ?? [] { applyServerHidden("homeassistant", cal.id, cal.sidebarHidden) } }
if b != banishedKeys {
banishedKeys = b
CalendarStore.saveBanishedKeys(b)
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
}
// Reflect the birthday calendar's current reminder setting in the picker.
birthdayNotify = birthdayCalendar?.birthdayNotifyDaysBefore ?? -1
isLoading = false
}
private func deleteCalDAV(offsets: IndexSet) async {
for i in offsets {
try? await api.deleteCalDAVAccount(id: caldavAccounts[i].id)
}
await load()
}
private func deleteLocal(offsets: IndexSet) async {
for i in offsets {
try? await api.deleteLocalCalendar(id: localCalendars[i].id)
}
await load()
}
private func deleteICal(offsets: IndexSet) async {
for i in offsets {
try? await api.deleteICalSubscription(id: icalSubs[i].id)
}
await load()
}
private func deleteGoogle(offsets: IndexSet) async {
for i in offsets {
try? await api.deleteGoogleAccount(id: googleAccounts[i].id)
}
await load()
}
private func deleteHA(offsets: IndexSet) async {
for i in offsets {
try? await api.deleteHomeAssistantAccount(id: haAccounts[i].id)
}
await load()
}
}
// MARK: – Add Sheets
struct AddCalDAVSheet: View {
let api: CalendarrAPI
let onDone: () async -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@State private var name = ""
@State private var url = ""
@State private var username = ""
@State private var password = ""
@State private var color = Color(hex: "#4285f4")
@State private var isLoading = false
@State private var error = ""
var body: some View {
NavigationStack {
Form {
Section(L10n.t("caldav.section", appLang)) {
TextField(L10n.t("caldav.display_name", appLang), text: $name)
TextField(L10n.t("caldav.url", appLang), text: $url)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
TextField(L10n.t("caldav.username", appLang), text: $username)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField(L10n.t("caldav.password", appLang), text: $password)
}
Section(L10n.t("caldav.color_section", appLang)) {
ColorPicker(L10n.t("caldav.color_label", appLang), selection: $color, supportsOpacity: false)
}
if !error.isEmpty {
Section {
Text(error).foregroundStyle(.red)
}
}
}
.navigationTitle(L10n.t("caldav.title", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.t("common.cancel", appLang)) { dismiss() }
}
ToolbarItem(placement: .primaryAction) {
Button(L10n.t("caldav.connect", appLang)) {
Task { await save() }
}
.bold()
.disabled(name.isEmpty || url.isEmpty || username.isEmpty || password.isEmpty || isLoading)
}
}
}
}
private func save() async {
isLoading = true
error = ""
do {
_ = try await api.addCalDAVAccount(name: name, url: url, username: username, password: password, color: color.toHex())
await onDone()
dismiss()
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
}
struct AddLocalCalSheet: View {
let api: CalendarrAPI
let onDone: () async -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@State private var name = ""
@State private var color = Color(hex: "#34a853")
@State private var isLoading = false
@State private var error = ""
var body: some View {
NavigationStack {
Form {
Section {
TextField(L10n.t("local.name", appLang), text: $name)
ColorPicker(L10n.t("local.color", appLang), selection: $color, supportsOpacity: false)
}
if !error.isEmpty {
Section { Text(error).foregroundStyle(.red) }
}
}
.navigationTitle(L10n.t("local.title", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button(L10n.t("local.create", appLang)) {
Task { await save() }
}
.bold()
.disabled(name.isEmpty || isLoading)
}
}
}
}
private func save() async {
isLoading = true
do {
_ = try await api.addLocalCalendar(name: name, color: color.toHex())
await onDone()
dismiss()
} catch { self.error = error.localizedDescription }
isLoading = false
}
}
/// 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 {
let api: CalendarrAPI
let onDone: () async -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@State private var name = ""
@State private var url = ""
@State private var color = Color(hex: "#46bdc6")
@State private var refreshMinutes = 60
@State private var isLoading = false
@State private var error = ""
private var refreshOptions: [(Int, String)] {
[(15, L10n.t("ical.refresh.15m", appLang)),
(30, L10n.t("ical.refresh.30m", appLang)),
(60, L10n.t("ical.refresh.1h", appLang)),
(360, L10n.t("ical.refresh.6h", appLang)),
(1440, L10n.t("ical.refresh.1d", appLang))]
}
var body: some View {
NavigationStack {
Form {
Section(L10n.t("ical.subscription", appLang)) {
TextField(L10n.t("ical.name", appLang), text: $name)
TextField(L10n.t("ical.url", appLang), text: $url)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
ColorPicker(L10n.t("ical.color", appLang), selection: $color, supportsOpacity: false)
}
Section(L10n.t("ical.refresh_section", appLang)) {
Picker(L10n.t("ical.interval", appLang), selection: $refreshMinutes) {
ForEach(refreshOptions, id: \.0) { opt in
Text(opt.1).tag(opt.0)
}
}
}
if !error.isEmpty {
Section { Text(error).foregroundStyle(.red) }
}
}
.navigationTitle(L10n.t("ical.title", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button(L10n.t("ical.subscribe", appLang)) {
Task { await save() }
}
.bold()
.disabled(name.isEmpty || url.isEmpty || isLoading)
}
}
}
}
private func save() async {
isLoading = true
do {
_ = try await api.addICalSubscription(name: name, url: url, color: color.toHex(), refreshMinutes: refreshMinutes)
await onDone()
dismiss()
} catch { self.error = error.localizedDescription }
isLoading = false
}
}
struct AddHASheet: View {
let api: CalendarrAPI
let onDone: () async -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@State private var name = ""
@State private var url = ""
@State private var token = ""
@State private var isLoading = false
@State private var error = ""
var body: some View {
NavigationStack {
Form {
Section(L10n.t("ha.section", appLang)) {
TextField(L10n.t("ha.display_name", appLang), text: $name)
TextField(L10n.t("ha.url_placeholder", appLang), text: $url)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
}
Section(L10n.t("ha.auth_section", appLang)) {
SecureField(L10n.t("ha.token", appLang), text: $token)
Text(L10n.t("ha.token_hint", appLang))
.font(.caption)
.foregroundStyle(.secondary)
}
if !error.isEmpty {
Section { Text(error).foregroundStyle(.red) }
}
}
.navigationTitle(L10n.t("accounts.add.ha", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button(L10n.t("ha.connect", appLang)) {
Task { await save() }
}
.bold()
.disabled(name.isEmpty || url.isEmpty || token.isEmpty || isLoading)
}
}
}
}
private func save() async {
isLoading = true
do {
_ = try await api.addHomeAssistantAccount(name: name, url: url, token: token)
await onDone()
dismiss()
} catch { self.error = error.localizedDescription }
isLoading = false
}
}
// MARK: – Sharing / Import-Export helpers
struct IdentifiableInt: Identifiable { let id: Int }
struct ExportedICS: Identifiable { let id = UUID(); let url: URL }
/// A tappable colour swatch (ColorPicker) for a calendar. Persists via `onPick`
/// when the chosen colour changes. Read-only fallback when `editable` is false.
struct CalendarColorDot: View {
let hex: String
var editable: Bool = true
let onPick: (String) async -> Void
@State private var color: Color
init(hex: String, editable: Bool = true, onPick: @escaping (String) async -> Void) {
self.hex = hex; self.editable = editable; self.onPick = onPick
_color = State(initialValue: Color(hex: hex))
}
var body: some View {
if editable {
ColorPicker("", selection: $color, supportsOpacity: false)
.labelsHidden()
.frame(width: 26, height: 26)
.onChange(of: color) { _, c in Task { await onPick(c.toHex()) } }
} else {
Circle().fill(Color(hex: hex)).frame(width: 14, height: 14)
}
}
}
/// Wraps UIActivityViewController so an exported .ics can be shared/saved.
struct ActivityView: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
func updateUIViewController(_ vc: UIActivityViewController, context: Context) {}
}
/// Manage who a local calendar is shared with (owner only).
struct SharingView: View {
let api: CalendarrAPI
let calendarId: Int
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@State private var shares: [CalendarShare] = []
@State private var directory: [DirectoryUser] = []
@State private var search = ""
@State private var permission = "read"
@State private var error = ""
private var candidates: [DirectoryUser] {
let sharedIds = Set(shares.map { $0.userId })
return directory.filter { !sharedIds.contains($0.id) &&
(search.isEmpty || $0.displayName.localizedCaseInsensitiveContains(search)) }
}
var body: some View {
NavigationStack {
List {
Section(L10n.t("share.current", appLang)) {
if shares.isEmpty {
Text(L10n.t("share.none", appLang)).foregroundStyle(.secondary)
} else {
ForEach(shares) { s in
HStack {
Text(s.displayName ?? "—")
Spacer()
Text(s.permission == "read_write"
? L10n.t("perm.read_write", appLang)
: L10n.t("perm.read", appLang))
.font(.caption).foregroundStyle(.secondary)
}
}
.onDelete { offsets in
Task { await removeShares(offsets) }
}
}
}
Section(L10n.t("share.add", appLang)) {
Picker(L10n.t("share.permission", appLang), selection: $permission) {
Text(L10n.t("perm.read", appLang)).tag("read")
Text(L10n.t("perm.read_write", appLang)).tag("read_write")
}
TextField(L10n.t("share.search", appLang), text: $search)
.autocapitalization(.none)
ForEach(candidates) { u in
Button { Task { await addShare(u.id) } } label: {
HStack {
Text(u.displayName)
Spacer()
Image(systemName: "plus.circle").foregroundStyle(Color.accentColor)
}
}
}
}
if !error.isEmpty { Section { Text(error).foregroundStyle(.red) } }
}
.navigationTitle(L10n.t("share.title", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(L10n.t("common.done", appLang)) { dismiss() }
}
}
}
.task { await load() }
}
private func load() async {
shares = (try? await api.getShares(calendarId: calendarId)) ?? []
directory = (try? await api.getUserDirectory()) ?? []
}
private func addShare(_ userId: Int) async {
do { try await api.addShare(calendarId: calendarId, userId: userId, permission: permission); await load() }
catch { self.error = error.localizedDescription }
}
private func removeShares(_ offsets: IndexSet) async {
for i in offsets { try? await api.removeShare(calendarId: calendarId, userId: shares[i].userId) }
await load()
}
}
@@ -0,0 +1,111 @@
import SwiftUI
struct AgendaView: View {
let store: CalendarStore
let onEventTap: (CalEvent) -> Void
@AppStorage("appLanguage") private var appLang = "system"
private var cal: Calendar { store.userCalendar }
private var grouped: [(Date, [CalEvent])] {
let start = cal.startOfDay(for: .now)
let end = cal.date(byAdding: .day, value: 90, to: start)!
var dict: [Date: [CalEvent]] = [:]
for ev in store.events(in: start, end: end) {
let key = cal.startOfDay(for: ev.startDate)
dict[key, default: []].append(ev)
}
return dict.keys.sorted().map { ($0, dict[$0]!.sorted { $0.startDate < $1.startDate }) }
}
private var dayFmt: DateFormatter {
let f = DateFormatter()
f.locale = L10n.locale(appLang)
f.dateFormat = "EEEE, d. MMMM yyyy"
return f
}
private var timeFmt: DateFormatter {
let f = DateFormatter()
f.locale = L10n.locale(appLang)
f.timeStyle = .short
f.dateStyle = .none
return f
}
var body: some View {
if grouped.isEmpty {
ContentUnavailableView(
L10n.t("cal.no_events_title", appLang),
systemImage: "calendar",
description: Text(L10n.t("cal.no_events_body", appLang))
)
} else {
List {
ForEach(grouped, id: \.0) { day, evs in
Section {
ForEach(evs) { ev in
Button { onEventTap(ev) } label: {
AgendaEventRow(event: ev, timeFmt: timeFmt, allDayLabel: L10n.t("cal.allday", appLang))
}
.buttonStyle(.plain)
}
} header: {
Text(dayFmt.string(from: day))
.font(.footnote.weight(.semibold))
.foregroundStyle(cal.isDateInToday(day) ? Color.accentColor : .secondary)
}
}
}
.listStyle(.plain)
}
}
}
private struct AgendaEventRow: View {
let event: CalEvent
let timeFmt: DateFormatter
let allDayLabel: String
var timeString: String {
if event.isAllDay { return allDayLabel }
return timeFmt.string(from: event.startDate)
}
var body: some View {
HStack(spacing: 12) {
RoundedRectangle(cornerRadius: 2)
.fill(Color(hex: event.effectiveColor))
.frame(width: 4, height: 40)
VStack(alignment: .leading, spacing: 3) {
EventLabel(event: event)
.font(.body.weight(.medium))
.foregroundStyle(.primary)
HStack(spacing: 6) {
Text(timeString)
.font(.caption)
.foregroundStyle(.secondary)
if !event.location.isEmpty {
Text("·")
.foregroundStyle(.secondary)
Text(event.location)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Text(event.calendarName)
.font(.caption2)
.foregroundStyle(.tertiary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.vertical, 4)
}
}
@@ -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()
}
}
@@ -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)
}
}
@@ -0,0 +1,626 @@
import SwiftUI
private enum CalEditorContext: Identifiable {
case create(Date)
case edit(CalEvent)
var id: String {
switch self {
case .create(let d): return "new-\(d.timeIntervalSince1970)"
case .edit(let ev): return "edit-\(ev.id)"
}
}
}
struct CalendarHostView: View {
let api: CalendarrAPI
@Binding var showMenu: Bool
@AppStorage("liquidGlass") private var liquidGlass = false
@AppStorage("cacheMonths") private var cacheMonths = 3
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("backgroundColor") private var bgHex = "#000000"
@AppStorage("weekStartDay") private var weekStartDay = "monday"
@AppStorage("defaultView") private var defaultView = "month"
// 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
@State private var store = CalendarStore()
@State private var editorContext: CalEditorContext? = nil
@State private var selectedEvent: CalEvent? = nil
@State private var showFilter = false
@State private var didApplyDefaultView = false
@State private var groups: [CalGroup] = []
@State private var showNewBirthday = false
@State private var showDrawer = false
private var titleString: String {
if store.viewType == .month {
let f = DateFormatter()
f.locale = L10n.locale(appLang)
f.dateFormat = "LLLL yyyy"
return f.string(from: store.visibleMonth).capitalized(with: L10n.locale(appLang))
}
return store.titleForCurrentView(language: appLang)
}
var body: some View {
ZStack(alignment: .leading) {
Group {
if liquidGlass { glassVariant } else { 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
@ViewBuilder
private var loadingIndicator: some View {
if store.isLoading || store.isCachingBackground {
ProgressView()
.padding(14)
.background(.regularMaterial, in: Circle())
.shadow(color: .black.opacity(0.12), radius: 6, y: 2)
.transition(.opacity.combined(with: .scale(scale: 0.85)))
}
}
// MARK: – Flat variant
private var flatVariant: some View {
VStack(spacing: 0) {
topBar
groupBanner
Divider()
errorBanner
calendarContent
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(hex: bgHex))
.overlay(alignment: .top) {
loadingIndicator.padding(.top, 12)
}
.animation(.easeInOut(duration: 0.2), value: store.isLoading || store.isCachingBackground)
}
.overlay(alignment: .bottomTrailing) { solidFAB }
.modifier(calendarSheets)
.task { await startup() }
.onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } }
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
.onChange(of: cacheMonths) { _, _ in Task { await recache() } }
.onChange(of: store.visibleMonth) { _, new in Task { await ensureLoaded(around: new) } }
.onChange(of: scenePhase) { _, phase in if phase == .active { Task { await syncFromServer() } } }
.onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in
store.syncBanishedFromDefaults()
}
.onReceive(NotificationCenter.default.publisher(for: .settingsDidChange)) { _ in
applyServerDrivenSettings(initial: false)
}
.onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in
Task { await forceReload() }
}
.onReceive(NotificationCenter.default.publisher(for: .rescheduleReminders)) { _ in
store.rescheduleNotifications()
}
}
// MARK: – Liquid Glass variant
private var glassVariant: some View {
// Real iOS-26 Liquid Glass: the system NavigationStack toolbar renders the
// glass bar (buttons). The month TITLE is NOT placed in the toolbar — the
// system title silently fails to refresh on month change on iOS 26 — but
// as a normal inline Text in a top safe-area inset just below the glass
// bar, where it updates reliably (same mechanism as the flat variant).
NavigationStack {
calendarContent
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(hex: bgHex))
.overlay(alignment: .top) {
loadingIndicator.padding(.top, 12)
}
.animation(.easeInOut(duration: 0.2), value: store.isLoading || store.isCachingBackground)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
HStack(spacing: 2) {
if !hideMenuButton { menuButton }
Button { store.navigatePrev() } label: { Image(systemName: "chevron.left") }
Button { store.navigateNext() } label: { Image(systemName: "chevron.right") }
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout)
}
}
.safeAreaInset(edge: .top, spacing: 0) {
VStack(spacing: 0) {
Text(titleString)
.font(.headline)
.lineLimit(1)
.minimumScaleFactor(0.7)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(.bar)
groupBanner
errorBanner
}
}
}
.overlay(alignment: .bottomTrailing) { glassFAB }
.modifier(calendarSheets)
.task { await startup() }
.onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } }
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
.onChange(of: cacheMonths) { _, _ in Task { await recache() } }
.onChange(of: store.visibleMonth) { _, new in Task { await ensureLoaded(around: new) } }
.onChange(of: scenePhase) { _, phase in if phase == .active { Task { await syncFromServer() } } }
.onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in
store.syncBanishedFromDefaults()
}
.onReceive(NotificationCenter.default.publisher(for: .settingsDidChange)) { _ in
applyServerDrivenSettings(initial: false)
}
.onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in
Task { await forceReload() }
}
.onReceive(NotificationCenter.default.publisher(for: .rescheduleReminders)) { _ in
store.rescheduleNotifications()
}
}
// MARK: – Top bar (flat mode)
/// Shared bar contents (chevrons / today / title / group / view / filter / menu).
/// Used by both the flat and the glass top bar so the inline title — which
/// updates reliably on month change — is identical in both modes.
@ViewBuilder private var barContents: some View {
HStack(spacing: 0) {
// Menu (hamburger) on the left — the drawer opens from the left.
if !hideMenuButton { menuButton.padding(.leading, 2) }
HStack(spacing: 2) {
Button { store.navigatePrev() } label: {
Image(systemName: "chevron.left")
.font(.system(size: 17, weight: .medium))
.frame(width: 36, height: 36)
}
Button { store.navigateNext() } label: {
Image(systemName: "chevron.right")
.font(.system(size: 17, weight: .medium))
.frame(width: 36, height: 36)
}
}
.padding(.leading, hideMenuButton ? 6 : 0)
Spacer(minLength: 6)
Text(titleString)
.font(.headline)
.lineLimit(1)
.minimumScaleFactor(0.7)
.layoutPriority(1)
Spacer(minLength: 6)
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }
.font(.callout).padding(.horizontal, 6)
.lineLimit(1).fixedSize()
.padding(.trailing, 4)
}
.frame(height: 48)
}
private var topBar: some View {
barContents.background(
surfaceHex.isEmpty ? AnyShapeStyle(Material.bar) : AnyShapeStyle(Color(hex: surfaceHex))
)
}
@ViewBuilder private var groupBanner: some View {
if let g = store.activeGroup {
HStack(spacing: 6) {
GroupIconView(icon: g.icon).font(.subheadline)
Text("\(L10n.t("groups.view_label", appLang)): \(g.name)")
.font(.subheadline).lineLimit(1)
Spacer()
Button(L10n.t("groups.exit", appLang)) { switchGroup(nil) }
.font(.callout)
}
.padding(.horizontal, 12).padding(.vertical, 7)
.background(Color.accentColor.opacity(0.18))
}
}
private func switchGroup(_ g: CalGroup?) {
store.activeGroup = g
store.hiddenGroupKeys = [] // member visibility is per-group; start fresh
// The cache holds the previous mode's events — drop it and reload the
// visible range + prefetch a wide window so the whole grid is covered.
Task { await forceReload() }
}
/// Opens the side drawer (calendars + groups + navigation). Tinted when a
/// filter/group is active so the user sees state at a glance.
private var menuButton: some View {
Button {
withAnimation(.easeInOut(duration: 0.25)) { showDrawer = true }
} label: {
Image(systemName: "line.3.horizontal")
.font(.system(size: 18, weight: .medium))
.foregroundStyle(store.activeGroup == nil && store.hiddenCalendarKeys.isEmpty ? .primary : Color.accentColor)
.frame(width: 36, height: 36)
}
.accessibilityLabel(L10n.t("nav.menu", appLang))
}
// MARK: – Error 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 {
if let err = store.lastError { errorBannerView(err) }
if !store.syncErrors.isEmpty { syncErrorBannerView(store.syncErrors) }
}
private func errorBannerView(_ err: String) -> some View {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.yellow)
Text(err).font(.caption).foregroundStyle(.white).lineLimit(2)
Spacer()
Button { Task { await onNavigate() } } label: {
Image(systemName: "arrow.clockwise").foregroundStyle(.white)
}
}
.padding(.horizontal, 12).padding(.vertical, 8)
.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)
@ViewBuilder
private var calendarContent: some View {
let swipe = DragGesture(minimumDistance: 14, coordinateSpace: .local)
.onEnded { val in
let h = val.translation.width
let v = val.translation.height
guard abs(h) > abs(v) * 1.2, abs(h) > 28 else { return }
withAnimation(.easeInOut(duration: 0.2)) {
if h < 0 { store.navigateNext() } else { store.navigatePrev() }
}
}
switch store.viewType {
case .month:
// Month view uses vertical scroll – no horizontal swipe.
MonthView(store: store,
onDayTap: { store.currentDate = $0 },
onEventTap: { selectedEvent = $0 },
onCreateEvent: { day in editorContext = .create(day) },
onShowWeek: { day in
store.currentDate = day
store.viewType = .week
},
onShowDay: { day in
store.currentDate = day
store.viewType = .day
})
case .week:
WeekView(store: store,
onEventTap: { selectedEvent = $0 },
onCreateEvent: { date in editorContext = .create(date) },
onShowMonth: { date in
store.currentDate = date
store.viewType = .month
},
onShowDay: { date in
store.currentDate = date
store.viewType = .day
})
.simultaneousGesture(swipe)
case .day:
DayView(store: store,
onEventTap: { selectedEvent = $0 },
onCreateEvent: { date in editorContext = .create(date) })
.simultaneousGesture(swipe)
case .quarter:
QuarterView(store: store, onEventTap: { selectedEvent = $0 })
.simultaneousGesture(swipe)
case .agenda:
AgendaView(store: store, onEventTap: { selectedEvent = $0 })
}
}
// 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)
private var solidFAB: some View {
Button {
editorContext = .create(.now)
} label: {
Image(systemName: "plus")
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 56, height: 56)
.background(Color.accentColor)
.clipShape(Circle())
.shadow(radius: 4, y: 2)
}
.contextMenu { fabMenu }
.padding(.trailing, 20).padding(.bottom, 20)
}
/// Liquid Glass FAB (iOS 26) with glass effect; falls back to solid on older OS
@ViewBuilder
private var glassFAB: some View {
if #available(iOS 26, *) {
Button {
editorContext = .create(.now)
} label: {
Image(systemName: "plus")
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(.primary)
.frame(width: 56, height: 56)
}
.buttonStyle(.plain)
.glassEffect(in: Circle())
.contextMenu { fabMenu }
.padding(.trailing, 20).padding(.bottom, 20)
} else {
solidFAB
}
}
// MARK: – Sheets modifier
private var calendarSheets: CalendarSheets {
CalendarSheets(store: store, editorContext: $editorContext,
selectedEvent: $selectedEvent, showFilter: $showFilter,
showNewBirthday: $showNewBirthday,
api: api,
reload: { await onNavigate() },
reloadForce: { await reloadVisible(force: true) })
}
// MARK: – Loading logic
private func startup() async {
// Ask for notification permission early so reminders can be scheduled.
NotificationScheduler.requestAuthorizationIfNeeded()
// 0. Pull settings first so week-start / default-view are correct
// before we compute the initial range and load events.
await SettingsSync.pull(api: api)
applyServerDrivenSettings(initial: true)
await store.loadWritableCalendars(api: api)
// 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()) ?? []
// 1. Load current view immediately (visible)
let (s, e) = store.rangeForCurrentView()
await store.loadEvents(api: api, start: s, end: e)
// 2. Background prefetch for the configured range (non-blocking)
Task(priority: .background) {
await store.prefetchBackground(api: api, months: cacheMonths)
}
// 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 {
try? await Task.sleep(for: .seconds(600))
if Task.isCancelled { break }
await syncFromServer()
}
}
/// Apply the server-driven "always sync" settings to the live store.
/// `weekStartsOnMonday` is applied every time; the default view is applied
/// only once at startup so it never overrides the user's manual switches.
private func applyServerDrivenSettings(initial: Bool) {
store.weekStartsOnMonday = (weekStartDay != "sunday")
if initial, !didApplyDefaultView {
didApplyDefaultView = true
if let vt = CalViewType(rawValue: defaultView) {
store.viewType = vt
}
}
}
/// Called on every navigation – instant if within cache, fetches otherwise.
private func onNavigate() async {
let (s, e) = store.rangeForCurrentView()
await store.loadEvents(api: api, start: s, end: e)
}
/// Re-fetch the visible range. With `force` it bypasses the cache so a just
/// created/edited event shows up immediately (the server is authoritative).
private func reloadVisible(force: Bool) async {
let (s, e) = store.rangeForCurrentView()
await store.loadEvents(api: api, start: s, end: e, force: force)
}
/// Called when cacheMonths setting changes – clear cache and re-prefetch.
private func recache() async {
store.invalidateCache()
await startup()
}
/// Manual sync from the menu: drop the event cache and re-fetch from the
/// server (the periodic loop in `startup()` is untouched, so we don't spawn
/// a second one). Settings were already pulled by the menu action.
private func forceReload() async {
store.invalidateCache()
let (s, e) = store.rangeForCurrentView()
await store.loadEvents(api: api, start: s, end: e)
Task(priority: .background) {
await store.prefetchBackground(api: api, months: cacheMonths)
}
}
/// 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
/// immediately from cache, then fetches on demand if needed.
private func ensureLoaded(around month: Date) async {
let cal = store.userCalendar
let monthStart = cal.date(from: cal.dateComponents([.year, .month], from: month)) ?? month
let s = cal.date(byAdding: .month, value: -1, to: monthStart) ?? monthStart
let e = cal.date(byAdding: .month, value: 2, to: monthStart) ?? monthStart
// Only narrow the visible event set if the requested window is already cached.
// Otherwise keep the current events visible until the network fetch finishes,
// so previous/current/next month events don't disappear temporarily.
if store.isCached(start: s, end: e) {
store.refreshFromCache(start: s, end: e)
}
await store.loadEvents(api: api, start: s, end: e)
}
}
// MARK: – Shared sheet modifier
private struct CalendarSheets: ViewModifier {
let store: CalendarStore
@Binding var editorContext: CalEditorContext?
@Binding var selectedEvent: CalEvent?
@Binding var showFilter: Bool
@Binding var showNewBirthday: Bool
let api: CalendarrAPI
let reload: () async -> Void
let reloadForce: () async -> Void
func body(content: Content) -> some View {
content
.sheet(isPresented: $showNewBirthday) {
BirthdayEditorSheet(api: api) { await reloadForce() }
}
// Use sheet(item:) so the editing event is captured atomically –
// avoiding the race where sheet(isPresented:) evaluates its content
// before the editingEvent state update propagates.
.sheet(item: $editorContext) { ctx in
let editingEv: CalEvent? = { if case .edit(let ev) = ctx { return ev }; return nil }()
let date: Date = { if case .create(let d) = ctx { return d }; return .now }()
EventEditorSheet(api: api, store: store,
initialDate: date, editingEvent: editingEv) {
editorContext = nil
await reloadForce()
}
}
.sheet(item: $selectedEvent) { ev in
EventDetailSheet(event: ev, api: api, store: store) { updated, needsForce in
selectedEvent = nil
if let u = updated { editorContext = .edit(u) }
if needsForce { await reloadForce() } else { await reload() }
}
}
.sheet(isPresented: $showFilter) {
CalendarFilterSheet(api: api, store: store)
}
}
}
@@ -0,0 +1,159 @@
import SwiftUI
struct DayView: View {
let store: CalendarStore
let onEventTap: (CalEvent) -> Void
let onCreateEvent: (Date) -> Void
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("todayColor") private var todayHex = "#4285f4"
@AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("lineColor") private var lineHex = "#3A3A52"
@AppStorage("textContrast") private var textContrast = 3
@AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout
private var cal: Calendar { store.userCalendar }
private var allDayEvents: [CalEvent] {
store.events(on: store.currentDate).filter { $0.isAllDay || eventSpansMultipleDays($0) }
}
private var timedEvents: [CalEvent] {
store.events(on: store.currentDate).filter { !$0.isAllDay && !eventSpansMultipleDays($0) }
}
var body: some View {
VStack(spacing: 0) {
if !allDayEvents.isEmpty { allDayStrip }
GeometryReader { geo in
ScrollViewReader { proxy in
ScrollView {
ZStack(alignment: .topLeading) {
// Background grid with per-hour context menus
HStack(alignment: .top, spacing: 0) {
timeLabels
VStack(spacing: 0) {
ForEach(hours, id: \.self) { hour in
DayHourSlot(day: store.currentDate, hour: hour,
hourHeight: hourHeight,
language: appLang,
onCreateEvent: onCreateEvent)
}
}
.frame(width: geo.size.width - timeColumnWidth)
}
// Events
let evWidth = geo.size.width - timeColumnWidth - 2
ForEach(timedEvents) { ev in
Button(action: { onEventTap(ev) }) {
EventBlock(event: ev)
}
.buttonStyle(.plain)
.frame(width: evWidth, height: max(eventHeight(ev), 18))
.offset(x: timeColumnWidth + 1, y: eventTop(ev))
}
// Current time
if cal.isDateInToday(store.currentDate) {
let lineY = nowLineY()
let nowColor = Color(hex: todayHex)
HStack(spacing: 0) {
Spacer().frame(width: timeColumnWidth - 4)
Circle().fill(nowColor).frame(width: 8, height: 8)
Rectangle().fill(nowColor)
.frame(width: geo.size.width - timeColumnWidth - 4, height: 1.5)
}
.offset(y: lineY - 0.75)
}
}
.frame(width: geo.size.width, height: hourHeight * 24 + 80)
.id("grid")
}
.onAppear { scrollToCurrentHour(proxy) }
.onChange(of: store.currentDate) { _, _ in scrollToCurrentHour(proxy) }
}
}
}
}
private var allDayStrip: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(allDayEvents) { ev in
Button(action: { onEventTap(ev) }) {
EventLabel(event: ev)
.font(.caption.weight(.medium))
.foregroundStyle(.white)
.padding(.horizontal, 8).padding(.vertical, 4)
.background(Color(hex: ev.effectiveColor))
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 12).padding(.vertical, 6)
}
.overlay(alignment: .bottom) { Divider() }
}
private var timeLabels: some View {
VStack(spacing: 0) {
ForEach(hours, id: \.self) { h in
ZStack(alignment: .topTrailing) {
Color.clear.frame(height: hourHeight)
Text(String(format: "%02d:00", h))
.font(.system(size: 10))
.foregroundStyle(Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
.offset(y: -6)
}
}
Color.clear.frame(height: 80)
}
.frame(width: timeColumnWidth)
}
private func nowLineY() -> CGFloat {
let cal = Calendar.current
let h = CGFloat(cal.component(.hour, from: Date()))
let m = CGFloat(cal.component(.minute, from: Date()))
return h * hourHeight + m * hourHeight / 60
}
private func scrollToCurrentHour(_ proxy: ScrollViewProxy) {
let h = Calendar.current.component(.hour, from: .now)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
withAnimation(.easeOut(duration: 0.3)) {
proxy.scrollTo("grid", anchor: UnitPoint(x: 0, y: CGFloat(max(h - 1, 0)) / 24.0))
}
}
}
}
// One-hour slot for the single-column day view.
private struct DayHourSlot: View {
let day: Date
let hour: Int
let hourHeight: CGFloat
let language: String
let onCreateEvent: (Date) -> Void
@AppStorage("lineColor") private var lineHex = "#3A3A52"
@AppStorage("lineContrast") private var lineContrast = 3
private var date: Date {
Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day
}
var body: some View {
VStack(spacing: 0) {
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(height: 0.5)
Color.clear.frame(height: hourHeight - 0.5)
}
.contentShape(Rectangle())
.contextMenu {
Button { onCreateEvent(date) } label: {
Label(L10n.t("cal.new_event", language), systemImage: "plus")
}
}
}
}
@@ -0,0 +1,198 @@
import SwiftUI
struct EventDetailSheet: View {
let event: CalEvent
let api: CalendarrAPI
let store: CalendarStore
/// Called when the sheet should close.
/// - `editEvent`: non-nil when the user wants to edit this event
/// - `forceReload`: true when server data changed (create/copy) and the
/// caller must bypass the cache to fetch fresh events
let onDone: (_ editEvent: CalEvent?, _ forceReload: Bool) async -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@State private var showDeleteConfirm = false
@State private var isDeleting = false
@State private var showCopySheet = false
private let timeFmt: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .short
return f
}()
private let dateFmt: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .none
return f
}()
private var timeString: String {
if event.isAllDay {
if Calendar.current.isDate(event.startDate, inSameDayAs: event.endDate) ||
event.endDate == event.startDate {
return "\(L10n.t("event.allday", appLang)) · \(dateFmt.string(from: event.startDate))"
}
let end = Calendar.current.date(byAdding: .day, value: -1, to: event.endDate) ?? event.endDate
return "\(L10n.t("event.allday", appLang)) · \(dateFmt.string(from: event.startDate)) – \(dateFmt.string(from: end))"
}
return "\(timeFmt.string(from: event.startDate)) – \(timeFmt.string(from: event.endDate))"
}
private var canEdit: Bool {
event.source == "local" || event.source == "caldav" || event.source == "homeassistant"
}
private var canDelete: Bool { canEdit }
private var currentUserId: Int? {
let id = UserDefaults.standard.integer(forKey: "userId")
return id == 0 ? nil : id
}
var body: some View {
NavigationStack {
List {
Section {
HStack(alignment: .top, spacing: 12) {
RoundedRectangle(cornerRadius: 4)
.fill(Color(hex: event.effectiveColor))
.frame(width: 6, height: 44)
VStack(alignment: .leading, spacing: 4) {
Text(event.renderTitle)
.font(.title3.bold())
Text(event.calendarName)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
Section {
Label(timeString, systemImage: "clock")
if !event.location.isEmpty {
Label(event.location, systemImage: "mappin.and.ellipse")
}
if !event.notes.isEmpty {
Label(event.notes, systemImage: "text.alignleft")
}
}
Section {
HStack {
Label(L10n.t("event.calendar_section", appLang), systemImage: "calendar")
Spacer()
Text(event.calendarName)
.foregroundStyle(.secondary)
}
HStack {
Label(L10n.t("detail.source", appLang), systemImage: "server.rack")
Spacer()
Text(event.source.capitalized)
.foregroundStyle(.secondary)
}
if let creator = event.creator, creator.id != currentUserId {
HStack {
Label(L10n.t("detail.created_by", appLang), systemImage: "person")
Spacer()
Text(creator.displayName)
.foregroundStyle(.secondary)
}
}
if event.isPrivate {
Label(L10n.t("event.private", appLang), systemImage: "lock")
.foregroundStyle(.secondary)
}
}
if !store.writableCalendars.isEmpty {
Section {
Button {
showCopySheet = true
} label: {
Label(L10n.t("event.copy_title", appLang), systemImage: "doc.on.doc")
}
}
}
if canDelete {
Section {
Button(role: .destructive) {
showDeleteConfirm = true
} label: {
Label(L10n.t("detail.delete", appLang), systemImage: "trash")
.foregroundStyle(.red)
}
.disabled(isDeleting)
}
}
}
.listStyle(.insetGrouped)
.navigationTitle(L10n.t("detail.title", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.t("common.close", appLang)) {
Task { await onDone(nil, false) }
}
}
if canEdit {
ToolbarItem(placement: .primaryAction) {
Button(L10n.t("detail.edit", appLang)) {
Task { await onDone(event, false) }
}
}
}
}
.alert(L10n.t("detail.delete_confirm_title", appLang), isPresented: $showDeleteConfirm) {
Button(L10n.t("common.delete", appLang), role: .destructive) {
Task { await deleteEvent() }
}
Button(L10n.t("common.cancel", appLang), role: .cancel) {}
} message: {
Text("\"\(event.renderTitle)\" \(L10n.t("detail.delete_msg_suffix", appLang))")
}
.sheet(isPresented: $showCopySheet) {
EventEditorSheet(
api: api,
store: store,
initialDate: event.startDate,
editingEvent: nil,
copyFrom: event
) {
// Copy created a new server-side event → force reload so it appears
await onDone(nil, true)
}
}
}
}
private func deleteEvent() async {
isDeleting = true
do {
switch event.source {
case "local":
try await api.deleteLocalEvent(uid: event.id)
case "homeassistant":
// calendarId looks like "homeassistant-42" → numeric DB id 42
let calId = Int(event.calendarId.replacingOccurrences(of: "homeassistant-", with: "")) ?? 0
try await api.deleteHAEvent(calendarId: calId, uid: event.id)
default:
let calId = Int(event.calendarId)
try await api.deleteCalDAVEvent(uid: event.id, url: event.url, calendarId: calId)
}
// Optimistically drop it from the cache so it vanishes immediately,
// regardless of how long the source takes to propagate the delete.
store.removeCachedEvent(id: event.id)
await onDone(nil, false)
} catch {
isDeleting = false
}
}
}
@@ -0,0 +1,327 @@
import SwiftUI
struct EventEditorSheet: View {
let api: CalendarrAPI
let store: CalendarStore
let initialDate: Date
let editingEvent: CalEvent?
var copyFrom: CalEvent? = nil
let onSaved: () async -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
@AppStorage("defaultEventDurationMinutes") private var defaultEventDurationMinutes = 60
@State private var title = ""
@State private var isAllDay = false
@State private var startDate = Date()
@State private var endDate = Date().addingTimeInterval(3600)
@State private var location = ""
@State private var notes = ""
@State private var selectedCalendarId: String = ""
@State private var color = ""
@State private var isPrivate = false
@State private var reminders: [Int] = []
@State private var isSaving = false
@State private var error = ""
private var isEditing: Bool { editingEvent != nil }
private var isCopying: Bool { copyFrom != nil && editingEvent == nil }
private var selectedCal: WritableCalendar? {
store.writableCalendars.first { $0.id == selectedCalendarId }
}
/// 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 {
NavigationStack {
Form {
Section {
TextField(L10n.t("event.title_placeholder", appLang), text: $title)
.font(.body.weight(.medium))
}
Section {
Toggle(L10n.t("event.allday", appLang), isOn: $isAllDay.animation())
.tint(Color.accentColor)
if isAllDay {
DatePicker(L10n.t("event.start", appLang), selection: $startDate, displayedComponents: .date)
DatePicker(L10n.t("event.end", appLang), selection: $endDate, displayedComponents: .date)
} else {
DatePicker(L10n.t("event.start", appLang), selection: $startDate)
DatePicker(L10n.t("event.end", appLang), selection: $endDate)
}
}
Section {
TextField(L10n.t("event.location", appLang), text: $location)
TextField(L10n.t("event.description", appLang), text: $notes, axis: .vertical)
.lineLimit(3...6)
}
Section(L10n.t("event.calendar_section", appLang)) {
if store.writableCalendars.isEmpty {
Text(L10n.t("event.no_writable", appLang))
.foregroundStyle(.secondary)
.font(.callout)
} else {
Picker(L10n.t("event.calendar_picker", appLang), selection: $selectedCalendarId) {
ForEach(store.writableCalendars) { cal in
HStack {
Circle()
.fill(Color(hex: cal.color))
.frame(width: 10, height: 10)
Text(cal.name)
}
.tag(cal.id)
}
}
}
}
if selectedCal?.source == "local" {
Section {
Toggle(L10n.t("event.private", appLang), isOn: $isPrivate)
.tint(Color.accentColor)
}
Section {
ForEach(reminders.indices, id: \.self) { idx in
ReminderEditRow(minutes: $reminders[idx], appLang: appLang)
}
.onDelete { reminders.remove(atOffsets: $0) }
Button {
let next = ReminderOptions.presets.first { !reminders.contains($0) }
?? ReminderOptions.customDefault
reminders.append(next)
} label: {
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)) {
HStack {
Text(L10n.t("event.color", appLang))
Spacer()
ColorPicker("", selection: Binding(
get: { Color(hex: color.isEmpty ? (selectedCal?.color ?? "#4285f4") : color) },
set: { color = $0.toHex() }
), supportsOpacity: false)
.labelsHidden()
if !color.isEmpty {
Button(L10n.t("event.reset_color", appLang)) { color = "" }
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
if !error.isEmpty {
Section {
Text(error).foregroundStyle(.red)
}
}
}
.navigationTitle(
isEditing ? L10n.t("event.edit_title", appLang) :
isCopying ? L10n.t("event.copy_title", appLang) :
L10n.t("event.new_title", appLang)
)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.t("common.cancel", appLang)) { dismiss() }
}
ToolbarItem(placement: .primaryAction) {
Button(isEditing
? L10n.t("event.save", appLang)
: L10n.t("event.add", appLang)) {
Task { await save() }
}
.bold()
.disabled(title.isEmpty || selectedCalendarId.isEmpty || isSaving)
}
}
}
.onAppear { setup() }
.onChange(of: startDate) { oldStart, newStart in
guard newStart >= endDate else { return }
let duration = endDate.timeIntervalSince(oldStart)
let minDuration: TimeInterval = isAllDay ? 86400 : 3600
endDate = newStart.addingTimeInterval(max(duration, minDuration))
}
}
private func setup() {
if let ev = editingEvent {
title = ev.title
isAllDay = ev.isAllDay
startDate = ev.startDate
// All-day end dates are stored as exclusive (day after last); subtract 1 for the picker.
endDate = ev.isAllDay
? Calendar.current.date(byAdding: .day, value: -1, to: ev.endDate) ?? ev.endDate
: ev.endDate
location = ev.location
notes = ev.notes
color = ev.color ?? ""
isPrivate = ev.isPrivate
reminders = ev.reminders
// HA events use "homeassistant-42" in CalEvent but "ha-42" in WritableCalendar
if ev.source == "homeassistant" {
let num = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
selectedCalendarId = "ha-\(num)"
} else {
selectedCalendarId = ev.calendarId
}
} else if let ev = copyFrom {
title = ev.title
isAllDay = ev.isAllDay
startDate = ev.startDate
endDate = ev.isAllDay
? Calendar.current.date(byAdding: .day, value: -1, to: ev.endDate) ?? ev.endDate
: ev.endDate
location = ev.location
notes = ev.notes
color = ev.color ?? ""
isPrivate = ev.isPrivate
reminders = ev.reminders
selectedCalendarId = store.writableCalendars.first?.id ?? ""
} else {
let cal = Calendar.current
startDate = cal.date(bySettingHour: cal.component(.hour, from: initialDate),
minute: 0, second: 0, of: initialDate) ?? initialDate
let durMin = defaultEventDurationMinutes > 0 ? defaultEventDurationMinutes : 60
endDate = startDate.addingTimeInterval(Double(durMin) * 60)
selectedCalendarId = store.writableCalendars.first?.id ?? ""
// New events inherit the user's default reminder (editable).
if defaultReminderMinutes >= 0 { reminders = [defaultReminderMinutes] }
}
}
private func save() async {
guard let cal = selectedCal else { return }
isSaving = true
error = ""
defer { isSaving = false }
let colorVal: String? = color.isEmpty ? nil : color
let start = isAllDay ? Calendar.current.startOfDay(for: startDate) : startDate
let end = isAllDay ? Calendar.current.startOfDay(for: Calendar.current.date(byAdding: .day, value: 1, to: endDate)!) : endDate
do {
if let ev = editingEvent {
switch ev.source {
case "local":
try await api.updateLocalEvent(uid: ev.id, title: title, start: start, end: end,
isAllDay: isAllDay, location: location, description: notes, color: colorVal,
isPrivate: isPrivate, reminders: reminders)
case "homeassistant":
// No update API exists – delete the old event and recreate with new data.
let rawId = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
let haCalId = Int(rawId) ?? 0
try await api.deleteHAEvent(calendarId: haCalId, uid: ev.id)
try await api.createHAEvent(calendarId: haCalId, title: title,
start: start, end: end, isAllDay: isAllDay,
location: location, description: notes)
default: // caldav
let calId = Int(ev.calendarId)
try await api.updateCalDAVEvent(uid: ev.id, url: ev.url, calendarId: calId,
title: title, start: start, end: end, isAllDay: isAllDay,
location: location, description: notes, color: colorVal)
}
} else {
switch cal.source {
case "local":
_ = try await api.createLocalEvent(calendarId: cal.numericId, title: title,
start: start, end: end, isAllDay: isAllDay,
location: location, description: notes, color: colorVal,
isPrivate: isPrivate, reminders: reminders)
case "google":
try await api.createGoogleEvent(calendarDbId: cal.numericId, title: title,
start: start, end: end, isAllDay: isAllDay,
location: location, description: notes)
case "homeassistant":
try await api.createHAEvent(calendarId: cal.numericId, title: title,
start: start, end: end, isAllDay: isAllDay,
location: location, description: notes)
default: // caldav
try await api.createCalDAVEvent(calendarId: cal.numericId, title: title,
start: start, end: end, isAllDay: isAllDay,
location: location, description: notes, color: colorVal)
}
}
await onSaved()
dismiss()
} catch {
self.error = error.localizedDescription
}
}
}
/// 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)
}
}
}
}
}
@@ -0,0 +1,619 @@
import SwiftUI
// Cover ~10 years back and ~77 years ahead, so the scrollable range goes well
// past 2100 — enough room for any vacation that's actually getting planned.
private let weeksBack = 520
private let weeksAhead = 4000
// 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 dayNumberRowHeight: CGFloat = 22
private let laneHeight: CGFloat = 16
private let laneSpacing: CGFloat = 2
private let maxLanesPerWeek = 5
private enum DividerEdge { case none, topHighlight, bottomHighlight }
struct MonthView: View {
let store: CalendarStore
let onDayTap: (Date) -> Void
let onEventTap: (CalEvent) -> Void
let onCreateEvent: (Date) -> Void
let onShowWeek: (Date) -> Void
let onShowDay: (Date) -> Void
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("monthDividerColor") private var dividerHex = "#7090c0"
@AppStorage("monthLabelColor") private var labelHex = "#7090c0"
@AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("lineColor") private var lineHex = "#3A3A52"
@AppStorage("textContrast") private var textContrast = 3
@AppStorage("monthViewPaged") private var monthPaged = false
@State private var scrolledWeek: Date? = nil
@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 }
/// 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] {
let today = cal.startOfDay(for: .now)
let thisWeek = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: today))!
return (-weeksBack...weeksAhead).compactMap {
cal.date(byAdding: .weekOfYear, value: $0, to: thisWeek)
}
}
private var weekdayHeaders: [String] {
let fmt = DateFormatter(); fmt.locale = L10n.locale(appLang)
let symbols = fmt.shortWeekdaySymbols ?? cal.shortWeekdaySymbols
let start = cal.firstWeekday - 1
return (0..<7).map { i in String(symbols[(start + i) % 7].prefix(2)) }
}
var body: some View {
VStack(spacing: 0) {
headerRow
Divider()
if monthPaged { pagedBody } else { scrollBody }
}
}
// Continuous vertical scroll feed (default).
private var scrollBody: some View {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(weekStarts, id: \.self) { ws in
weekRow(for: ws, fillHeight: false)
.id(ws)
}
}
.scrollTargetLayout()
}
.scrollIndicators(.hidden)
.scrollPosition(id: $scrolledWeek, anchor: .top)
.onAppear {
if !didInitialScroll {
didInitialScroll = true
scrolledWeek = weekStart(for: store.currentDate)
publishVisibleMonth(from: scrolledWeek)
}
}
.onChange(of: store.currentDate) { _, newDate in
let target = weekStart(for: newDate)
if scrolledWeek != target {
withAnimation(.easeInOut(duration: 0.25)) {
scrolledWeek = target
}
}
}
.onChange(of: scrolledWeek) { _, newWeek in
publishVisibleMonth(from: newWeek)
}
}
// 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 {
HStack(spacing: 0) {
ForEach(weekdayHeaders, id: \.self) { d in
Text(d)
.font(.caption2.weight(.semibold))
.foregroundStyle(Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
.frame(maxWidth: .infinity, minHeight: weekdayHeaderHeight)
}
}
}
private func weekStart(for date: Date) -> Date {
cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date))!
}
/// Determine the header month from the currently-scrolled week.
/// Rule: take the month of the topmost visible week's start day. This
/// means as long as the "1." of the next month is still visible in the
/// top row, the header keeps showing the previous month – and only flips
/// to the new month once its "1." has scrolled out of view above.
private func publishVisibleMonth(from week: Date?) {
guard let w = week else { return }
let month = cal.date(from: cal.dateComponents([.year, .month], from: w)) ?? w
if store.visibleMonth != month {
store.visibleMonth = month
}
}
}
// MARK: – Week Row
private struct WeekRow: View {
let weekStart: Date
// Scroll mode uses a fixed row height; paged mode fills the page evenly.
var fillHeight: Bool = false
let store: CalendarStore
let dividerColor: Color
let labelColor: Color
let textColor: Color
let lineColor: Color
let language: String
let onDayTap: (Date) -> Void
let onEventTap: (CalEvent) -> Void
let onCreateEvent: (Date) -> Void
let onShowWeek: (Date) -> Void
let onShowDay: (Date) -> Void
private var cal: Calendar { store.userCalendar }
private var days: [Date] {
(0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: weekStart) }
}
private var weekNumber: Int { cal.component(.weekOfYear, from: weekStart) }
private func columnRange(for ev: CalEvent) -> (startCol: Int, span: Int) {
let weekEnd = cal.date(byAdding: .day, value: 7, to: weekStart)!
let evStart = max(cal.startOfDay(for: ev.startDate), weekStart)
// All-day end is already exclusive; timed end-of-day-on-same-day shouldn't add a column.
let rawEnd: Date
if ev.isAllDay {
rawEnd = ev.endDate
} else {
// Treat timed events as occupying days from start up to and including the day of end.
rawEnd = cal.date(byAdding: .day, value: 1, to: cal.startOfDay(for: ev.endDate))!
}
let evEnd = min(rawEnd, weekEnd)
let sc = max(0, cal.dateComponents([.day], from: weekStart, to: evStart).day ?? 0)
let lastIncl = (cal.dateComponents([.day], from: weekStart, to: evEnd).day ?? 0) - 1
let ec = min(6, lastIncl)
return (sc, max(1, ec - sc + 1))
}
/// Greedy lane packing for events overlapping this week.
private func packEvents() -> (placed: [(event: CalEvent, lane: Int, startCol: Int, span: Int)],
extraPerCol: [Int]) {
let weekEndExclusive = cal.date(byAdding: .day, value: 7, to: weekStart)!
let evs = store.events(in: weekStart, end: weekEndExclusive)
.sorted { a, b in
if a.startDate != b.startDate { return a.startDate < b.startDate }
return a.endDate > b.endDate
}
var laneLastEnd: [Int] = []
var placed: [(CalEvent, Int, Int, Int)] = []
var overflowPerCol = [Int](repeating: 0, count: 7)
for ev in evs {
let (sc, sp) = columnRange(for: ev)
var assigned: Int? = nil
for laneIdx in 0..<laneLastEnd.count {
if laneLastEnd[laneIdx] < sc {
laneLastEnd[laneIdx] = sc + sp - 1
assigned = laneIdx
break
}
}
if assigned == nil {
if laneLastEnd.count < maxLanesPerWeek {
laneLastEnd.append(sc + sp - 1)
assigned = laneLastEnd.count - 1
}
}
if let lane = assigned {
placed.append((ev, lane, sc, sp))
} else {
for c in sc...min(6, sc + sp - 1) {
overflowPerCol[c] += 1
}
}
}
return (placed.map { (event: $0.0, lane: $0.1, startCol: $0.2, span: $0.3) },
overflowPerCol)
}
var body: some View {
let weekEndExclusive = cal.date(byAdding: .day, value: 7, to: weekStart)!
let (placed, extras) = packEvents()
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
// Where in this row does a new month start? (col 1...6 = mid-row step; nil = no step)
let midRowBoundaryCol: Int? = {
for idx in 1..<7 where cal.component(.day, from: days[idx]) == 1 { return idx }
return nil
}()
let rowStartsNewMonth = cal.component(.day, from: days[0]) == 1
GeometryReader { geo in
let cellW = geo.size.width / 7
ZStack(alignment: .topLeading) {
HStack(spacing: 0) {
ForEach(Array(days.enumerated()), id: \.offset) { idx, day in
let edge: DividerEdge = {
if let b = midRowBoundaryCol {
return idx < b ? .bottomHighlight : .topHighlight
}
return rowStartsNewMonth ? .topHighlight : .none
}()
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,
isToday: cal.isDateInToday(day),
monthLabelColor: labelColor,
dividerColor: dividerColor,
textColor: textColor,
lineColor: lineColor,
language: language,
extraCount: extras[idx],
weekNumber: idx == mondayIdx ? weekNumber : nil,
cwLabel: L10n.t("cal.cw", language),
edge: edge,
dayEvents: eventsForDay,
onTap: { onDayTap(day) },
onCreateEvent: { onCreateEvent(day) },
onShowWeek: { onShowWeek(day) },
onShowDay: { onShowDay(day) })
.frame(width: cellW, height: geo.size.height)
}
}
ForEach(Array(placed.enumerated()), id: \.offset) { _, p in
Button { onEventTap(p.event) } label: {
EventBar(event: p.event)
.frame(width: cellW * CGFloat(p.span) - 2, height: laneHeight)
}
.buttonStyle(.plain)
.offset(x: CGFloat(p.startCol) * cellW + 1,
y: dayNumberRowHeight + CGFloat(p.lane) * (laneHeight + laneSpacing))
}
// Vertical connector at the month-boundary column – ties the bottom-line
// of old-month cells to the top-line of new-month cells into a step.
if let b = midRowBoundaryCol {
Rectangle()
.fill(dividerColor)
.frame(width: 1.5, height: geo.size.height)
.offset(x: CGFloat(b) * cellW - 0.75, y: 0)
}
}
}
.frame(height: fillHeight ? nil : rowHeight)
.frame(maxHeight: fillHeight ? .infinity : nil)
}
}
// MARK: – Day Cell
private struct DayCell: View {
let date: Date
let isToday: Bool
let monthLabelColor: Color
let dividerColor: Color
let textColor: Color
let lineColor: Color
let language: String
let extraCount: Int
let weekNumber: Int?
let cwLabel: String
let edge: DividerEdge
let dayEvents: [CalEvent]
let onTap: () -> Void
let onCreateEvent: () -> Void
let onShowWeek: () -> Void
let onShowDay: () -> Void
@AppStorage("textContrast") private var textContrast = 3
@AppStorage("lineContrast") private var lineContrast = 3
private var cal: Calendar { Calendar.current }
private var dayNum: Int { cal.component(.day, from: date) }
private var isFirstOfMonth: Bool { dayNum == 1 }
private var monthAbbrev: String {
let f = DateFormatter()
f.locale = L10n.locale(language)
f.dateFormat = "LLL"
return f.string(from: date).uppercased()
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Button(action: onTap) {
HStack(spacing: 4) {
Text("\(dayNum)")
.font(.system(size: 13, weight: isToday ? .bold : .regular))
.foregroundStyle(isToday ? Color.white : textColor)
.frame(width: 22, height: 22)
.background(isToday ? Color.accentColor : Color.clear)
.clipShape(Circle())
if isFirstOfMonth {
Text(monthAbbrev)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(monthLabelColor)
.lineLimit(1)
.fixedSize()
}
Spacer(minLength: 0)
}
.padding(.leading, 4)
.padding(.top, 2)
}
.buttonStyle(.plain)
Spacer(minLength: 0)
HStack(spacing: 0) {
if extraCount > 0 {
Text("+\(extraCount)")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(textColor.opacity(secondaryTextOpacity(textContrast)))
.padding(.leading, 4)
}
Spacer(minLength: 0)
if let wn = weekNumber {
Text("\(cwLabel) \(wn)")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(textColor.opacity(secondaryTextOpacity(textContrast)))
.padding(.trailing, 4)
}
}
.padding(.bottom, 1)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.overlay(alignment: .trailing) {
Rectangle().fill(lineColor.opacity(gridLineOpacity(lineContrast))).frame(width: 0.5)
}
.overlay(alignment: .top) {
Rectangle()
.fill(edge == .topHighlight ? dividerColor : lineColor.opacity(gridLineOpacity(lineContrast)))
.frame(height: edge == .topHighlight ? 1.5 : 0.5)
}
.overlay(alignment: .bottom) {
if edge == .bottomHighlight {
Rectangle().fill(dividerColor).frame(height: 1.5)
}
}
.contentShape(Rectangle())
.contextMenu {
Button { onCreateEvent() } label: {
Label(L10n.t("cal.new_event", language), systemImage: "plus")
}
Button { onShowWeek() } label: {
Label(L10n.t("cal.show_in_week_view", language),
systemImage: "calendar.day.timeline.leading")
}
Button { onShowDay() } label: {
Label(L10n.t("cal.show_in_day_view", language), systemImage: "sun.max")
}
} 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
private struct EventBar: View {
let event: CalEvent
@AppStorage("dimPastEvents") private var dimPast = false
private var isPast: Bool { event.endDate < .now }
var body: some View {
HStack(spacing: 3) {
EventLabel(event: event)
.font(.system(size: 10, weight: .medium))
.lineLimit(1)
.foregroundStyle(.white)
.padding(.leading, 4)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(hex: event.effectiveColor))
.clipShape(RoundedRectangle(cornerRadius: 3))
.opacity(dimPast && isPast ? 0.5 : 1.0)
}
}
@@ -0,0 +1,118 @@
import SwiftUI
struct QuarterView: View {
let store: CalendarStore
let onEventTap: (CalEvent) -> Void
private var cal: Calendar { store.userCalendar }
private var months: [Date] {
let start = cal.date(from: cal.dateComponents([.year, .month], from: store.currentDate))!
return (0..<3).compactMap { cal.date(byAdding: .month, value: $0, to: start) }
}
var body: some View {
ScrollView {
VStack(spacing: 0) {
ForEach(months, id: \.self) { month in
MiniMonthBlock(month: month, store: store, onEventTap: onEventTap)
Divider()
}
}
}
}
}
private struct MiniMonthBlock: View {
let month: Date
let store: CalendarStore
let onEventTap: (CalEvent) -> Void
private var cal: Calendar { store.userCalendar }
private let monthFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "MMMM yyyy"; return f
}()
private var gridDays: [Date] {
let firstWeekday = cal.firstWeekday
let weekday = cal.component(.weekday, from: month)
let offset = ((weekday - firstWeekday) + 7) % 7
let gridStart = cal.date(byAdding: .day, value: -offset, to: month)!
let rows = 6
return (0..<(rows * 7)).compactMap { cal.date(byAdding: .day, value: $0, to: gridStart) }
}
private var weekdayHeaders: [String] {
let symbols = cal.veryShortWeekdaySymbols
let start = cal.firstWeekday - 1
return (0..<7).map { symbols[(start + $0) % 7] }
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(monthFmt.string(from: month))
.font(.headline.weight(.semibold))
.padding(.horizontal, 16)
.padding(.top, 12)
HStack(spacing: 0) {
ForEach(weekdayHeaders, id: \.self) { d in
Text(d)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
}
}
.padding(.horizontal, 8)
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 0), count: 7), spacing: 2) {
ForEach(gridDays, id: \.self) { day in
MiniDayCell(
date: day,
isCurrentMonth: cal.isDate(day, equalTo: month, toGranularity: .month),
isToday: cal.isDateInToday(day),
events: store.events(on: day),
onEventTap: onEventTap
)
}
}
.padding(.horizontal, 8)
.padding(.bottom, 12)
}
}
}
private struct MiniDayCell: View {
let date: Date
let isCurrentMonth: Bool
let isToday: Bool
let events: [CalEvent]
let onEventTap: (CalEvent) -> Void
var body: some View {
VStack(spacing: 1) {
Text("\(Calendar.current.component(.day, from: date))")
.font(.system(size: 12, weight: isToday ? .bold : .regular))
.foregroundStyle(
isToday ? Color.white :
isCurrentMonth ? Color.primary : Color.secondary.opacity(0.3)
)
.frame(width: 22, height: 22)
.background(isToday ? Color.accentColor : Color.clear)
.clipShape(Circle())
// Up to 3 event dots
HStack(spacing: 2) {
ForEach(events.prefix(3)) { ev in
Circle()
.fill(Color(hex: ev.effectiveColor))
.frame(width: 4, height: 4)
.onTapGesture { onEventTap(ev) }
}
}
.frame(height: 6)
}
.frame(minHeight: 36)
}
}
@@ -0,0 +1,93 @@
import SwiftUI
// Shared constants used by WeekView, DayView, EventEditorSheet
let timeColumnWidth: CGFloat = 44
let hours = Array(0..<24)
/// Live hour-row height, driven by the synced `hourHeight` setting.
/// Falls back to 60 when unset (fresh install / value 0). Views that lay out
/// against this also observe `@AppStorage("hourHeight")` so their body
/// re-renders when it changes.
var hourHeight: CGFloat {
let v = UserDefaults.standard.integer(forKey: "hourHeight")
return v > 0 ? CGFloat(v) : 60
}
/// Opacity for secondary text (weekday headers, time labels, "+N"/"KW"),
/// mapped from the 1–4 `textContrast` level. Level 3 ≈ the previous hard-coded
/// look so existing installs are visually unchanged.
func secondaryTextOpacity(_ level: Int) -> Double {
switch level {
case 1: return 0.4
case 2: return 0.55
case 4: return 1.0
default: return 0.75
}
}
/// Opacity for grid lines / separators, mapped from the 1–4 `lineContrast`
/// level. Level 3 ≈ the previous hard-coded ~0.4 look.
func gridLineOpacity(_ level: Int) -> Double {
switch level {
case 1: return 0.15
case 2: return 0.3
case 4: return 0.8
default: return 0.5
}
}
/// A timed (non-all-day) event that crosses a day boundary. Such events must
/// NOT be placed in the hourly grid — their height would be `duration ×
/// hourHeight`, i.e. taller than the whole day, rendering as a giant block
/// (and, sharing one id across days, only drawing on the first day). They are
/// shown in the all-day strip instead, like all-day events.
func eventSpansMultipleDays(_ ev: CalEvent) -> Bool {
guard !ev.isAllDay, ev.endDate > ev.startDate else { return false }
let cal = Calendar.current
// End is exclusive: an event ending exactly at midnight is still single-day.
let lastInstant = ev.endDate.addingTimeInterval(-1)
return !cal.isDate(ev.startDate, inSameDayAs: lastInstant)
}
// Position helpers
func eventTop(_ ev: CalEvent) -> CGFloat {
let cal = Calendar.current
let h = CGFloat(cal.component(.hour, from: ev.startDate))
let m = CGFloat(cal.component(.minute, from: ev.startDate))
return h * hourHeight + m * hourHeight / 60
}
func eventHeight(_ ev: CalEvent) -> CGFloat {
let dur = ev.endDate.timeIntervalSince(ev.startDate)
return max(CGFloat(dur / 3600) * hourHeight, 20)
}
// Shared event block used in WeekView and DayView
struct EventBlock: View {
let event: CalEvent
@AppStorage("dimPastEvents") private var dimPast = false
private var isPast: Bool { event.endDate < .now }
var body: some View {
RoundedRectangle(cornerRadius: 4)
.fill(Color(hex: event.effectiveColor).opacity(0.85))
.overlay(alignment: .topLeading) {
VStack(alignment: .leading, spacing: 1) {
EventLabel(event: event)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white)
.lineLimit(2)
if !event.location.isEmpty {
Text(event.location)
.font(.system(size: 10))
.foregroundStyle(.white.opacity(0.85))
.lineLimit(1)
}
}
.padding(4)
}
.padding(.horizontal, 1)
.opacity(dimPast && isPast ? 0.5 : 1.0)
}
}
@@ -0,0 +1,270 @@
import SwiftUI
struct WeekView: View {
let store: CalendarStore
let onEventTap: (CalEvent) -> Void
let onCreateEvent: (Date) -> Void
let onShowMonth: (Date) -> Void
let onShowDay: (Date) -> Void
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("todayColor") private var todayHex = "#4285f4"
@AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("lineColor") private var lineHex = "#3A3A52"
@AppStorage("textContrast") private var textContrast = 3
@AppStorage("lineContrast") private var lineContrast = 3
@AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout
private var cal: Calendar { store.userCalendar }
private var weekDays: [Date] {
let start = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: store.currentDate))!
return (0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: start) }
}
private var timedEvents: [(Int, CalEvent)] {
weekDays.enumerated().flatMap { idx, day in
store.events(on: day)
.filter { !$0.isAllDay && !eventSpansMultipleDays($0) }
.map { (idx, $0) }
}
}
private var allDayEvents: [CalEvent] {
let s = weekDays.first ?? .now
let e = cal.date(byAdding: .day, value: 1, to: weekDays.last ?? .now)!
return store.events(in: s, end: e).filter { $0.isAllDay || eventSpansMultipleDays($0) }
}
private var todayIndex: Int? {
weekDays.firstIndex(where: { cal.isDateInToday($0) })
}
private let headerFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "EEE d"; return f
}()
var body: some View {
VStack(spacing: 0) {
columnHeaders
Divider()
if !allDayEvents.isEmpty { allDayRow }
timeGrid
}
}
// MARK: – Column headers (fixed height — no Color.clear tricks)
private var columnHeaders: some View {
HStack(spacing: 0) {
Spacer().frame(width: timeColumnWidth, height: 36) // fixed height!
ForEach(weekDays, id: \.self) { day in
Text(headerFmt.string(from: day).uppercased())
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(cal.isDateInToday(day) ? Color(hex: todayHex) : Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
.frame(maxWidth: .infinity, minHeight: 36)
.overlay(alignment: .trailing) {
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(width: 0.5)
}
}
}
}
// 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 {
let bars = allDayBars()
let laneCount = max(1, (bars.map { $0.lane }.max() ?? -1) + 1)
return GeometryReader { geo in
let colW = (geo.size.width - timeColumnWidth) / 7
ZStack(alignment: .topLeading) {
ForEach(bars, id: \.ev.id) { bar in
Button { onEventTap(bar.ev) } label: {
EventLabel(event: bar.ev)
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.white)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 3)
.frame(height: allDayLaneHeight - 2)
.background(Color(hex: bar.ev.effectiveColor))
.clipShape(RoundedRectangle(cornerRadius: 2))
}
.buttonStyle(.plain)
.frame(width: colW * CGFloat(bar.end - bar.start + 1) - 3)
.offset(x: timeColumnWidth + colW * CGFloat(bar.start) + 1.5,
y: CGFloat(bar.lane) * allDayLaneHeight)
}
}
}
.frame(height: CGFloat(laneCount) * allDayLaneHeight)
.padding(.vertical, 4)
.overlay(alignment: .bottom) { Divider() }
}
// MARK: – Time grid (GeometryReader OUTSIDE ScrollView → clean layout)
private var timeGrid: some View {
GeometryReader { geo in
let colW = (geo.size.width - timeColumnWidth) / 7
ScrollViewReader { proxy in
ScrollView {
ZStack(alignment: .topLeading) {
// Background: time labels + per-hour cells per day
HStack(alignment: .top, spacing: 0) {
timeLabels
ForEach(Array(weekDays.enumerated()), id: \.offset) { _, day in
VStack(spacing: 0) {
ForEach(hours, id: \.self) { hour in
HourSlot(day: day, hour: hour,
hourHeight: hourHeight,
language: appLang,
onCreateEvent: onCreateEvent,
onShowMonth: onShowMonth,
onShowDay: onShowDay)
}
}
.frame(width: colW)
.overlay(alignment: .trailing) {
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(width: 0.5)
}
}
}
// Events – positioned using known column widths (no GeometryReader inside)
ForEach(timedEvents, id: \.1.id) { dayIdx, ev in
Button(action: { onEventTap(ev) }) {
EventBlock(event: ev)
}
.buttonStyle(.plain)
.frame(width: colW - 2, height: max(eventHeight(ev), 18))
.offset(x: timeColumnWidth + CGFloat(dayIdx) * colW + 1,
y: eventTop(ev))
}
// Current time line
if let ti = todayIndex {
let lineY = eventTop(Date.now)
let nowColor = Color(hex: todayHex)
HStack(spacing: 0) {
Spacer().frame(width: timeColumnWidth + CGFloat(ti) * colW - 4)
Circle().fill(nowColor).frame(width: 8, height: 8)
Rectangle().fill(nowColor).frame(width: colW - 4, height: 1.5)
}
.offset(y: lineY - 0.75)
}
}
.frame(width: geo.size.width, height: hourHeight * 24 + 80)
.id("grid")
}
.onAppear { scrollToCurrentHour(proxy) }
.onChange(of: store.currentDate) { _, _ in scrollToCurrentHour(proxy) }
}
}
}
private var timeLabels: some View {
VStack(spacing: 0) {
ForEach(hours, id: \.self) { h in
ZStack(alignment: .topTrailing) {
Color.clear.frame(height: hourHeight)
Text(String(format: "%02d:00", h))
.font(.system(size: 10))
.foregroundStyle(Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
.offset(y: -6)
}
}
Color.clear.frame(height: 80) // FAB space
}
.frame(width: timeColumnWidth)
}
private func scrollToCurrentHour(_ proxy: ScrollViewProxy) {
let h = Calendar.current.component(.hour, from: .now)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
withAnimation(.easeOut(duration: 0.3)) {
proxy.scrollTo("grid", anchor: UnitPoint(x: 0, y: CGFloat(max(h - 1, 0)) / 24.0))
}
}
}
}
// Trick to compute eventTop from a Date instead of CalEvent
private func eventTop(_ date: Date) -> CGFloat {
let cal = Calendar.current
let h = CGFloat(cal.component(.hour, from: date))
let m = CGFloat(cal.component(.minute, from: date))
return h * hourHeight + m * hourHeight / 60
}
// One-hour slot with native long-press context menu.
struct HourSlot: View {
let day: Date
let hour: Int
let hourHeight: CGFloat
let language: String
let onCreateEvent: (Date) -> Void
let onShowMonth: (Date) -> Void
let onShowDay: (Date) -> Void
@AppStorage("lineColor") private var lineHex = "#3A3A52"
@AppStorage("lineContrast") private var lineContrast = 3
private var date: Date {
Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day
}
var body: some View {
VStack(spacing: 0) {
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(height: 0.5)
Color.clear.frame(height: hourHeight - 0.5)
}
.contentShape(Rectangle())
.contextMenu {
Button { onCreateEvent(date) } label: {
Label(L10n.t("cal.new_event", language), systemImage: "plus")
}
Button { onShowMonth(date) } label: {
Label(L10n.t("cal.show_in_month_view", language), systemImage: "calendar")
}
Button { onShowDay(date) } label: {
Label(L10n.t("cal.show_in_day_view", language), systemImage: "sun.max")
}
}
}
}
@@ -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) }
}
}
@@ -0,0 +1,23 @@
import SwiftUI
/// Modal wrapper around `CalendarFilterContent` (kept for contexts that still
/// present the filter as a sheet). The drawer embeds the same content directly.
struct CalendarFilterSheet: View {
let api: CalendarrAPI
let store: CalendarStore
@Environment(\.dismiss) private var dismiss
@AppStorage("appLanguage") private var appLang = "system"
var body: some View {
NavigationStack {
CalendarFilterContent(api: api, store: store)
.navigationTitle(L10n.t("filter.title", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(L10n.t("nav.done", appLang)) { dismiss() }
}
}
}
}
}
+412
View File
@@ -0,0 +1,412 @@
import SwiftUI
// MARK: - Group icons (cross-platform, non-emoji)
/// Canonical group-icon keys stored server-side and rendered as native SF
/// Symbols here (Material on Android, SVG on web), so groups look consistent
/// instead of relying on OS-specific emoji rendering.
enum GroupIcons {
static let keys = ["people", "home", "heart", "work", "school", "sports",
"party", "pet", "travel", "music", "food", "star"]
static func symbol(_ key: String?) -> String {
switch key {
case "people": return "person.2.fill"
case "home": return "house.fill"
case "heart": return "heart.fill"
case "work": return "briefcase.fill"
case "school": return "graduationcap.fill"
case "sports": return "figure.run"
case "party": return "party.popper.fill"
case "pet": return "pawprint.fill"
case "travel": return "airplane"
case "music": return "music.note"
case "food": return "fork.knife"
case "star": return "star.fill"
default: return "person.2.fill"
}
}
static func isKey(_ s: String?) -> Bool { if let s { return keys.contains(s) }; return false }
}
/// Render a group's icon: native SF Symbol for known keys, the legacy emoji for
/// pre-migration groups, else a default people glyph.
struct GroupIconView: View {
let icon: String?
var body: some View {
if GroupIcons.isKey(icon) {
Image(systemName: GroupIcons.symbol(icon))
} else if let e = icon, !e.isEmpty {
Text(e)
} else {
Image(systemName: "person.2.fill")
}
}
}
// MARK: - Groups list
struct GroupsView: View {
let api: CalendarrAPI
@AppStorage("appLanguage") private var appLang = "system"
@State private var groups: [CalGroup] = []
@State private var isLoading = true
@State private var showCreate = false
var body: some View {
Group {
if isLoading {
ProgressView()
} else {
List {
if groups.isEmpty {
Text(L10n.t("groups.none", appLang)).foregroundStyle(.secondary)
}
ForEach(groups) { g in
NavigationLink {
GroupCombinedView(api: api, group: g)
} label: {
HStack {
GroupIconView(icon: g.icon)
Text(g.name)
Spacer()
if let n = g.memberCount {
Text("\(n)").font(.caption).foregroundStyle(.secondary)
}
}
}
.swipeActions {
NavigationLink {
GroupManageSheet(api: api, groupId: g.id) { await load() }
} label: {
Label(L10n.t("group.manage", appLang), systemImage: "slider.horizontal.3")
}.tint(.gray)
}
}
}
}
}
.navigationTitle(L10n.t("groups.title", appLang))
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button { showCreate = true } label: { Image(systemName: "plus") }
}
}
.sheet(isPresented: $showCreate) {
GroupEditSheet(api: api, existing: nil) { await load() }
}
.task { await load() }
}
private func load() async {
isLoading = true
groups = (try? await api.getGroups()) ?? []
isLoading = false
}
}
// MARK: - Create / edit a group (name + icon + members)
struct GroupEditSheet: View {
let api: CalendarrAPI
let existing: CalGroup?
let onDone: () async -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@State private var name = ""
@State private var icon = "people"
@State private var directory: [DirectoryUser] = []
@State private var selected: Set<Int> = []
@State private var error = ""
private let icons = GroupIcons.keys
private let cols = [GridItem(.adaptive(minimum: 46))]
var body: some View {
NavigationStack {
Form {
Section(L10n.t("group.name", appLang)) {
TextField(L10n.t("group.name", appLang), text: $name)
}
Section(L10n.t("group.icon", appLang)) {
LazyVGrid(columns: cols, spacing: 8) {
ForEach(icons, id: \.self) { ic in
Image(systemName: GroupIcons.symbol(ic))
.font(.title3)
.frame(width: 44, height: 44)
.background(ic == icon ? Color.accentColor.opacity(0.25) : Color(.systemGray6))
.foregroundStyle(ic == icon ? Color.accentColor : .primary)
.clipShape(RoundedRectangle(cornerRadius: 8))
.onTapGesture { icon = ic }
}
}
}
Section(L10n.t("group.members", appLang)) {
ForEach(directory) { u in
Button {
if selected.contains(u.id) { selected.remove(u.id) } else { selected.insert(u.id) }
} label: {
HStack {
Image(systemName: selected.contains(u.id) ? "checkmark.square.fill" : "square")
.foregroundStyle(selected.contains(u.id) ? Color.accentColor : .secondary)
Text(u.displayName).foregroundStyle(.primary)
}
}
}
}
if !error.isEmpty { Section { Text(error).foregroundStyle(.red) } }
}
.navigationTitle(existing == nil ? L10n.t("group.create", appLang) : L10n.t("group.manage", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button(L10n.t("event.save", appLang)) { Task { await save() } }
.bold().disabled(name.isEmpty)
}
}
}
.task { directory = (try? await api.getUserDirectory()) ?? [] }
}
private func save() async {
do {
_ = try await api.createGroup(name: name, memberIds: Array(selected), icon: icon)
await onDone()
dismiss()
} catch { self.error = error.localizedDescription }
}
}
// MARK: - Manage existing group (rename, icon, members, colors, delete)
struct GroupManageSheet: View {
let api: CalendarrAPI
let groupId: Int
let onDone: () async -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@State private var group: CalGroup?
@State private var name = ""
@State private var icon = "people"
@State private var directory: [DirectoryUser] = []
@State private var memberIds: Set<Int> = []
@State private var showDeleteConfirm = false
@State private var error = ""
private let icons = GroupIcons.keys
private let cols = [GridItem(.adaptive(minimum: 46))]
var body: some View {
NavigationStack {
Form {
Section(L10n.t("group.name", appLang)) {
TextField(L10n.t("group.name", appLang), text: $name)
}
Section(L10n.t("group.icon", appLang)) {
LazyVGrid(columns: cols, spacing: 8) {
ForEach(icons, id: \.self) { ic in
Image(systemName: GroupIcons.symbol(ic))
.font(.title3)
.frame(width: 44, height: 44)
.background(ic == icon ? Color.accentColor.opacity(0.25) : Color(.systemGray6))
.foregroundStyle(ic == icon ? Color.accentColor : .primary)
.clipShape(RoundedRectangle(cornerRadius: 8))
.onTapGesture { icon = ic }
}
}
}
Section(L10n.t("group.members", appLang)) {
ForEach(directory) { u in
Button {
if memberIds.contains(u.id) { memberIds.remove(u.id) } else { memberIds.insert(u.id) }
} label: {
HStack {
Image(systemName: memberIds.contains(u.id) ? "checkmark.square.fill" : "square")
.foregroundStyle(memberIds.contains(u.id) ? Color.accentColor : .secondary)
Text(u.displayName).foregroundStyle(.primary)
}
}
}
}
if let members = group?.members {
Section(L10n.t("group.member_colors", appLang)) {
ForEach(members) { m in
MemberColorRow(api: api, groupId: groupId, member: m)
}
}
}
Section {
Button(role: .destructive) { showDeleteConfirm = true } label: {
Label(L10n.t("group.delete", appLang), systemImage: "trash")
}
}
if !error.isEmpty { Section { Text(error).foregroundStyle(.red) } }
}
.navigationTitle(L10n.t("group.manage", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button(L10n.t("event.save", appLang)) { Task { await save() } }.bold()
}
}
.alert(L10n.t("group.delete", appLang), isPresented: $showDeleteConfirm) {
Button(L10n.t("group.delete", appLang), role: .destructive) { Task { await deleteGroup() } }
Button(L10n.t("common.cancel", appLang), role: .cancel) {}
}
}
.task { await load() }
}
private func load() async {
directory = (try? await api.getUserDirectory()) ?? []
if let g = try? await api.getGroup(id: groupId) {
group = g
name = g.name
icon = GroupIcons.isKey(g.icon) ? g.icon! : "people"
let me = UserDefaults.standard.integer(forKey: "userId")
memberIds = Set((g.members ?? []).map { $0.id }.filter { $0 != me })
}
}
private func save() async {
do {
try await api.updateGroup(id: groupId, name: name, icon: icon)
let me = UserDefaults.standard.integer(forKey: "userId")
let current = Set((group?.members ?? []).map { $0.id }.filter { $0 != me })
for id in memberIds where !current.contains(id) { try await api.addGroupMember(groupId: groupId, userId: id) }
for id in current where !memberIds.contains(id) { try await api.removeGroupMember(groupId: groupId, userId: id) }
await onDone()
dismiss()
} catch { self.error = error.localizedDescription }
}
private func deleteGroup() async {
do { try await api.deleteGroup(id: groupId); await onDone(); dismiss() }
catch { self.error = error.localizedDescription }
}
}
struct MemberColorRow: View {
let api: CalendarrAPI
let groupId: Int
let member: GroupMember
@State private var color: Color
init(api: CalendarrAPI, groupId: Int, member: GroupMember) {
self.api = api; self.groupId = groupId; self.member = member
_color = State(initialValue: Color(hex: member.color ?? "#4285f4"))
}
var body: some View {
HStack {
Text(member.displayName ?? "—")
Spacer()
ColorPicker("", selection: $color, supportsOpacity: false)
.labelsHidden()
.onChange(of: color) { _, c in
Task { try? await api.setGroupMemberColor(groupId: groupId, userId: member.id, color: c.toHex()) }
}
}
}
}
// MARK: - Combined (overlay) agenda view
struct GroupCombinedView: View {
let api: CalendarrAPI
let group: CalGroup
@AppStorage("appLanguage") private var appLang = "system"
@State private var anchor = Date()
@State private var events: [CalEvent] = []
@State private var isLoading = false
private var monthRange: (Date, Date) {
let cal = Calendar.current
let start = cal.date(from: cal.dateComponents([.year, .month], from: anchor)) ?? anchor
let end = cal.date(byAdding: .month, value: 1, to: start) ?? anchor
return (start, end)
}
private var grouped: [(day: Date, items: [CalEvent])] {
let cal = Calendar.current
let dict = Dictionary(grouping: events.sorted { $0.startDate < $1.startDate }) {
cal.startOfDay(for: $0.startDate)
}
return dict.keys.sorted().map { ($0, dict[$0] ?? []) }
}
private let monthFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "MMMM yyyy"; return f
}()
private let dayFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "EEEE, d. MMM"; return f
}()
private let timeFmt: DateFormatter = {
let f = DateFormatter(); f.timeStyle = .short; f.dateStyle = .none; return f
}()
var body: some View {
List {
ForEach(grouped, id: \.day) { section in
Section(dayFmt.string(from: section.day)) {
ForEach(section.items) { ev in
HStack(spacing: 10) {
RoundedRectangle(cornerRadius: 3)
.fill(Color(hex: ev.effectiveColor))
.frame(width: 5, height: 34)
VStack(alignment: .leading, spacing: 2) {
Text(displayTitle(ev)).font(.body)
Text(ev.isAllDay ? L10n.t("event.allday", appLang) : timeFmt.string(from: ev.startDate))
.font(.caption).foregroundStyle(.secondary)
}
}
}
}
}
if !isLoading && events.isEmpty {
Text(L10n.t("groups.combined_empty", appLang)).foregroundStyle(.secondary)
}
}
.navigationTitle(group.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
HStack {
Button { shift(-1) } label: { Image(systemName: "chevron.left") }
Button { shift(1) } label: { Image(systemName: "chevron.right") }
}
}
ToolbarItem(placement: .principal) {
Text(monthFmt.string(from: anchor)).font(.headline)
}
}
.task(id: anchor) { await load() }
}
// Prefer the server-decorated title; fall back to a name prefix.
private func displayTitle(_ ev: CalEvent) -> String {
if let dt = ev.displayTitle, !dt.isEmpty { return dt }
let me = UserDefaults.standard.integer(forKey: "userId")
if let c = ev.creator, ev.isGroupEvent, c.id != me { return "\(firstName(c.displayName)): \(ev.title)" }
if let o = ev.owner, o.id != me { return "\(firstName(o.displayName)): \(ev.title)" }
return ev.title
}
private func firstName(_ s: String) -> String { s.split(separator: " ").first.map(String.init) ?? s }
private func shift(_ months: Int) {
anchor = Calendar.current.date(byAdding: .month, value: months, to: anchor) ?? anchor
}
private func load() async {
isLoading = true
let (s, e) = monthRange
events = (try? await api.fetchGroupCombined(groupId: group.id, start: s, end: e)) ?? []
isLoading = false
}
}
+215
View File
@@ -0,0 +1,215 @@
import Foundation
import SwiftUI
struct LoginView: View {
@Environment(AppState.self) var appState
@AppStorage("appLanguage") private var appLang = "system"
@State private var username = ""
@State private var password = ""
@State private var totpCode = ""
@State private var rememberMe = true
@State private var needsTOTP = false
@State private var error = ""
@State private var isLoading = false
@State private var ssoProviders: [OIDCProvider] = []
/// Key of the provider whose flow is running — per-provider, so a second
/// button does not also spin.
@State private var ssoBusyKey: String?
@State private var oidc = OIDCAuthenticator()
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 0) {
Spacer().frame(height: 60)
VStack(spacing: 8) {
Image(systemName: "calendar")
.font(.system(size: 48, weight: .light))
.foregroundStyle(Color.accentColor)
Text("Calendarr")
.font(.largeTitle.bold())
Text(appState.serverURL
.replacingOccurrences(of: "https://", with: "")
.replacingOccurrences(of: "http://", with: ""))
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.bottom, 40)
VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text(L10n.t("login.username", appLang))
.font(.footnote.weight(.medium))
.foregroundStyle(.secondary)
TextField(L10n.t("login.username", appLang), text: $username)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.padding(12)
.background(.quaternary)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
VStack(alignment: .leading, spacing: 6) {
Text(L10n.t("login.password", appLang))
.font(.footnote.weight(.medium))
.foregroundStyle(.secondary)
SecureField(L10n.t("login.password", appLang), text: $password)
.padding(12)
.background(.quaternary)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
if needsTOTP {
VStack(alignment: .leading, spacing: 6) {
Text(L10n.t("login.totp", appLang))
.font(.footnote.weight(.medium))
.foregroundStyle(.secondary)
TextField(L10n.t("login.totp_placeholder", appLang), text: $totpCode)
.keyboardType(.numberPad)
.padding(12)
.background(.quaternary)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
.transition(.move(edge: .top).combined(with: .opacity))
}
Toggle(L10n.t("login.remember", appLang), isOn: $rememberMe)
.tint(Color.accentColor)
if !error.isEmpty {
Text(error)
.font(.caption)
.foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading)
}
Button {
Task { await login() }
} label: {
HStack {
if isLoading {
ProgressView().tint(.white)
} else {
Text(L10n.t("login.signin", appLang)).fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.padding(14)
.background(Color.accentColor)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(username.isEmpty || password.isEmpty || isLoading)
// Single sign-on — hidden entirely when the server has
// no provider configured.
if !ssoProviders.isEmpty {
HStack(spacing: 12) {
Rectangle().frame(height: 1).foregroundStyle(.quaternary)
Text(L10n.t("sso.or", appLang))
.font(.caption)
.foregroundStyle(.secondary)
Rectangle().frame(height: 1).foregroundStyle(.quaternary)
}
.padding(.top, 8)
ForEach(ssoProviders) { provider in
Button {
Task { await signInWithSSO(provider) }
} label: {
HStack {
if ssoBusyKey == provider.key {
ProgressView()
} else {
Text(String(format: L10n.t("sso.login_with", appLang),
provider.name))
.fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.padding(14)
.background(Color.accentColor.opacity(0.12))
.foregroundStyle(Color.accentColor)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(isLoading || ssoBusyKey != nil)
}
}
}
.padding(.horizontal, 32)
.animation(.easeInOut, value: needsTOTP)
.animation(.easeInOut, value: ssoProviders)
Spacer().frame(height: 40)
Button(L10n.t("login.choose_server", appLang)) {
appState.resetServer()
}
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.navigationBarHidden(true)
.task { await loadSSOProviders() }
}
}
/// Best-effort: an older server or a network hiccup simply means no button.
private func loadSSOProviders() async {
guard ssoProviders.isEmpty else { return }
ssoProviders = (try? await CalendarrAPI.oidcProviders(baseURL: appState.serverURL)) ?? []
}
private func signInWithSSO(_ provider: OIDCProvider) async {
ssoBusyKey = provider.key
error = ""
defer { ssoBusyKey = nil }
do {
let tokens = try await oidc.authorize(provider: provider)
let result = try await CalendarrAPI.exchangeOIDC(
baseURL: appState.serverURL,
provider: provider.key,
clientID: provider.mobileClientID,
idToken: tokens.idToken,
accessToken: tokens.accessToken,
nonce: tokens.nonce
)
// Keep the provider's refresh token so the session can be renewed
// later without sending the user through the browser again.
OIDCAuthenticator.store(state: tokens.serialisedState, providerKey: provider.key)
appState.saveLogin(token: result.token, user: result.username, admin: result.isAdmin)
} catch let e as OIDCError {
// Cancelling the browser sheet is a normal action, not an error.
if e.slug == "oidc_cancelled" { return }
let key = "sso.err." + e.slug
let msg = L10n.t(key, appLang)
error = msg == key ? L10n.t("sso.err.generic", appLang) : msg
} catch {
self.error = error.localizedDescription
}
}
private func login() async {
isLoading = true
error = ""
defer { isLoading = false }
do {
let code = needsTOTP ? (totpCode.isEmpty ? nil : totpCode) : nil
let result = try await CalendarrAPI.login(
baseURL: appState.serverURL,
username: username,
password: password,
totpCode: code,
rememberMe: rememberMe
)
appState.saveLogin(token: result.token, user: result.username, admin: result.isAdmin)
} catch APIError.twoFactorRequired {
withAnimation { needsTOTP = true }
} catch {
self.error = error.localizedDescription
}
}
}
+18
View File
@@ -0,0 +1,18 @@
import SwiftUI
struct MainTabView: View {
@Environment(AppState.self) var appState
@State private var showMenu = false
var api: CalendarrAPI {
CalendarrAPI(baseURL: appState.serverURL, token: appState.authToken)
}
var body: some View {
CalendarHostView(api: api, showMenu: $showMenu)
.ignoresSafeArea(edges: .bottom)
.sheet(isPresented: $showMenu) {
MenuSheet(api: api)
}
}
}
+121
View File
@@ -0,0 +1,121 @@
import SwiftUI
struct MenuSheet: View {
let api: CalendarrAPI
@Environment(AppState.self) var appState
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@State private var isSyncing = false
var body: some View {
NavigationStack {
List {
// User info header
Section {
HStack(spacing: 12) {
Circle()
.fill(Color.accentColor)
.frame(width: 44, height: 44)
.overlay {
Text(appState.username.prefix(1).uppercased())
.font(.title3.bold())
.foregroundStyle(.white)
}
VStack(alignment: .leading, spacing: 2) {
Text(appState.username).font(.headline)
Text(appState.serverURL
.replacingOccurrences(of: "https://", with: "")
.replacingOccurrences(of: "http://", with: ""))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
if appState.isAdmin {
Spacer()
Text(L10n.t("menu.admin", appLang))
.font(.caption2.weight(.semibold))
.padding(.horizontal, 8).padding(.vertical, 3)
.background(Color.accentColor.opacity(0.15))
.foregroundStyle(Color.accentColor)
.clipShape(Capsule())
}
}
.padding(.vertical, 4)
}
Section(L10n.t("menu.section.settings", appLang)) {
NavigationLink {
ProfileView(api: api)
} label: {
Label(L10n.t("menu.profile", appLang), systemImage: "person.circle")
}
NavigationLink {
SettingsView(api: api)
} label: {
Label(L10n.t("menu.appearance", appLang), systemImage: "paintpalette")
}
NavigationLink {
AccountsView(api: api)
} label: {
Label(L10n.t("menu.accounts", appLang), systemImage: "tray.2")
}
NavigationLink {
GroupsView(api: api)
} label: {
Label(L10n.t("groups.title", appLang), systemImage: "person.2")
}
NavigationLink {
ServerView()
} label: {
Label(L10n.t("menu.server", appLang), systemImage: "server.rack")
}
}
Section(L10n.t("menu.sync.section", appLang)) {
Button {
Task { await syncNow() }
} label: {
HStack {
Label(L10n.t("menu.sync", appLang), systemImage: "arrow.triangle.2.circlepath")
Spacer()
if isSyncing { ProgressView() }
}
}
.disabled(isSyncing)
}
Section {
Button(role: .destructive) {
dismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
appState.logout()
}
} label: {
Label(L10n.t("menu.logout", appLang), systemImage: "rectangle.portrait.and.arrow.right")
}
}
}
.navigationTitle(L10n.t("nav.menu", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(L10n.t("nav.done", appLang)) { dismiss() }
}
}
}
}
/// Manual sync: pull appearance/behaviour settings from the server, then
/// ask the calendar host to re-fetch events (cache-busting).
private func syncNow() async {
isSyncing = true
await SettingsSync.pull(api: api)
NotificationCenter.default.post(name: .manualSyncRequested, object: nil)
isSyncing = false
dismiss()
}
}
+327
View File
@@ -0,0 +1,327 @@
import SwiftUI
struct ProfileView: View {
let api: CalendarrAPI
@State private var profile: UserProfile?
@State private var isLoading = true
@State private var newEmail = ""
@State private var currentPW = ""
@State private var newPW = ""
@State private var confirmPW = ""
@State private var toast = ""
@State private var showToast = false
@State private var show2FASetup = false
@State private var show2FADisable = false
@State private var totpQR = ""
@State private var totpSecret = ""
@State private var totpCode = ""
@State private var disablePW = ""
@State private var isSaving2FA = false
@AppStorage("appLanguage") private var appLang = "system"
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView(L10n.t("profile.loading", appLang))
} else if let profile {
Form {
kontoSection(profile: profile)
passwordSection
twoFASection(profile: profile)
adminNoteSection
}
}
}
.navigationTitle(L10n.t("profile.title", appLang))
.navigationBarTitleDisplayMode(.large)
.overlay(alignment: .bottom) {
if showToast {
Text(toast)
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(.regularMaterial)
.clipShape(Capsule())
.padding(.bottom, 20)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(.easeInOut, value: showToast)
.sheet(isPresented: $show2FASetup) {
TwoFASetupSheet(
qrURL: totpQR,
secret: totpSecret,
code: $totpCode,
isSaving: isSaving2FA
) {
Task { await enable2FA() }
}
}
.sheet(isPresented: $show2FADisable) {
TwoFADisableSheet(password: $disablePW) {
Task { await disable2FA() }
}
}
}
.task { await load() }
}
func kontoSection(profile: UserProfile) -> some View {
Section(L10n.t("profile.account", appLang)) {
HStack {
Text(L10n.t("profile.username", appLang))
Spacer()
Text(profile.username)
.foregroundStyle(.secondary)
}
HStack {
Text(L10n.t("profile.role", appLang))
Spacer()
Text(profile.isAdmin
? L10n.t("profile.role.admin", appLang)
: L10n.t("profile.role.user", appLang))
.foregroundStyle(.secondary)
}
HStack {
Text(L10n.t("profile.email", appLang))
Spacer()
TextField(L10n.t("profile.no_email", appLang), text: $newEmail)
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
}
Button(L10n.t("profile.save_email", appLang)) {
Task { await saveEmail() }
}
.foregroundStyle(Color.accentColor)
}
}
var passwordSection: some View {
Section(L10n.t("profile.change_password", appLang)) {
SecureField(L10n.t("profile.current_password", appLang), text: $currentPW)
SecureField(L10n.t("profile.new_password", appLang), text: $newPW)
SecureField(L10n.t("profile.new_password_repeat", appLang), text: $confirmPW)
Button(L10n.t("profile.change_password", appLang)) {
Task { await changePassword() }
}
.foregroundStyle(Color.accentColor)
.disabled(currentPW.isEmpty || newPW.isEmpty || confirmPW.isEmpty)
}
}
func twoFASection(profile: UserProfile) -> some View {
Section(L10n.t("profile.twofa", appLang)) {
if profile.totpEnabled {
HStack {
Image(systemName: "checkmark.shield.fill")
.foregroundStyle(.green)
Text(L10n.t("profile.twofa.active", appLang))
}
Button(L10n.t("profile.twofa.disable", appLang)) {
show2FADisable = true
}
.foregroundStyle(.red)
} else {
HStack {
Image(systemName: "shield")
.foregroundStyle(.secondary)
Text(L10n.t("profile.twofa.inactive", appLang))
.foregroundStyle(.secondary)
}
Button(L10n.t("profile.twofa.enable", appLang)) {
Task { await setup2FA() }
}
.foregroundStyle(Color.accentColor)
}
}
}
var adminNoteSection: some View {
Section {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "info.circle")
.foregroundStyle(.secondary)
.padding(.top, 1)
Text(L10n.t("profile.admin_note", appLang))
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
}
private func load() async {
isLoading = true
defer { isLoading = false }
if let p = try? await api.getProfile() {
profile = p
newEmail = p.email ?? ""
}
}
private func saveEmail() async {
do {
try await api.updateEmail(newEmail)
showNotice(L10n.t("profile.email_saved", appLang))
} catch {
showNotice(error.localizedDescription)
}
}
private func changePassword() async {
guard newPW == confirmPW else {
showNotice(L10n.t("profile.password_mismatch", appLang))
return
}
do {
try await api.changePassword(current: currentPW, new: newPW)
currentPW = ""; newPW = ""; confirmPW = ""
showNotice(L10n.t("profile.password_changed", appLang))
} catch {
showNotice(error.localizedDescription)
}
}
private func setup2FA() async {
do {
let result = try await api.setup2FA()
totpSecret = result.secret
totpQR = result.qrUrl
totpCode = ""
show2FASetup = true
} catch {
showNotice(error.localizedDescription)
}
}
private func enable2FA() async {
isSaving2FA = true
do {
try await api.enable2FA(code: totpCode)
show2FASetup = false
showNotice(L10n.t("profile.twofa.enabled_toast", appLang))
await load()
} catch {
showNotice(error.localizedDescription)
}
isSaving2FA = false
}
private func disable2FA() async {
do {
try await api.disable2FA(password: disablePW)
show2FADisable = false
showNotice(L10n.t("profile.twofa.disabled_toast", appLang))
await load()
} catch {
showNotice(error.localizedDescription)
}
}
private func showNotice(_ msg: String) {
toast = msg
withAnimation { showToast = true }
Task {
try? await Task.sleep(for: .seconds(2))
withAnimation { showToast = false }
}
}
}
struct TwoFASetupSheet: View {
let qrURL: String
let secret: String
@Binding var code: String
let isSaving: Bool
let onEnable: () -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
var body: some View {
NavigationStack {
Form {
Section {
Text(L10n.t("twofa.scan_hint", appLang))
.font(.body)
}
Section(L10n.t("twofa.qr_section", appLang)) {
if let url = URL(string: qrURL) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let img):
img.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 200)
.frame(maxWidth: .infinity)
default:
ProgressView()
.frame(maxWidth: .infinity)
}
}
.padding(.vertical, 8)
}
HStack {
Text(secret)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
Spacer()
Button {
UIPasteboard.general.string = secret
} label: {
Image(systemName: "doc.on.doc")
}
.foregroundStyle(Color.accentColor)
}
}
Section(L10n.t("twofa.confirmation", appLang)) {
TextField(L10n.t("twofa.code_placeholder", appLang), text: $code)
.keyboardType(.numberPad)
}
}
.navigationTitle(L10n.t("twofa.setup_title", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button(L10n.t("twofa.activate", appLang)) { onEnable() }
.bold()
.disabled(code.count < 6 || isSaving)
}
}
}
}
}
struct TwoFADisableSheet: View {
@Binding var password: String
let onDisable: () -> Void
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
var body: some View {
NavigationStack {
Form {
Section(L10n.t("twofa.password_section", appLang)) {
SecureField(L10n.t("twofa.password_placeholder", appLang), text: $password)
}
}
.navigationTitle(L10n.t("twofa.disable_title", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
ToolbarItem(placement: .primaryAction) {
Button(L10n.t("twofa.disable", appLang)) { onDisable() }
.bold()
.foregroundStyle(.red)
.disabled(password.isEmpty)
}
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
import SwiftUI
struct RootView: View {
@Environment(AppState.self) var appState
var body: some View {
if !appState.isConfigured {
ServerSetupView()
} else if !appState.isLoggedIn {
LoginView()
} else {
MainTabView()
// Renew an SSO session before it lapses, so a user signed in
// via a provider is not bounced back to the login screen.
.task { await OIDCSessionRefresher.refreshIfNeeded(appState) }
}
}
}
@@ -0,0 +1,95 @@
import SwiftUI
struct ServerSetupView: View {
@Environment(AppState.self) var appState
@AppStorage("appLanguage") private var appLang = "system"
@State private var urlInput = ""
@State private var error = ""
@State private var isChecking = false
var body: some View {
NavigationStack {
VStack(spacing: 0) {
Spacer()
VStack(spacing: 24) {
VStack(spacing: 8) {
Image(systemName: "calendar")
.font(.system(size: 56, weight: .light))
.foregroundStyle(Color.accentColor)
Text("Calendarr")
.font(.largeTitle.bold())
Text(L10n.t("server.connect_title", appLang))
.font(.subheadline)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 8) {
Text(L10n.t("server.url", appLang))
.font(.footnote.weight(.medium))
.foregroundStyle(.secondary)
TextField("https://calendarr.example.com", text: $urlInput)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
.padding(12)
.background(.quaternary)
.clipShape(RoundedRectangle(cornerRadius: 10))
if !error.isEmpty {
Text(error)
.font(.caption)
.foregroundStyle(.red)
}
}
Button {
Task { await connect() }
} label: {
HStack {
if isChecking {
ProgressView()
.tint(.white)
} else {
Text(L10n.t("server.connect", appLang))
.fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.padding(14)
.background(Color.accentColor)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(urlInput.isEmpty || isChecking)
}
.padding(.horizontal, 32)
Spacer()
Text("© 2026 Scarriffleservices")
.font(.caption2)
.foregroundStyle(.tertiary)
.padding(.bottom, 24)
}
.navigationBarHidden(true)
}
}
private func connect() async {
isChecking = true
error = ""
defer { isChecking = false }
var url = urlInput.trimmingCharacters(in: .whitespacesAndNewlines)
if !url.hasPrefix("http") { url = "https://" + url }
if url.hasSuffix("/") { url = String(url.dropLast()) }
do {
_ = try await CalendarrAPI.checkSetupRequired(baseURL: url)
appState.saveServer(url: url)
} catch {
self.error = L10n.t("server.unreachable", appLang)
}
}
}
+170
View File
@@ -0,0 +1,170 @@
import SwiftUI
struct ServerView: View {
@Environment(AppState.self) var appState
@State private var showLogoutConfirm = false
@State private var showChangeServer = false
@State private var showImpressum = false
@AppStorage("appLanguage") private var appLang = "system"
var serverHost: String {
appState.serverURL
.replacingOccurrences(of: "https://", with: "")
.replacingOccurrences(of: "http://", with: "")
}
var body: some View {
NavigationStack {
Form {
Section(L10n.t("server.connected", appLang)) {
HStack {
Image(systemName: "server.rack")
.foregroundStyle(Color.accentColor)
.frame(width: 28)
VStack(alignment: .leading, spacing: 2) {
Text(serverHost)
.font(.body)
Text(appState.serverURL)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
HStack {
Image(systemName: "person.fill")
.foregroundStyle(.secondary)
.frame(width: 28)
Text(appState.username)
if appState.isAdmin {
Spacer()
Text(L10n.t("menu.admin", appLang))
.font(.caption.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color.accentColor.opacity(0.15))
.foregroundStyle(Color.accentColor)
.clipShape(Capsule())
}
}
}
Section {
Button {
showChangeServer = true
} label: {
HStack {
Image(systemName: "arrow.triangle.swap")
.frame(width: 28)
Text(L10n.t("server.switch", appLang))
}
}
.foregroundStyle(.primary)
Button(role: .destructive) {
showLogoutConfirm = true
} label: {
HStack {
Image(systemName: "rectangle.portrait.and.arrow.right")
.frame(width: 28)
Text(L10n.t("menu.logout", appLang))
}
}
}
Section(L10n.t("server.info", appLang)) {
Button {
showImpressum = true
} label: {
HStack {
Image(systemName: "info.circle")
.frame(width: 28)
Text(L10n.t("server.imprint", appLang))
}
}
.foregroundStyle(.secondary)
HStack {
Text(L10n.t("server.version", appLang))
Spacer()
Text("1.0")
.foregroundStyle(.secondary)
}
}
}
.navigationTitle(L10n.t("server.title", appLang))
.navigationBarTitleDisplayMode(.large)
.confirmationDialog(L10n.t("server.logout_title", appLang), isPresented: $showLogoutConfirm, titleVisibility: .visible) {
Button(L10n.t("menu.logout", appLang), role: .destructive) {
appState.logout()
}
Button(L10n.t("common.cancel", appLang), role: .cancel) {}
} message: {
Text(String(format: L10n.t("server.logout_msg", appLang), serverHost))
}
.confirmationDialog(L10n.t("server.switch", appLang), isPresented: $showChangeServer, titleVisibility: .visible) {
Button(L10n.t("server.switch", appLang), role: .destructive) {
appState.resetServer()
}
Button(L10n.t("common.cancel", appLang), role: .cancel) {}
} message: {
Text(String(format: L10n.t("server.switch_msg", appLang), serverHost))
}
.sheet(isPresented: $showImpressum) {
ImpressumView()
}
}
}
}
struct ImpressumView: View {
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Group {
Text(L10n.t("imprint.company", appLang))
.font(.title2.bold())
Text(L10n.t("imprint.role", appLang))
.foregroundStyle(.secondary)
}
Divider()
Text(L10n.t("imprint.copyright", appLang))
VStack(alignment: .leading, spacing: 6) {
Text(L10n.t("imprint.storage.title", appLang)).font(.headline)
Text(L10n.t("imprint.storage.body", appLang))
}
VStack(alignment: .leading, spacing: 6) {
Text(L10n.t("imprint.disclaimer.title", appLang)).font(.headline)
Text(L10n.t("imprint.disclaimer.body", appLang))
}
VStack(alignment: .leading, spacing: 6) {
Text(L10n.t("imprint.contact.title", appLang)).font(.headline)
Link("scarriffleservices@gmail.com", destination: URL(string: "mailto:scarriffleservices@gmail.com")!)
}
Divider()
Text("Calendarr v11 · iOS App v1.0")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(24)
}
.navigationTitle(L10n.t("server.imprint", appLang))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(L10n.t("common.close", appLang)) { dismiss() }
}
}
}
}
}
+461
View File
@@ -0,0 +1,461 @@
import SwiftUI
struct SettingsView: View {
let api: CalendarrAPI
@AppStorage("liquidGlass") private var liquidGlass = false
@AppStorage("hideMenuButton") private var hideMenuButton = false
@AppStorage("cacheMonths") private var cacheMonths = 3
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("monthDividerColor") private var dividerHex = "#7090C0"
@AppStorage("monthLabelColor") private var labelHex = "#7090C0"
@AppStorage("todayColor") private var todayHex = "#4285F4"
@AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("backgroundColor") private var bgHex = "#000000"
@AppStorage("lineColor") private var lineHex = "#3A3A52"
// Device-local top-bar/surface colour. Empty = translucent .bar material.
@AppStorage("surfaceColor") private var surfaceHex = ""
@AppStorage("primaryColor") private var primaryHex = "#4285F4"
@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("lineContrast") private var lineContrast = 3
@AppStorage("hourHeight") private var hourHeight = 60
@AppStorage("defaultView") private var defaultView = "month"
@AppStorage("weekStartDay") private var weekStartDay = "monday"
@AppStorage("dimPastEvents") private var dimPastEvents = false
@AppStorage("monthViewPaged") private var monthViewPaged = false
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
@AppStorage("defaultEventDurationMinutes") private var defaultEventDurationMinutes = 60
// 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 loginName = ""
@State private var email = ""
@State private var privateVisibility = "busy"
@State private var groupVisibleId = 0 // 0 = none
@State private var ownLocalCals: [LocalCalendar] = []
@State private var directoryHidden = false
@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 {
NavigationStack {
Form {
globalSyncSection
profilSection
privatsphaereSection
geteilterKalenderSection
termineSection
ansichtSection
farbenSection
cacheSection
geraetSection
}
.navigationTitle(L10n.t("settings.title", appLang))
.navigationBarTitleDisplayMode(.large)
}
// Reflect the latest server values (and flag map) when opening the screen.
.task { await SettingsSync.pull(api: api); syncFlags = SettingsSync.flags() }
.task { await loadProfile() }
// Value edits update widgets live; a synced value is also pushed (debounced).
// `push` decides what actually gets sent based on each key's flag.
.onChange(of: primaryHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
.onChange(of: accentHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
.onChange(of: todayHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
.onChange(of: textHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
.onChange(of: bgHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
.onChange(of: lineHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
.onChange(of: dividerHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
.onChange(of: labelHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
.onChange(of: hourHeight) { _, _ in SettingsSync.push(api: api) }
.onChange(of: defaultView) { _, _ in SettingsSync.push(api: api) }
.onChange(of: weekStartDay) { _, _ in SettingsSync.push(api: api) }
.onChange(of: dimPastEvents) { _, _ in SettingsSync.push(api: api) }
.onChange(of: 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() }
}
// 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 {
Section(L10n.t("settings.nav.profile", appLang)) {
HStack {
Text(L10n.t("profile.display_name", appLang))
Spacer()
TextField(L10n.t("profile.display_name", appLang), text: $displayName)
.multilineTextAlignment(.trailing)
}
HStack {
Text(L10n.t("profile.login_name", appLang))
Spacer()
Text(loginName).foregroundStyle(.secondary)
}
HStack {
Text(L10n.t("settings.email", appLang))
Spacer()
TextField(L10n.t("settings.email", appLang), text: $email)
.multilineTextAlignment(.trailing)
.keyboardType(.emailAddress)
.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() } }
if !profileMsg.isEmpty {
Text(profileMsg).font(.caption).foregroundStyle(.secondary)
}
}
}
// MARK: – Privatsphäre (account — not synced)
var privatsphaereSection: some View {
Section {
Picker(L10n.t("settings.private_visibility", appLang), selection: $privateVisibility) {
Text(L10n.t("settings.private.busy", appLang)).tag("busy")
Text(L10n.t("settings.private.hidden", appLang)).tag("hidden")
}
.onChange(of: privateVisibility) { _, v in
Task { try? await api.updatePrivateVisibility(v) }
}
} header: {
Text(L10n.t("settings.privacy", appLang))
} footer: {
Text(L10n.t("settings.private_visibility.desc", appLang)).font(.caption)
}
}
// MARK: – Geteilter Kalender (account — not synced)
var geteilterKalenderSection: some View {
Section {
Picker(L10n.t("settings.group_visible", appLang), selection: $groupVisibleId) {
Text(L10n.t("group.visible.none", appLang)).tag(0)
ForEach(ownLocalCals) { cal in
Text(cal.name).tag(cal.id)
}
}
.onChange(of: groupVisibleId) { _, id in
Task { try? await api.updateGroupVisibleCalendar(id == 0 ? nil : id) }
}
} header: {
Text(L10n.t("settings.calendars", appLang))
} footer: {
Text(L10n.t("settings.group_visible.desc", appLang)).font(.caption)
}
}
// 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 {
if let p = try? await api.getProfile() {
displayName = p.displayName ?? p.username
loginName = p.username
email = p.email ?? ""
directoryHidden = p.directoryHidden
}
if let s = try? await api.getSettings() {
privateVisibility = s.privateEventVisibility
groupVisibleId = s.groupVisibleCalendarId ?? 0
}
if let cals = try? await api.getLocalCalendars() {
// 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 }
}
}
private func saveProfile() async {
do {
_ = try await api.updateProfile(displayName: displayName.isEmpty ? nil : displayName,
username: nil,
email: email.isEmpty ? "" : email,
directoryHidden: directoryHidden)
UserDefaults.standard.set(displayName, forKey: "displayName")
profileMsg = L10n.t("settings.saved", appLang)
} catch {
profileMsg = error.localizedDescription
}
}
}
@@ -0,0 +1,18 @@
//
// Calendarr_iOSTests.swift
// Calendarr iOSTests
//
// Created by Guido Schmit on 17.05.2026.
//
import Testing
struct Calendarr_iOSTests {
@Test func example() async throws {
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
// Swift Testing Documentation
// https://developer.apple.com/documentation/testing
}
}
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.scarriffleservices.calendarr.ios.refresh</string>
</array>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
</dict>
</plist>
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon-1024.png",
"idiom" : "universal",
"platform" : "watchos",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.scarriffleservices.calendarr</string>
</array>
</dict>
</plist>
@@ -0,0 +1,19 @@
import SwiftUI
@main
struct CalendarrWatchApp: App {
@State private var model = WatchSnapshotModel()
init() {
// The session has to be live before the first payload arrives, and a
// complication push can wake this app with no UI on screen at all.
WatchSnapshotReceiver.shared.activate()
}
var body: some Scene {
WindowGroup {
WatchAgendaView()
.environment(model)
}
}
}
@@ -0,0 +1,114 @@
import SwiftUI
import CalendarrCore
/// The whole app: a scrolling agenda grouped by day, plus a way to reach the
/// calendar filter. Read-only by design — the snapshot carries neither
/// recurrence rules nor permissions, so it is deliberately not enough to edit.
///
/// Deliberately does **not** apply `snapshot.theme` to the background. The
/// always-on display and OLED make black the right choice here; the theme only
/// contributes the per-event accent colours.
struct WatchAgendaView: View {
@Environment(WatchSnapshotModel.self) private var model
@State private var showingFilter = false
private var calendar: Calendar {
var calendar = Calendar(identifier: .gregorian)
calendar.locale = WatchL10n.locale(model.language)
return calendar
}
var body: some View {
NavigationStack {
content
.navigationTitle(WatchL10n.t("watch.title", model.language))
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button { showingFilter = true } label: {
Image(systemName: "line.3.horizontal.decrease.circle")
}
.accessibilityLabel(WatchL10n.t("watch.calendars", model.language))
}
}
.sheet(isPresented: $showingFilter) { WatchCalendarFilterView() }
}
.onAppear { WatchSnapshotReceiver.shared.requestRefresh() }
}
@ViewBuilder
private var content: some View {
if let snapshot = model.visible, !groups(in: snapshot).isEmpty {
List {
ForEach(groups(in: snapshot), id: \.day) { group in
Section(WatchEventFormat.dayHeader(group.day, language: model.language,
calendar: calendar)) {
ForEach(group.events) { event in
NavigationLink {
WatchEventDetailView(event: event,
calendarName: model.calendarName(for: event.calendarKey),
language: model.language)
} label: {
WatchEventRow(event: event, language: model.language)
}
}
}
}
Section {
VStack(alignment: .leading, spacing: 2) {
Text("\(WatchL10n.t("watch.as_of", model.language)) \(WatchEventFormat.time(snapshot.writtenAt, language: model.language))")
// Beyond the coverage window the snapshot carries no
// information, which is not the same as "nothing
// scheduled". Saying so is what keeps the empty tail of
// the list from reading as a free afternoon.
Text("\(WatchL10n.t("watch.no_information", model.language)) \(coverageEndLine(snapshot))")
if let error = WatchSnapshotReceiver.shared.lastError {
Text(error).foregroundStyle(.orange)
}
}
.font(.system(size: 10))
.foregroundStyle(.tertiary)
}
}
.listStyle(.carousel)
} else {
WatchStatusView(result: model.result, language: model.language)
}
}
private struct DayGroup {
let day: Date
let events: [SnapshotEvent]
}
/// The last day the snapshot can speak for, formatted for the footer.
private func coverageEndLine(_ snapshot: CalendarrSnapshot) -> String {
let formatter = DateFormatter()
formatter.locale = WatchL10n.locale(model.language)
formatter.setLocalizedDateFormatFromTemplate("d MMM")
let lastCovered = calendar.date(byAdding: .day, value: -1, to: snapshot.coverageEnd)
return formatter.string(from: lastCovered ?? snapshot.coverageEnd)
}
/// Group the covered window by day. Days the snapshot cannot speak for are
/// simply absent — never rendered as "nothing scheduled", which would look
/// correct and be false.
private func groups(in snapshot: CalendarrSnapshot) -> [DayGroup] {
let calendar = self.calendar
let from = max(snapshot.coverageStart, calendar.startOfDay(for: Date()))
var buckets: [Date: [SnapshotEvent]] = [:]
for event in snapshot.events where event.end > from {
var day = calendar.startOfDay(for: max(event.start, from))
// A multi-day event appears on each day it covers.
while day < event.end && day < snapshot.coverageEnd {
buckets[day, default: []].append(event)
guard let next = calendar.date(byAdding: .day, value: 1, to: day) else { break }
day = next
}
}
return buckets
.map { DayGroup(day: $0.key, events: $0.value.sorted { $0.start < $1.start }) }
.sorted { $0.day < $1.day }
}
}
@@ -0,0 +1,49 @@
import SwiftUI
import CalendarrCore
/// Narrows what this watch shows, on top of what the phone already filtered.
/// One toggle here affects the agenda and every complication at once.
struct WatchCalendarFilterView: View {
@Environment(WatchSnapshotModel.self) private var model
var body: some View {
NavigationStack {
List {
if model.calendars.isEmpty {
Text(WatchL10n.t("watch.waiting", model.language))
.font(.footnote)
.foregroundStyle(.secondary)
} else {
ForEach(model.calendars) { calendar in
row(calendar)
}
}
}
.navigationTitle(WatchL10n.t("watch.calendars", model.language))
}
}
private func row(_ calendar: SnapshotCalendar) -> some View {
let shown = !model.isHidden(calendar.id)
return Button {
model.setHidden(calendar.id, shown)
} label: {
HStack(spacing: 8) {
// A checkbox, not a Toggle: a switch reads as a state indicator
// you wait on rather than a control you tap.
Image(systemName: shown ? "checkmark.square.fill" : "square")
.font(.system(size: 16))
.foregroundStyle(shown ? Color.accentColor : Color.secondary)
Circle()
.fill(Color(watchHex: calendar.colorHex))
.frame(width: 7, height: 7)
Text(calendar.name)
.font(.system(size: 14))
.lineLimit(1)
Spacer(minLength: 0)
}
}
.buttonStyle(.plain)
.accessibilityAddTraits(shown ? [.isSelected] : [])
}
}
@@ -0,0 +1,60 @@
import SwiftUI
import CalendarrCore
/// Everything the snapshot carries about one event, which is deliberately not
/// everything the server knows: notes, recurrence and permissions are dropped
/// on the way out, so there is nothing here to edit.
struct WatchEventDetailView: View {
let event: SnapshotEvent
/// Nil when the calendar list has not arrived yet, which is possible right
/// after a first install.
let calendarName: String?
let language: String
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 6) {
Circle()
.fill(Color(watchHex: event.colorHex))
.frame(width: 8, height: 8)
.padding(.top, 5)
Text(event.title)
.font(.headline)
.fixedSize(horizontal: false, vertical: true)
}
Label(WatchEventFormat.timeRange(event, language: language),
systemImage: "clock")
.font(.footnote)
Label(dateLine, systemImage: "calendar")
.font(.footnote)
.foregroundStyle(.secondary)
if !event.location.isEmpty {
Label(event.location, systemImage: "mappin.and.ellipse")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if let calendarName {
Label(calendarName, systemImage: "list.bullet.rectangle")
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 4)
}
}
private var dateLine: String {
let formatter = DateFormatter()
formatter.locale = WatchL10n.locale(language)
formatter.setLocalizedDateFormatFromTemplate("EEEE d MMMM")
return formatter.string(from: event.start)
}
}
@@ -0,0 +1,89 @@
import SwiftUI
import CalendarrCore
/// One event: a colour bar for the calendar, the time range, the title.
struct WatchEventRow: View {
let event: SnapshotEvent
let language: String
var body: some View {
HStack(alignment: .top, spacing: 7) {
RoundedRectangle(cornerRadius: 2)
.fill(Color(watchHex: event.colorHex))
.frame(width: 3)
VStack(alignment: .leading, spacing: 1) {
Text(WatchEventFormat.timeRange(event, language: language))
.font(.system(size: 11))
.foregroundStyle(.secondary)
Text(event.title)
.font(.system(size: 14))
.lineLimit(2)
}
Spacer(minLength: 0)
}
.padding(.vertical, 2)
.fixedSize(horizontal: false, vertical: true)
}
}
extension Color {
/// Local copy of the hex initialiser: `CalendarrCore` is Foundation-only by
/// design, so colour parsing cannot live there.
init(watchHex hex: String) {
let cleaned = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var value: UInt64 = 0
Scanner(string: cleaned).scanHexInt64(&value)
guard cleaned.count == 6 else { self = .gray; return }
self.init(red: Double((value >> 16) & 0xFF) / 255,
green: Double((value >> 8) & 0xFF) / 255,
blue: Double(value & 0xFF) / 255)
}
}
enum WatchEventFormat {
static func timeRange(_ event: SnapshotEvent, language: String,
calendar: Calendar = Calendar(identifier: .gregorian)) -> String {
if event.isAllDay { return WatchL10n.t("watch.allday", language) }
let time = DateFormatter()
time.locale = WatchL10n.locale(language)
time.dateFormat = "HH:mm"
let start = time.string(from: event.start)
guard event.end > event.start else { return start }
// Without the weekday, an event running from Wednesday to Monday reads
// as a one-hour slot: "00:00 – 01:00" is true and useless.
if calendar.startOfDay(for: event.end) > calendar.startOfDay(for: event.start) {
let day = DateFormatter()
day.locale = WatchL10n.locale(language)
day.setLocalizedDateFormatFromTemplate("EEE")
return "\(day.string(from: event.start)) \(start) – "
+ "\(day.string(from: event.end)) \(time.string(from: event.end))"
}
return "\(start) – \(time.string(from: event.end))"
}
static func time(_ date: Date, language: String) -> String {
let formatter = DateFormatter()
formatter.locale = WatchL10n.locale(language)
formatter.dateFormat = "HH:mm"
return formatter.string(from: date)
}
/// "Heute", "Morgen", else "Do 11. Sep".
static func dayHeader(_ day: Date, language: String,
calendar: Calendar, now: Date = Date()) -> String {
if calendar.isDate(day, inSameDayAs: now) {
return WatchL10n.t("watch.today", language)
}
if let tomorrow = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now)),
calendar.isDate(day, inSameDayAs: tomorrow) {
return WatchL10n.t("watch.tomorrow", language)
}
let formatter = DateFormatter()
formatter.locale = WatchL10n.locale(language)
formatter.setLocalizedDateFormatFromTemplate("EEE d MMM")
return formatter.string(from: day)
}
}
@@ -0,0 +1,44 @@
import SwiftUI
import CalendarrCore
/// Every reason there is nothing to show, named. "Never received", "signed out"
/// and "genuinely empty" call for different sentences, and showing the wrong one
/// sends the user looking in the wrong place.
struct WatchStatusView: View {
let result: SnapshotReadResult
let language: String
var body: some View {
VStack(spacing: 8) {
Image(systemName: symbol)
.font(.system(size: 22))
.foregroundStyle(.secondary)
Text(message)
.font(.footnote)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 6)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var symbol: String {
switch result {
case .ok: return "calendar"
case .loggedOut: return "person.crop.circle.badge.xmark"
case .neverWritten: return "iphone.and.arrow.forward"
case .incompatible: return "arrow.down.circle"
case .unreadable: return "exclamationmark.triangle"
}
}
private var message: String {
switch result {
case .ok: return WatchL10n.t("watch.no_events", language)
case .loggedOut: return WatchL10n.t("watch.sign_in", language)
case .neverWritten: return WatchL10n.t("watch.waiting", language)
case .incompatible: return WatchL10n.t("watch.update", language)
case .unreadable: return WatchL10n.t("watch.unreadable", language)
}
}
}
@@ -0,0 +1,52 @@
import Foundation
import WidgetKit
import CalendarrCore
/// What the watch views read. Holds the last received snapshot, the calendar
/// list, and this watch's filter.
@MainActor
@Observable
final class WatchSnapshotModel {
private(set) var result: SnapshotReadResult = .neverWritten
private(set) var calendars: [SnapshotCalendar] = []
private(set) var hiddenKeys: Set<String> = WatchFilterStore.load()
private let store = SnapshotStore()
private var changeToken: SnapshotChangeNotifier.ObservationToken?
init() {
reload()
// The receiver writes through SnapshotStore, which broadcasts. Listening
// here means the UI does not have to be notified separately.
changeToken = SnapshotChangeNotifier.observe { [weak self] in
Task { @MainActor in self?.reload() }
}
}
func reload() {
result = store.read()
calendars = store.readCalendars()
}
/// The snapshot with this watch's filter applied, or nil when there is
/// nothing usable to show.
var visible: CalendarrSnapshot? {
result.snapshot?.filtered(excludingCalendarKeys: hiddenKeys)
}
var language: String { result.snapshot?.language ?? "system" }
func isHidden(_ key: String) -> Bool { hiddenKeys.contains(key) }
/// The display name for an event's calendar. Events carry only a
/// `calendarKey`; the names travel separately, in the calendar list.
func calendarName(for key: String) -> String? {
calendars.first { $0.id == key }?.name
}
func setHidden(_ key: String, _ hidden: Bool) {
if hidden { hiddenKeys.insert(key) } else { hiddenKeys.remove(key) }
WatchFilterStore.save(hiddenKeys)
WidgetCenter.shared.reloadAllTimelines()
}
}
@@ -0,0 +1,81 @@
import Foundation
import WatchConnectivity
import WidgetKit
@_spi(Writer) import CalendarrCore
/// Takes what the phone sends and writes it into this watch's own App Group
/// container, using the same `SnapshotStore` the phone writes with. One wire
/// format, one reader, no drift.
///
/// Only the app runs a `WCSession`; a widget extension cannot. The complications
/// always read what this wrote.
@MainActor
final class WatchSnapshotReceiver: NSObject {
static let shared = WatchSnapshotReceiver()
private let store = SnapshotStore()
private let sessions = SharedSessionStore()
/// Shown in the agenda footer, because a silent failure here looks exactly
/// like an empty calendar.
private(set) var lastError: String?
func activate() {
guard WCSession.isSupported() else { return }
WCSession.default.delegate = self
WCSession.default.activate()
}
/// Ask the phone for the current state. Covers "the app is open in front of
/// me and looks stale"; the other two paths are push-driven.
func requestRefresh() {
let session = WCSession.default
guard session.activationState == .activated, session.isReachable else { return }
session.sendMessage(["request": "snapshot"],
replyHandler: { reply in
Task { @MainActor in self.apply(reply) }
},
errorHandler: { error in
Task { @MainActor in self.lastError = error.localizedDescription }
})
}
func apply(_ dict: [String: Any]) {
guard !dict.isEmpty else { return }
do {
let payload = try WatchTransportPayload.decode(dict)
// This write posts SnapshotChangeNotifier, which is what the read
// model listens for — so the UI updates without being told twice.
try store.write(payload.snapshot)
try store.writeCalendars(payload.calendars)
if let session = payload.session { try sessions.write(session) }
WidgetCenter.shared.reloadAllTimelines()
lastError = nil
} catch {
lastError = error.localizedDescription
}
}
}
extension WatchSnapshotReceiver: WCSessionDelegate {
nonisolated func session(_ session: WCSession,
activationDidCompleteWith state: WCSessionActivationState,
error: Error?) {
if let error {
Task { @MainActor in self.lastError = error.localizedDescription }
}
}
/// The main path: latest state only, delivered when this app launches or wakes.
nonisolated func session(_ session: WCSession,
didReceiveApplicationContext applicationContext: [String: Any]) {
Task { @MainActor in self.apply(applicationContext) }
}
/// The complication push, which is the only path that wakes this app while
/// it is not running.
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
Task { @MainActor in self.apply(userInfo) }
}
}
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "watchos",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.scarriffleservices.calendarr</string>
</array>
</dict>
</plist>
@@ -0,0 +1,161 @@
import WidgetKit
import SwiftUI
@main
struct CalendarrWatchComplicationBundle: WidgetBundle {
var body: some Widget {
WatchNextEventWidget()
WatchCornerCountdownWidget()
WatchCornerTitleWidget()
WatchCornerTimeWidget()
WatchCountdownWidget()
WatchDateWidget()
WatchTodayCountWidget()
WatchNextTwoWidget()
WatchDatePlusEventWidget()
}
}
private extension View {
/// Accessory families paint their own background; ours must stay out of
/// the way so the watch face's tint comes through.
func complicationChrome(_ language: String) -> some View {
containerBackground(for: .widget) { Color.clear }
.environment(\.locale, WatchL10n.locale(language))
}
}
struct WatchNextEventWidget: Widget {
let kind = "WatchNextEvent"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
NextEventComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Nächster Termin")
.description("Zeit, Titel und Ort des nächsten Termins.")
.supportedFamilies([.accessoryRectangular, .accessoryInline])
}
}
struct WatchCornerCountdownWidget: Widget {
let kind = "WatchCornerCountdown"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
CornerCountdownComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Restzeit (Ecke)")
.description("Restzeit bis zum nächsten Termin, Name am Rand.")
.supportedFamilies([.accessoryCorner])
}
}
struct WatchCornerTitleWidget: Widget {
let kind = "WatchCornerTitle"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
CornerTitleComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Terminname (Ecke)")
.description("Name des nächsten Termins, Uhrzeit klein am Rand.")
.supportedFamilies([.accessoryCorner])
}
}
struct WatchCornerTimeWidget: Widget {
let kind = "WatchCornerTime"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
CornerTimeComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Uhrzeit (Ecke)")
.description("Uhrzeit des nächsten Termins, Name am Rand.")
.supportedFamilies([.accessoryCorner])
}
}
struct WatchCountdownWidget: Widget {
let kind = "WatchCountdown"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
CountdownComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Countdown")
.description("Ring, der bis zum nächsten Termin leerläuft.")
.supportedFamilies([.accessoryCircular, .accessoryInline])
}
}
struct WatchDateWidget: Widget {
let kind = "WatchDate"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
DateComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Datum")
.description("Tag und Monat.")
.supportedFamilies([.accessoryCircular, .accessoryInline])
}
}
struct WatchTodayCountWidget: Widget {
let kind = "WatchTodayCount"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
TodayCountComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Termine heute")
.description("Anzahl der heutigen Termine.")
.supportedFamilies([.accessoryCircular, .accessoryInline])
}
}
struct WatchNextTwoWidget: Widget {
let kind = "WatchNextTwo"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
NextTwoComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Die nächsten zwei")
.description("Die beiden nächsten Termine.")
.supportedFamilies([.accessoryRectangular])
}
}
struct WatchDatePlusEventWidget: Widget {
let kind = "WatchDatePlusEvent"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind,
intent: WatchCalendarSelectionIntent.self,
provider: WatchComplicationProvider()) { entry in
DatePlusEventComplicationView(entry: entry).complicationChrome(entry.language)
}
.configurationDisplayName("Datum + Termin")
.description("Datum und nächster Termin in einem Slot.")
.supportedFamilies([.accessoryRectangular])
}
}
@@ -0,0 +1,90 @@
import SwiftUI
import WidgetKit
import CalendarrCore
// A corner holds **one** value plus the curved bezel label. A second inner line
// runs into the curved text — do not add one. `.widgetLabel` is what makes
// WidgetKit lay the text along the edge; there is no arc to draw by hand.
/// Value: live countdown. Label: time and title.
struct CornerCountdownComplicationView: View {
let entry: WatchComplicationEntry
var body: some View {
let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date)
Group {
if let next {
Text(WatchComplicationSupport.compactCountdown(
to: WatchComplicationSupport.countdownTarget(next, at: entry.date),
at: entry.date,
language: entry.language))
.lineLimit(1)
.minimumScaleFactor(0.6)
.widgetAccentable()
} else {
Image(systemName: "calendar")
}
}
.widgetLabel {
Text(WatchComplicationSupport.eventLabel(next, at: entry.date, language: entry.language))
}
}
}
/// Value: the appointment's clock time. Label: the title.
struct CornerTimeComplicationView: View {
let entry: WatchComplicationEntry
var body: some View {
let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date)
Group {
if let next {
Text(next.isAllDay
? WatchL10n.t("watch.allday", entry.language)
: WatchComplicationSupport.time(next.start, language: entry.language))
.lineLimit(1)
.minimumScaleFactor(0.6)
.widgetAccentable()
} else {
Image(systemName: "calendar")
}
}
.widgetLabel {
Text(next?.title ?? WatchL10n.t("watch.no_events", entry.language))
}
}
}
/// Value: the appointment's name. Label: the time, small and curved.
///
/// This is the inverse of how the slot is shaped — the curved bezel is where a
/// corner has room for long text, and the inner area holds three or four
/// characters. The title therefore shrinks and truncates here. Built this way
/// on request; `CornerTimeComplicationView` is the same information with the
/// roles the other way round, so both can be compared on the face.
struct CornerTitleComplicationView: View {
let entry: WatchComplicationEntry
var body: some View {
let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date)
Group {
if let next {
Text(next.title)
.font(.system(size: 13, weight: .medium))
.lineLimit(2)
.minimumScaleFactor(0.5)
.multilineTextAlignment(.center)
.widgetAccentable()
} else {
Image(systemName: "calendar")
}
}
.widgetLabel {
Text(next.map {
$0.isAllDay
? WatchL10n.t("watch.allday", entry.language)
: WatchComplicationSupport.time($0.start, language: entry.language)
} ?? WatchL10n.t("watch.no_events", entry.language))
}
}
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,113 @@
import SwiftUI
import WidgetKit
import CalendarrCore
/// Time, title, location. The variant that answers the actual question.
struct NextEventComplicationView: View {
let entry: WatchComplicationEntry
@Environment(\.widgetFamily) private var family
var body: some View {
let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date)
switch family {
case .accessoryInline:
inline(next)
default:
rectangular(next)
}
}
@ViewBuilder
private func rectangular(_ next: SnapshotEvent?) -> some View {
VStack(alignment: .leading, spacing: 1) {
if let next {
Text(next.isAllDay
? WatchL10n.t("watch.allday", entry.language)
: WatchComplicationSupport.time(next.start, language: entry.language))
.font(.system(size: 12, weight: .semibold))
.widgetAccentable()
Text(next.title)
.font(.system(size: 15))
.lineLimit(1)
if !next.location.isEmpty {
Text(next.location)
.font(.system(size: 11))
.lineLimit(1)
}
} else {
Image(systemName: "calendar").font(.system(size: 13)).widgetAccentable()
Text(WatchL10n.t("watch.no_events", entry.language)).font(.system(size: 13))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private func inline(_ next: SnapshotEvent?) -> some View {
let text: String = {
guard let next else { return WatchL10n.t("watch.no_events", entry.language) }
if next.isAllDay { return next.title }
return "\(WatchComplicationSupport.time(next.start, language: entry.language)) \(next.title)"
}()
return Label(text, systemImage: "calendar")
}
}
/// The next two, so back-to-back appointments are visible at once.
struct NextTwoComplicationView: View {
let entry: WatchComplicationEntry
var body: some View {
let events = WatchComplicationSupport.upcoming(in: entry.snapshot, at: entry.date, limit: 2)
VStack(alignment: .leading, spacing: 3) {
if events.isEmpty {
Image(systemName: "calendar").font(.system(size: 13)).widgetAccentable()
Text(WatchL10n.t("watch.no_events", entry.language)).font(.system(size: 13))
} else {
ForEach(events) { event in
HStack(spacing: 5) {
Text(event.isAllDay
? WatchL10n.t("watch.allday", entry.language)
: WatchComplicationSupport.time(event.start, language: entry.language))
.font(.system(size: 12, weight: .semibold))
.widgetAccentable()
Text(event.title)
.font(.system(size: 13))
.lineLimit(1)
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
/// Weekday and date plus the next appointment, so one slot does both jobs.
struct DatePlusEventComplicationView: View {
let entry: WatchComplicationEntry
var body: some View {
let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date)
VStack(alignment: .leading, spacing: 2) {
Text(dateLine)
.font(.system(size: 11, weight: .semibold))
.widgetAccentable()
if let next {
Text(next.isAllDay
? next.title
: "\(WatchComplicationSupport.time(next.start, language: entry.language)) \(next.title)")
.font(.system(size: 14))
.lineLimit(1)
} else {
Text(WatchL10n.t("watch.no_events", entry.language)).font(.system(size: 14))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var dateLine: String {
let formatter = DateFormatter()
formatter.locale = WatchL10n.locale(entry.language)
formatter.setLocalizedDateFormatFromTemplate("EEE d MMMM")
return formatter.string(from: entry.date).uppercased()
}
}
@@ -0,0 +1,156 @@
import SwiftUI
import WidgetKit
import CalendarrCore
/// Attaches the curved bezel label that a face with a bezel slot — Infograph —
/// renders across the top, where the tick marks otherwise are. Faces without
/// that slot ignore it, so it costs nothing to always provide.
private extension View {
func eventBezelLabel(_ entry: WatchComplicationEntry) -> some View {
let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date)
return widgetLabel {
Text(WatchComplicationSupport.eventLabel(next, at: entry.date, language: entry.language))
}
}
}
/// A draining ring plus the remaining time.
struct CountdownComplicationView: View {
let entry: WatchComplicationEntry
@Environment(\.widgetFamily) private var family
var body: some View {
let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date)
switch family {
case .accessoryInline:
inline(next)
default:
circular(next)
}
}
@ViewBuilder
private func circular(_ next: SnapshotEvent?) -> some View {
if let next {
let target = WatchComplicationSupport.countdownTarget(next, at: entry.date)
Gauge(value: WatchComplicationSupport.gaugeFraction(to: target, at: entry.date)) {
Image(systemName: "calendar")
} currentValueLabel: {
Text(WatchComplicationSupport.compactCountdown(to: target, at: entry.date,
language: entry.language))
.font(.system(size: 13))
.lineLimit(1)
.minimumScaleFactor(0.6)
}
.gaugeStyle(.accessoryCircularCapacity)
.widgetAccentable()
.eventBezelLabel(entry)
} else {
ZStack {
AccessoryWidgetBackground()
Image(systemName: "calendar").font(.system(size: 18))
}
}
}
@ViewBuilder
private func inline(_ next: SnapshotEvent?) -> some View {
if let next {
Label {
Text(WatchComplicationSupport.countdownTarget(next, at: entry.date), style: .timer)
} icon: {
Image(systemName: "timer")
}
} else {
Label(WatchL10n.t("watch.no_events", entry.language), systemImage: "calendar")
}
}
}
/// Weekday and day number, the way a date is actually read. The ring appears
/// only while an appointment is running, and then shows how far through it we
/// are — an empty track around an idle day is noise, so there is no Gauge at
/// all in that case.
struct DateComplicationView: View {
let entry: WatchComplicationEntry
@Environment(\.widgetFamily) private var family
private var day: Int {
Calendar(identifier: .gregorian).component(.day, from: entry.date)
}
private var weekday: String {
WatchComplicationSupport.weekdayAbbreviation(entry.date, language: entry.language)
}
var body: some View {
switch family {
case .accessoryInline:
Label("\(weekday) \(day)", systemImage: "calendar")
default:
circular
}
}
@ViewBuilder
private var circular: some View {
if let running = WatchComplicationSupport.runningEvent(in: entry.snapshot, at: entry.date) {
Gauge(value: WatchComplicationSupport.progress(of: running, at: entry.date)) {
Text(weekday)
} currentValueLabel: {
dateStack
}
.gaugeStyle(.accessoryCircularCapacity)
.eventBezelLabel(entry)
} else {
ZStack {
AccessoryWidgetBackground()
dateStack
}
.eventBezelLabel(entry)
}
}
private var dateStack: some View {
VStack(spacing: -2) {
Text(weekday)
.font(.system(size: 11, weight: .semibold))
.widgetAccentable()
Text("\(day)")
.font(.system(size: 23, weight: .semibold))
.lineLimit(1)
.minimumScaleFactor(0.7)
}
}
}
/// How many appointments today. Says "busy", not "what".
struct TodayCountComplicationView: View {
let entry: WatchComplicationEntry
@Environment(\.widgetFamily) private var family
private var count: Int {
WatchComplicationSupport.todayCount(in: entry.snapshot, at: entry.date)
}
var body: some View {
switch family {
case .accessoryInline:
Label("\(count) \(WatchL10n.t("watch.events_today", entry.language))",
systemImage: "calendar")
default:
Gauge(value: WatchComplicationSupport.todayRemainingFraction(in: entry.snapshot,
at: entry.date)) {
Image(systemName: "calendar")
} currentValueLabel: {
Text("\(count)")
.font(.system(size: 20, weight: .semibold))
.lineLimit(1)
.minimumScaleFactor(0.6)
}
.gaugeStyle(.accessoryCircularCapacity)
.widgetAccentable()
.eventBezelLabel(entry)
}
}
}
@@ -0,0 +1,44 @@
import AppIntents
import WidgetKit
import CalendarrCore
/// One calendar option in a complication's configuration.
struct WatchCalendarEntity: AppEntity, Identifiable {
let id: String
let name: String
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Kalender"
static var defaultQuery = WatchCalendarEntityQuery()
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: LocalizedStringResource(stringLiteral: name))
}
}
/// Reads the calendars from this watch's App Group container, so configuration
/// needs no network call and no phone.
struct WatchCalendarEntityQuery: EntityQuery {
func entities(for identifiers: [String]) async throws -> [WatchCalendarEntity] {
let wanted = Set(identifiers)
return SnapshotStore().readCalendars()
.filter { wanted.contains($0.id) }
.map { WatchCalendarEntity(id: $0.id, name: $0.name) }
}
func suggestedEntities() async throws -> [WatchCalendarEntity] {
SnapshotStore().readCalendars().map { WatchCalendarEntity(id: $0.id, name: $0.name) }
}
}
/// Per-complication override of the calendar selection. The in-app toggles are
/// the primary path — intent configuration on a watch face is awkward to
/// operate — so an empty selection means "whatever the app is set to".
struct WatchCalendarSelectionIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource = "Kalender auswählen"
static var description = IntentDescription("Leer = die Auswahl aus der Watch-App.")
// Must be optional: WidgetConfigurationIntent requires optional parameter
// types, and the watchOS SDK enforces it where iOS lets a bare array pass.
@Parameter(title: "Kalender")
var selectedCalendars: [WatchCalendarEntity]?
}
@@ -0,0 +1,62 @@
import WidgetKit
import AppIntents
import SwiftUI
import CalendarrCore
struct WatchComplicationEntry: TimelineEntry {
let date: Date
let snapshot: CalendarrSnapshot?
var language: String { snapshot?.language ?? "system" }
}
/// Builds every Calendarr complication's timeline.
///
/// Entries land on event boundaries rather than on a fixed schedule, so the
/// complication flips in the exact minute the next appointment changes without
/// a network call and without spending refresh budget. New *data* arrives by
/// push from the phone, which is why the refresh policy is `.atEnd`: when the
/// timeline simply runs out, regenerating from the same snapshot is cheap and
/// correct.
struct WatchComplicationProvider: AppIntentTimelineProvider {
typealias Entry = WatchComplicationEntry
typealias Intent = WatchCalendarSelectionIntent
func placeholder(in context: Context) -> WatchComplicationEntry {
WatchComplicationEntry(date: .now, snapshot: nil)
}
func snapshot(for configuration: WatchCalendarSelectionIntent,
in context: Context) async -> WatchComplicationEntry {
WatchComplicationEntry(date: .now, snapshot: resolved(configuration))
}
func timeline(for configuration: WatchCalendarSelectionIntent,
in context: Context) async -> Timeline<WatchComplicationEntry> {
let snapshot = resolved(configuration)
let now = Date()
let dates = snapshot.map { ComplicationTimeline.entryDates(from: now, in: $0) } ?? [now]
return Timeline(entries: dates.map { WatchComplicationEntry(date: $0, snapshot: snapshot) },
policy: .atEnd)
}
/// What the complication gallery offers before anyone configures anything.
/// Required on watchOS — iOS has a default implementation, the watch does
/// not, because the gallery has to be populated.
///
/// One entry, with nothing selected: that means "whatever the app is set
/// to", which is the filter the user already chose there.
func recommendations() -> [AppIntentRecommendation<WatchCalendarSelectionIntent>] {
[AppIntentRecommendation(intent: WatchCalendarSelectionIntent(),
description: Text("Calendarr"))]
}
/// The app-level filter applies first; a per-complication selection narrows
/// further. An empty selection means "whatever the app is set to".
private func resolved(_ configuration: WatchCalendarSelectionIntent) -> CalendarrSnapshot? {
guard case .ok(let snapshot) = SnapshotStore().read() else { return nil }
return snapshot
.filtered(excludingCalendarKeys: WatchFilterStore.load())
.filtered(toCalendarKeys: Set((configuration.selectedCalendars ?? []).map(\.id)))
}
}
@@ -0,0 +1,131 @@
import Foundation
import CalendarrCore
enum WatchComplicationSupport {
/// The first event that has not ended yet — including one running now,
/// which is what "what is next" means when you are already in a meeting.
/// Matches `WatchPushPolicy.nextEventIdentity`, deliberately.
static func nextEvent(in snapshot: CalendarrSnapshot?, at now: Date) -> SnapshotEvent? {
snapshot?.events.filter { $0.end > now }.min { $0.start < $1.start }
}
static func upcoming(in snapshot: CalendarrSnapshot?, at now: Date, limit: Int) -> [SnapshotEvent] {
guard let snapshot else { return [] }
return Array(snapshot.events.filter { $0.end > now }.sorted { $0.start < $1.start }.prefix(limit))
}
static func todayCount(in snapshot: CalendarrSnapshot?, at now: Date,
calendar: Calendar = Calendar(identifier: .gregorian)) -> Int {
guard let snapshot else { return 0 }
let dayStart = calendar.startOfDay(for: now)
guard let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) else { return 0 }
return snapshot.events.filter { $0.start < dayEnd && $0.end > dayStart }.count
}
/// The appointment happening right now, if there is one. All-day events are
/// excluded on purpose: one of those is "running" for twenty-four hours and
/// would hold the ring at some arbitrary fraction all day, which says
/// nothing.
static func runningEvent(in snapshot: CalendarrSnapshot?, at now: Date) -> SnapshotEvent? {
snapshot?.events
.filter { !$0.isAllDay && $0.start <= now && $0.end > now }
.min { $0.end < $1.end }
}
/// How far through an appointment we are, 0 at its start and 1 at its end.
static func progress(of event: SnapshotEvent, at now: Date) -> Double {
let total = event.end.timeIntervalSince(event.start)
guard total > 0 else { return 0 }
return min(1, max(0, now.timeIntervalSince(event.start) / total))
}
static func weekdayAbbreviation(_ date: Date, language: String) -> String {
let formatter = DateFormatter()
formatter.locale = WatchL10n.locale(language)
formatter.dateFormat = "EEE"
return formatter.string(from: date)
.replacingOccurrences(of: ".", with: "")
.uppercased()
}
/// What a countdown counts to: the start if it has not begun, otherwise the
/// end. Counting up from a start time that has passed reads as broken.
static func countdownTarget(_ event: SnapshotEvent, at now: Date) -> Date {
event.start > now ? event.start : event.end
}
/// A countdown narrow enough for a corner or a circular gauge: at most
/// three characters. `Text(style: .timer)` would be live and free of
/// timeline entries, but renders `1:23:45` and overlaps whatever sits
/// beside it — so the timeline carries minute-granular entries instead and
/// this is recomputed per entry.
static func compactCountdown(to target: Date, at now: Date, language: String) -> String {
let seconds = target.timeIntervalSince(now)
guard seconds > 0 else { return WatchL10n.t("watch.now", language) }
let minutes = Int(seconds / 60)
if minutes < 1 { return WatchL10n.t("watch.now", language) }
if minutes < 60 { return "\(minutes)m" }
let hours = minutes / 60
if hours < 24 { return "\(hours)h" }
return "\(hours / 24)d"
}
/// How full a draining ring should be, over a two-hour lead window.
static func gaugeFraction(to target: Date, at now: Date) -> Double {
let window: TimeInterval = 2 * 3600
return min(1, max(0, target.timeIntervalSince(now) / window))
}
/// How much of today is still ahead. Drains through the day, matching the
/// countdown ring rather than inventing a second direction.
static func dayRemainingFraction(at now: Date,
calendar: Calendar = Calendar(identifier: .gregorian)) -> Double {
let dayStart = calendar.startOfDay(for: now)
guard let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) else { return 0 }
let total = dayEnd.timeIntervalSince(dayStart)
guard total > 0 else { return 0 }
return min(1, max(0, dayEnd.timeIntervalSince(now) / total))
}
/// The share of today's appointments still ahead. Zero when there are none,
/// which leaves the ring empty rather than full — an empty day should not
/// look like an untouched one.
static func todayRemainingFraction(in snapshot: CalendarrSnapshot?, at now: Date,
calendar: Calendar = Calendar(identifier: .gregorian)) -> Double {
guard let snapshot else { return 0 }
let dayStart = calendar.startOfDay(for: now)
guard let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) else { return 0 }
let today = snapshot.events.filter { $0.start < dayEnd && $0.end > dayStart }
guard !today.isEmpty else { return 0 }
let ahead = today.filter { $0.end > now }.count
return min(1, max(0, Double(ahead) / Double(today.count)))
}
static func time(_ date: Date, language: String) -> String {
let formatter = DateFormatter()
formatter.locale = WatchL10n.locale(language)
formatter.dateFormat = "HH:mm"
return formatter.string(from: date)
}
static func monthAbbreviation(_ date: Date, language: String) -> String {
let formatter = DateFormatter()
formatter.locale = WatchL10n.locale(language)
formatter.dateFormat = "LLL"
return formatter.string(from: date).uppercased()
}
/// The curved label that accompanies a circular or corner complication:
/// time plus title, or a plain "no events".
///
/// On a corner this follows the screen edge. On a face with a bezel slot —
/// Infograph — the same label arcs across the top, where the tick marks
/// otherwise are. That is the position ClockKit called `graphicBezel`, and
/// in WidgetKit it is reached by attaching `.widgetLabel` to an
/// `accessoryCircular` widget.
static func eventLabel(_ event: SnapshotEvent?, at now: Date, language: String) -> String {
guard let event else { return WatchL10n.t("watch.no_events", language) }
if event.isAllDay { return event.title }
return "\(time(event.start, language: language)) \(event.title)"
}
}
@@ -0,0 +1,23 @@
import Foundation
import CalendarrCore
/// Which calendars this watch hides, on top of what the phone already filtered.
///
/// The snapshot arrives with hidden and banished calendars already removed, so
/// this narrows further and can never contradict the phone. It lives in the App
/// Group so one toggle in the app affects every complication at once.
enum WatchFilterStore {
private static let key = "watchHiddenCalendarKeys"
private static var defaults: UserDefaults? {
UserDefaults(suiteName: CalendarrAppGroup.current)
}
static func load() -> Set<String> {
Set(defaults?.stringArray(forKey: key) ?? [])
}
static func save(_ keys: Set<String>) {
defaults?.set(keys.sorted(), forKey: key)
}
}
+57
View File
@@ -0,0 +1,57 @@
import Foundation
/// German and English strings for the watch app and its complications.
///
/// A local copy rather than a shared one: `CalendarrCore` is deliberately
/// Foundation-only and holds no presentation concerns, and the iOS `WidgetL10n`
/// belongs to a target the watch cannot link against.
enum WatchL10n {
static func t(_ key: String, _ stored: String) -> String {
strings[resolve(stored)]?[key] ?? strings["en"]?[key] ?? key
}
static func locale(_ stored: String) -> Locale {
Locale(identifier: resolve(stored) == "de" ? "de_DE" : "en_US")
}
private static func resolve(_ stored: String) -> String {
if stored == "de" || stored == "en" { return stored }
let preferred = Locale.preferredLanguages.first ?? "en"
return preferred.lowercased().hasPrefix("de") ? "de" : "en"
}
private static let strings: [String: [String: String]] = [
"de": [
"watch.title": "Calendarr",
"watch.calendars": "Kalender",
"watch.allday": "Ganztägig",
"watch.no_events": "Keine Termine",
"watch.now": "jetzt",
"watch.today": "Heute",
"watch.tomorrow": "Morgen",
"watch.events_today": "Termine",
"watch.sign_in": "Auf dem iPhone anmelden",
"watch.update": "Watch-App aktualisieren",
"watch.waiting": "Warte auf Daten vom iPhone",
"watch.unreadable": "Daten nicht lesbar",
"watch.as_of": "Stand",
"watch.no_information": "Keine Daten nach",
],
"en": [
"watch.title": "Calendarr",
"watch.calendars": "Calendars",
"watch.allday": "All-day",
"watch.no_events": "No events",
"watch.now": "now",
"watch.today": "Today",
"watch.tomorrow": "Tomorrow",
"watch.events_today": "events",
"watch.sign_in": "Sign in on your iPhone",
"watch.update": "Update the watch app",
"watch.waiting": "Waiting for data from your iPhone",
"watch.unreadable": "Data unreadable",
"watch.as_of": "As of",
"watch.no_information": "No data after",
],
]
}
@@ -0,0 +1,19 @@
//
// CalendarrWatch_Watch_AppTests.swift
// CalendarrWatch Watch AppTests
//
// Created by Guido Schmit on 11.09.2026.
//
import Testing
@testable import CalendarrWatch_Watch_App
struct CalendarrWatch_Watch_AppTests {
@Test func example() async throws {
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
// Swift Testing Documentation
// https://developer.apple.com/documentation/testing
}
}
@@ -0,0 +1,43 @@
//
// CalendarrWatch_Watch_AppUITests.swift
// CalendarrWatch Watch AppUITests
//
// Created by Guido Schmit on 11.09.2026.
//
import XCTest
final class CalendarrWatch_Watch_AppUITests: XCTestCase {
override func setUpWithError() throws {
// Put setup code here. This method is called before the invocation of each test method in the class.
// In UI tests it is usually best to stop immediately when a failure occurs.
continueAfterFailure = false
// In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
}
override func tearDownWithError() throws {
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
@MainActor
func testExample() throws {
// UI tests must launch the application that they test.
let app = XCUIApplication()
app.launch()
// Use XCTAssert and related functions to verify your tests produce the correct results.
// XCUIAutomation Documentation
// https://developer.apple.com/documentation/xcuiautomation
}
@MainActor
func testLaunchPerformance() throws {
// This measures how long it takes to launch your application.
measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication().launch()
}
}
}
@@ -0,0 +1,152 @@
import SwiftUI
import WidgetKit
struct CalendarDayWidgetView: View {
let entry: CalendarrEntry
private var snapshot: WidgetSnapshot? { entry.snapshot }
private var lang: String { snapshot?.language ?? "system" }
private var cal: Calendar {
var c = Calendar(identifier: .gregorian)
c.locale = WidgetL10n.locale(lang)
c.firstWeekday = 2
return c
}
private var weekDays: [Date] {
let start = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: entry.date)) ?? entry.date
return (0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: start) }
}
private var upcomingEvents: [WidgetEvent] {
guard let s = snapshot else { return [] }
return WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s)
}
private var monthFmt: DateFormatter {
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "LLLL"; return f
}
private var weekdayFmt: DateFormatter {
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "EEEE"; return f
}
private var timeFmt: DateFormatter {
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "HH:mm"; return f
}
var body: some View {
if let s = snapshot {
let primary = Color(widgetHex: s.primaryColorHex)
let accent = Color(widgetHex: s.accentColorHex)
VStack(alignment: .leading, spacing: 0) {
header(primary: primary)
.padding(.top, 6)
weekStrip(snapshot: s, primary: primary, accent: accent)
.padding(.vertical, 3)
Rectangle()
.fill(Color(widgetHex: s.lineColorHex).opacity(0.4))
.frame(height: 0.5)
.padding(.bottom, 4)
eventList(accent: accent)
}
} else {
Text(WidgetL10n.t("widget.no_data", lang))
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: – Header
private func header(primary: Color) -> some View {
HStack(alignment: .top, spacing: 6) {
Text("\(cal.component(.day, from: entry.date))")
.font(.system(size: 30, weight: .bold))
.foregroundStyle(primary)
.frame(width: 40, alignment: .leading)
.minimumScaleFactor(0.7)
VStack(alignment: .leading, spacing: 1) {
Text(monthFmt.string(from: entry.date).uppercased())
.font(.system(size: 11, weight: .bold))
.foregroundStyle(primary)
Text("\(WidgetL10n.t("widget.today", lang)), \(weekdayFmt.string(from: entry.date))")
.font(.system(size: 10))
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.75)
}
Spacer(minLength: 0)
}
.padding(.bottom, 2)
}
// MARK: – Week strip
private func weekStrip(snapshot: WidgetSnapshot, primary: Color, accent: Color) -> some View {
HStack(spacing: 0) {
ForEach(weekDays, id: \.self) { day in
let isToday = cal.isDateInToday(day)
let hasEvs = !WidgetHelpers.events(for: day, in: snapshot).isEmpty
VStack(spacing: 2) {
Text(shortDay(day))
.font(.system(size: 8, weight: .bold))
.foregroundStyle(isToday ? accent : .secondary)
ZStack {
if isToday {
Circle().fill(primary)
} else if hasEvs {
Circle().fill(accent.opacity(0.18))
}
Text("\(cal.component(.day, from: day))")
.font(.system(size: 11, weight: isToday ? .bold : .medium))
.foregroundStyle(isToday ? .white : .primary)
}
.frame(width: 22, height: 22)
}
.frame(maxWidth: .infinity)
}
}
}
private func shortDay(_ date: Date) -> String {
let f = DateFormatter()
f.locale = WidgetL10n.locale(lang)
f.dateFormat = "EEE"
return String(f.string(from: date).prefix(2)).uppercased()
}
// MARK: – Event list
@ViewBuilder
private func eventList(accent: Color) -> some View {
if upcomingEvents.isEmpty {
Text(WidgetL10n.t("widget.no_events", lang))
.font(.system(size: 11))
.foregroundStyle(.secondary)
Spacer(minLength: 0)
} else {
VStack(alignment: .leading, spacing: 4) {
ForEach(upcomingEvents.prefix(3)) { ev in
HStack(alignment: .center, spacing: 6) {
RoundedRectangle(cornerRadius: 1.5)
.fill(Color(widgetHex: ev.colorHex))
.frame(width: 3, height: 22)
VStack(alignment: .leading, spacing: 1) {
Text(ev.title)
.font(.system(size: 11, weight: .semibold))
.lineLimit(1)
Text(ev.isAllDay
? WidgetL10n.t("widget.allday", lang)
: "\(timeFmt.string(from: ev.start)) – \(timeFmt.string(from: ev.end))")
.font(.system(size: 9))
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
}
Spacer(minLength: 0)
}
}
}
}
+47
View 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]?
}
@@ -0,0 +1,66 @@
import WidgetKit
import AppIntents
import CalendarrCore
struct CalendarrEntry: TimelineEntry {
let date: Date
let snapshot: WidgetSnapshot?
}
struct CalendarrTimelineProvider: AppIntentTimelineProvider {
typealias Entry = CalendarrEntry
typealias Intent = CalendarSelectionIntent
func placeholder(in context: Context) -> CalendarrEntry {
CalendarrEntry(date: .now, snapshot: WidgetStore.read())
}
func snapshot(for configuration: CalendarSelectionIntent, in context: Context) async -> CalendarrEntry {
let raw = WidgetStore.read()
return CalendarrEntry(date: .now, snapshot: filtered(raw, by: configuration))
}
func timeline(for configuration: CalendarSelectionIntent, in context: Context) async -> Timeline<CalendarrEntry> {
let raw = WidgetStore.read()
let snap = filtered(raw, by: configuration)
let now = Date()
// One entry per hour for 24 h so the widget re-renders as time advances.
var entries: [CalendarrEntry] = []
for h in 0..<24 {
let date = Calendar.current.date(byAdding: .hour, value: h, to: now) ?? now
entries.append(CalendarrEntry(date: date, snapshot: snap))
}
let refreshAt = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now
return Timeline(entries: entries, policy: .after(refreshAt))
}
// MARK: – Filtering
private func filtered(_ snapshot: WidgetSnapshot?, by config: CalendarSelectionIntent) -> WidgetSnapshot? {
// The rule lives in CalendarrCore so the watch provider cannot drift
// from this one. Nothing selected means "show all calendars".
snapshot?.filtered(toCalendarKeys: Set((config.selectedCalendars ?? []).map(\.id)))
}
}
// MARK: – Shared helpers used by all widget views
enum WidgetHelpers {
static func events(for day: Date, in snapshot: WidgetSnapshot) -> [WidgetEvent] {
let cal = Calendar.current
let dayStart = cal.startOfDay(for: day)
let dayEnd = cal.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart
return snapshot.events
.filter { $0.start < dayEnd && $0.end > dayStart }
.sorted { $0.start < $1.start }
}
static func upcoming(from now: Date, daysAhead: Int, in snapshot: WidgetSnapshot) -> [WidgetEvent] {
let cal = Calendar.current
let end = cal.date(byAdding: .day, value: daysAhead, to: cal.startOfDay(for: now)) ?? now
return snapshot.events
.filter { $0.end > now && $0.start < end }
.sorted { $0.start < $1.start }
}
}
@@ -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>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.scarriffleservices.calendarr</string>
</array>
</dict>
</plist>
+243
View File
@@ -0,0 +1,243 @@
import WidgetKit
import SwiftUI
@main
struct CalendarrWidgetBundle: WidgetBundle {
var body: some Widget {
TodayWidget()
TwoDaysWidget()
ThreeDaysWidget()
ThisWeekWidget()
TwoWeeksWidget()
UpcomingWidget()
UpNextWidget()
CalendarDayWidget()
TwoMonthWidget()
NowNextEventsWidget()
LockScreenWidget()
LockScreenCountWidget()
LockScreenCountdownWidget()
}
}
// Shared chrome modifier — keeps every home-screen widget on the same theme.
private struct CalendarrWidgetChrome: ViewModifier {
let snapshot: WidgetSnapshot?
func body(content: Content) -> some View {
let lang = snapshot?.language ?? "system"
content
.containerBackground(for: .widget) {
Color(widgetHex: snapshot?.backgroundColorHex ?? "#000000")
}
.foregroundStyle(Color(widgetHex: snapshot?.textColorHex ?? "#FFFFFF"))
.environment(\.locale, WidgetL10n.locale(lang))
}
}
private extension View {
func calendarrChrome(_ snapshot: WidgetSnapshot?) -> some View {
modifier(CalendarrWidgetChrome(snapshot: snapshot))
}
}
// MARK: – Today (small)
struct TodayWidget: Widget {
let kind: String = "TodayWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
TodayWidgetView(entry: entry).calendarrChrome(entry.snapshot)
}
.configurationDisplayName(WidgetL10n.t("widget.display.today_title", "system"))
.description(WidgetL10n.t("widget.display.today_desc", "system"))
.supportedFamilies([.systemSmall])
}
}
// MARK: – Today & Tomorrow (medium)
struct TwoDaysWidget: Widget {
let kind: String = "TwoDaysWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
TwoDaysWidgetView(entry: entry).calendarrChrome(entry.snapshot)
}
.configurationDisplayName(WidgetL10n.t("widget.display.days_title", "system"))
.description(WidgetL10n.t("widget.display.days_desc", "system"))
.supportedFamilies([.systemMedium])
}
}
// MARK: – Three Days (medium)
struct ThreeDaysWidget: Widget {
let kind: String = "ThreeDaysWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
ThreeDaysWidgetView(entry: entry).calendarrChrome(entry.snapshot)
}
.configurationDisplayName(WidgetL10n.t("widget.display.threedays_title", "system"))
.description(WidgetL10n.t("widget.display.threedays_desc", "system"))
.supportedFamilies([.systemMedium])
}
}
// MARK: – This Week (medium)
struct ThisWeekWidget: Widget {
let kind: String = "ThisWeekWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
ThisWeekWidgetView(entry: entry).calendarrChrome(entry.snapshot)
}
.configurationDisplayName(WidgetL10n.t("widget.display.thisweek_title", "system"))
.description(WidgetL10n.t("widget.display.thisweek_desc", "system"))
.supportedFamilies([.systemMedium])
}
}
// MARK: – Two Weeks (medium)
struct TwoWeeksWidget: Widget {
let kind: String = "TwoWeeksWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
TwoWeeksWidgetView(entry: entry).calendarrChrome(entry.snapshot)
}
.configurationDisplayName(WidgetL10n.t("widget.display.twoweeks_title", "system"))
.description(WidgetL10n.t("widget.display.twoweeks_desc", "system"))
.supportedFamilies([.systemMedium])
}
}
// MARK: – Upcoming (large + extra large on iPad)
struct UpcomingWidget: Widget {
let kind: String = "UpcomingWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
UpcomingWidgetView(entry: entry).calendarrChrome(entry.snapshot)
}
.configurationDisplayName(WidgetL10n.t("widget.display.upcoming_title", "system"))
.description(WidgetL10n.t("widget.display.upcoming_desc", "system"))
.supportedFamilies([.systemLarge, .systemExtraLarge])
}
}
// MARK: – Up Next + Calendar (medium)
struct UpNextWidget: Widget {
let kind: String = "UpNextWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
UpNextWidgetView(entry: entry).calendarrChrome(entry.snapshot)
}
.configurationDisplayName(WidgetL10n.t("widget.display.upnext_title", "system"))
.description(WidgetL10n.t("widget.display.upnext_desc", "system"))
.supportedFamilies([.systemMedium])
}
}
// MARK: – Calendar Day: date + week strip + events (medium)
struct CalendarDayWidget: Widget {
let kind: String = "CalendarDayWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
CalendarDayWidgetView(entry: entry).calendarrChrome(entry.snapshot)
}
.configurationDisplayName(WidgetL10n.t("widget.display.calday_title", "system"))
.description(WidgetL10n.t("widget.display.calday_desc", "system"))
.supportedFamilies([.systemMedium])
}
}
// MARK: – Two Month calendar grid (medium + large)
struct TwoMonthWidget: Widget {
let kind: String = "TwoMonthWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
TwoMonthWidgetView(entry: entry).calendarrChrome(entry.snapshot)
}
.configurationDisplayName(WidgetL10n.t("widget.display.twomonth_title", "system"))
.description(WidgetL10n.t("widget.display.twomonth_desc", "system"))
.supportedFamilies([.systemMedium, .systemLarge])
}
}
// MARK: – Now & Next events (medium)
struct NowNextEventsWidget: Widget {
let kind: String = "NowNextEventsWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
NowNextWidgetView(entry: entry).calendarrChrome(entry.snapshot)
}
.configurationDisplayName(WidgetL10n.t("widget.display.nownext_title", "system"))
.description(WidgetL10n.t("widget.display.nownext_desc", "system"))
.supportedFamilies([.systemMedium])
}
}
// MARK: – Lock Screen: date (circular, rectangular, inline)
struct LockScreenWidget: Widget {
let kind: String = "LockScreenWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
LockScreenWidgetView(entry: entry)
.containerBackground(for: .widget) { Color.clear }
.environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system"))
}
.configurationDisplayName(WidgetL10n.t("widget.display.lockscreen_title", "system"))
.description(WidgetL10n.t("widget.display.lockscreen_desc", "system"))
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
}
}
// MARK: – Lock Screen: today event count (circular, rectangular, inline)
struct LockScreenCountWidget: Widget {
let kind: String = "LockScreenCountWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
LockScreenCountWidgetView(entry: entry)
.containerBackground(for: .widget) { Color.clear }
.environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system"))
}
.configurationDisplayName(WidgetL10n.t("widget.display.lockscreen_count_title", "system"))
.description(WidgetL10n.t("widget.display.lockscreen_count_desc", "system"))
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
}
}
// MARK: – Lock Screen: countdown to next event (circular, rectangular, inline)
struct LockScreenCountdownWidget: Widget {
let kind: String = "LockScreenCountdownWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
LockScreenCountdownWidgetView(entry: entry)
.containerBackground(for: .widget) { Color.clear }
.environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system"))
}
.configurationDisplayName(WidgetL10n.t("widget.display.lockscreen_countdown_title", "system"))
.description(WidgetL10n.t("widget.display.lockscreen_countdown_desc", "system"))
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
}
}
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Calendarr Widgets</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,288 @@
import SwiftUI
import WidgetKit
// MARK: – Date widget (existing)
struct LockScreenWidgetView: View {
let entry: CalendarrEntry
@Environment(\.widgetFamily) private var family
private var snapshot: WidgetSnapshot? { entry.snapshot }
private var lang: String { snapshot?.language ?? "system" }
private var cal: Calendar {
var c = Calendar(identifier: .gregorian)
c.locale = WidgetL10n.locale(lang)
return c
}
private var nextEvent: WidgetEvent? {
guard let s = snapshot else { return nil }
return WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s).first
}
private var timeFmt: DateFormatter {
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "HH:mm"; return f
}
private var monthAbbrev: String {
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "LLL"
return f.string(from: entry.date).uppercased()
}
@ViewBuilder
var body: some View {
switch family {
case .accessoryCircular:
circularView
case .accessoryRectangular:
rectangularView
default:
inlineView
}
}
// MARK: – Circular: today's date
private var circularView: some View {
ZStack {
AccessoryWidgetBackground()
VStack(spacing: 0) {
Text("\(cal.component(.day, from: entry.date))")
.font(.system(size: 22, weight: .bold))
.minimumScaleFactor(0.7)
.widgetAccentable()
Text(monthAbbrev)
.font(.system(size: 8, weight: .semibold))
}
}
}
// MARK: – Rectangular: next event
private var rectangularView: some View {
VStack(alignment: .leading, spacing: 2) {
if let ev = nextEvent {
Text(ev.isAllDay
? WidgetL10n.t("widget.allday", lang)
: timeFmt.string(from: ev.start))
.font(.system(size: 11, weight: .semibold))
.widgetAccentable()
Text(ev.title)
.font(.system(size: 14, weight: .bold))
.lineLimit(1)
if !ev.location.isEmpty {
Text(ev.location)
.font(.system(size: 11))
.lineLimit(1)
}
} else {
Image(systemName: "calendar")
.font(.system(size: 13))
.widgetAccentable()
Text(WidgetL10n.t("widget.no_events", lang))
.font(.system(size: 13))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
// MARK: – Inline: brief next event
private var inlineView: some View {
let text: String = {
guard let ev = nextEvent else {
return WidgetL10n.t("widget.no_events", lang)
}
return ev.isAllDay ? ev.title : "\(timeFmt.string(from: ev.start)) \(ev.title)"
}()
return Label(text, systemImage: "calendar")
}
}
// MARK: – Today event count widget
struct LockScreenCountWidgetView: View {
let entry: CalendarrEntry
@Environment(\.widgetFamily) private var family
private var snapshot: WidgetSnapshot? { entry.snapshot }
private var lang: String { snapshot?.language ?? "system" }
private var timeFmt: DateFormatter {
let f = DateFormatter()
f.locale = WidgetL10n.locale(lang)
f.dateFormat = "HH:mm"
return f
}
private var todayEvents: [WidgetEvent] {
guard let s = snapshot else { return [] }
return WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s)
}
@ViewBuilder
var body: some View {
switch family {
case .accessoryCircular:
circularView
case .accessoryRectangular:
rectangularView
default:
inlineView
}
}
private var circularView: some View {
ZStack {
AccessoryWidgetBackground()
VStack(spacing: 1) {
Image(systemName: "calendar")
.font(.system(size: 10, weight: .semibold))
.widgetAccentable()
Text("\(todayEvents.count)")
.font(.system(size: 22, weight: .bold))
.minimumScaleFactor(0.7)
.widgetAccentable()
}
}
}
private var rectangularView: some View {
let countLabel = "\(todayEvents.count) \(WidgetL10n.t("widget.events_count", lang))"
return VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 4) {
Text(WidgetL10n.t("widget.today", lang).uppercased())
.font(.system(size: 9, weight: .bold))
.widgetAccentable()
Text("· \(countLabel)")
.font(.system(size: 9))
}
if todayEvents.isEmpty {
Text(WidgetL10n.t("widget.no_events", lang))
.font(.system(size: 12))
.foregroundStyle(.secondary)
} else {
ForEach(todayEvents.prefix(2)) { ev in
HStack(spacing: 4) {
Text(ev.isAllDay ? "·" : timeFmt.string(from: ev.start))
.font(.system(size: 10, weight: .semibold))
.widgetAccentable()
.frame(width: 32, alignment: .leading)
Text(ev.title)
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var inlineView: some View {
let label = "\(todayEvents.count) \(WidgetL10n.t("widget.events_count", lang))"
return Label(label, systemImage: "calendar.badge.clock")
}
}
// MARK: – Countdown to next event widget
struct LockScreenCountdownWidgetView: View {
let entry: CalendarrEntry
@Environment(\.widgetFamily) private var family
private var snapshot: WidgetSnapshot? { entry.snapshot }
private var lang: String { snapshot?.language ?? "system" }
private var timeFmt: DateFormatter {
let f = DateFormatter()
f.locale = WidgetL10n.locale(lang)
f.dateFormat = "HH:mm"
return f
}
private var nextEvent: WidgetEvent? {
guard let s = snapshot else { return nil }
return WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s).first
}
private var isRunning: Bool {
guard let ev = nextEvent, !ev.isAllDay else { return false }
return ev.start <= entry.date && ev.end > entry.date
}
private var countdownText: String {
guard let ev = nextEvent else { return WidgetL10n.t("widget.no_events", lang) }
if isRunning { return WidgetL10n.t("widget.running", lang) }
if ev.isAllDay { return WidgetL10n.t("widget.allday", lang) }
let total = Int(max(0, ev.start.timeIntervalSince(entry.date)) / 60)
if total < 60 { return "in \(total)m" }
let h = total / 60; let m = total % 60
return m == 0 ? "in \(h)h" : "in \(h)h \(m)m"
}
@ViewBuilder
var body: some View {
switch family {
case .accessoryCircular:
circularView
case .accessoryRectangular:
rectangularView
default:
inlineView
}
}
private var circularView: some View {
ZStack {
AccessoryWidgetBackground()
VStack(spacing: 1) {
Text(countdownText)
.font(.system(size: 13, weight: .bold))
.minimumScaleFactor(0.5)
.lineLimit(1)
.widgetAccentable()
if let ev = nextEvent, !ev.isAllDay {
Text(timeFmt.string(from: ev.start))
.font(.system(size: 8))
.lineLimit(1)
}
}
.padding(.horizontal, 4)
}
}
private var rectangularView: some View {
VStack(alignment: .leading, spacing: 2) {
if let ev = nextEvent {
Text(countdownText)
.font(.system(size: 11, weight: .semibold))
.widgetAccentable()
Text(ev.title)
.font(.system(size: 14, weight: .bold))
.lineLimit(1)
let timeStr = ev.isAllDay
? WidgetL10n.t("widget.allday", lang)
: "\(timeFmt.string(from: ev.start)) – \(timeFmt.string(from: ev.end))"
Text(timeStr)
.font(.system(size: 11))
.lineLimit(1)
} else {
Image(systemName: "timer")
.font(.system(size: 13))
.widgetAccentable()
Text(WidgetL10n.t("widget.no_events", lang))
.font(.system(size: 13))
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var inlineView: some View {
let text: String = {
guard let ev = nextEvent else { return WidgetL10n.t("widget.no_events", lang) }
return "\(ev.title) \(countdownText)"
}()
return Label(text, systemImage: "timer")
}
}
@@ -0,0 +1,162 @@
import SwiftUI
import WidgetKit
struct NowNextWidgetView: View {
let entry: CalendarrEntry
private var snapshot: WidgetSnapshot? { entry.snapshot }
private var lang: String { snapshot?.language ?? "system" }
private var cal: Calendar {
var c = Calendar(identifier: .gregorian)
c.locale = WidgetL10n.locale(lang)
return c
}
private var timeFmt: DateFormatter {
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "HH:mm"; return f
}
private var dayOfWeekFmt: DateFormatter {
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "EEEE"; return f
}
// Currently running event, or next upcoming timed event, or first all-day event
private var featuredEvent: WidgetEvent? {
guard let s = snapshot else { return nil }
let pool = WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s)
if let running = pool.first(where: { !$0.isAllDay && $0.start <= entry.date }) { return running }
if let next = pool.first(where: { !$0.isAllDay }) { return next }
return pool.first
}
// All upcoming events today except the featured one
private var remainingEvents: [WidgetEvent] {
guard let s = snapshot else { return [] }
let pool = WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s)
guard let featured = featuredEvent else { return pool }
return pool.filter { $0.id != featured.id }
}
private func timeRange(_ ev: WidgetEvent) -> String {
ev.isAllDay
? WidgetL10n.t("widget.allday", lang)
: "\(timeFmt.string(from: ev.start)) – \(timeFmt.string(from: ev.end))"
}
var body: some View {
if let s = snapshot {
let line = Color(widgetHex: s.lineColorHex)
VStack(spacing: 6) {
featuredCard(snapshot: s)
bottomRow(line: line)
}
} else {
Text(WidgetL10n.t("widget.no_data", lang))
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: – Featured event card
private func featuredCard(snapshot: WidgetSnapshot) -> some View {
let ev = featuredEvent
let baseColor = ev.map { Color(widgetHex: $0.colorHex) } ?? Color(widgetHex: snapshot.primaryColorHex)
return ZStack(alignment: .leading) {
LinearGradient(
colors: [baseColor.opacity(0.75), baseColor],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.clipShape(RoundedRectangle(cornerRadius: 10))
HStack(spacing: 0) {
VStack(alignment: .leading, spacing: 2) {
Text(ev?.title ?? WidgetL10n.t("widget.no_events", lang))
.font(.system(size: 13, weight: .bold))
.foregroundStyle(.white)
.lineLimit(1)
Text(ev.map { timeRange($0) } ?? "")
.font(.system(size: 10))
.foregroundStyle(.white.opacity(0.85))
}
.padding(.leading, 10)
.padding(.vertical, 9)
Spacer()
if ev != nil {
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white.opacity(0.7))
.padding(.trailing, 12)
}
}
}
.frame(maxWidth: .infinity)
.fixedSize(horizontal: false, vertical: true)
}
// MARK: – Bottom: date + event list
private func bottomRow(line: Color) -> some View {
HStack(alignment: .top, spacing: 0) {
// Left: day name + large number
VStack(alignment: .leading, spacing: 0) {
Text(dayOfWeekFmt.string(from: entry.date).uppercased())
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.6)
Text("\(cal.component(.day, from: entry.date))")
.font(.system(size: 30, weight: .light))
}
.frame(width: 50, alignment: .leading)
// Divider
line.opacity(0.4).frame(width: 0.5)
.padding(.horizontal, 6)
// Right: event list
VStack(alignment: .leading, spacing: 4) {
let shown = remainingEvents.prefix(2)
if shown.isEmpty {
Text(WidgetL10n.t("widget.no_events", lang))
.font(.system(size: 10))
.foregroundStyle(.secondary)
} else {
ForEach(shown) { ev in
HStack(alignment: .firstTextBaseline, spacing: 5) {
Circle()
.fill(Color(widgetHex: ev.colorHex))
.frame(width: 7, height: 7)
.padding(.top, 1)
VStack(alignment: .leading, spacing: 0) {
Text(ev.title)
.font(.system(size: 11, weight: .semibold))
.lineLimit(1)
Text(timeRange(ev))
.font(.system(size: 9))
.foregroundStyle(.secondary)
}
}
}
}
Spacer(minLength: 0)
}
Spacer(minLength: 0)
// +N badge
if remainingEvents.count > 2 {
Text("+\(remainingEvents.count - 2)")
.font(.system(size: 9, weight: .semibold))
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(.secondary.opacity(0.18), in: Capsule())
.frame(maxHeight: .infinity, alignment: .bottom)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
@@ -0,0 +1,116 @@
import SwiftUI
import WidgetKit
struct ThisWeekWidgetView: View {
let entry: CalendarrEntry
private var snapshot: WidgetSnapshot? { entry.snapshot }
private var lang: String { snapshot?.language ?? "system" }
private var cal: Calendar {
var c = Calendar(identifier: .gregorian)
c.locale = WidgetL10n.locale(lang)
c.firstWeekday = 2
return c
}
private var weekStart: Date {
cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: entry.date)) ?? entry.date
}
private var weekDays: [Date] {
(0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: weekStart) }
}
private var monthHeader: String {
let f = DateFormatter()
f.locale = WidgetL10n.locale(lang)
f.dateFormat = "LLLL yyyy"
return f.string(from: weekStart).uppercased()
}
private var weekdayHeaders: [String] {
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang)
let symbols = f.shortWeekdaySymbols ?? cal.shortWeekdaySymbols
let start = cal.firstWeekday - 1
return (0..<7).map { String(symbols[(start + $0) % 7].prefix(2)).uppercased() }
}
var body: some View {
if let s = snapshot {
let primary = Color(widgetHex: s.primaryColorHex)
let accent = Color(widgetHex: s.accentColorHex)
VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(monthHeader.split(separator: " ").first.map(String.init) ?? "")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(primary)
Text(monthHeader.split(separator: " ").dropFirst().joined(separator: " "))
.font(.system(size: 10, weight: .semibold))
}
// Equal-width columns via maxWidth — no GeometryReader needed
HStack(spacing: 0) {
ForEach(Array(weekDays.enumerated()), id: \.offset) { idx, day in
dayColumn(day, snapshot: s, primary: primary, accent: accent)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.overlay(alignment: .trailing) {
if idx < 6 {
Rectangle()
.fill(Color(widgetHex: s.lineColorHex).opacity(0.35))
.frame(width: 0.5)
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
} else {
Text(WidgetL10n.t("widget.no_data", lang))
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private func dayColumn(_ day: Date,
snapshot: WidgetSnapshot,
primary: Color,
accent: Color) -> some View {
let isToday = cal.isDateInToday(day)
let evs = WidgetHelpers.events(for: day, in: snapshot)
let dayIdx = (0..<7).firstIndex { i in cal.isDate(weekDays[i], inSameDayAs: day) } ?? 0
return VStack(alignment: .center, spacing: 1) {
Text(weekdayHeaders[dayIdx])
.font(.system(size: 7.5, weight: .bold))
.foregroundStyle(isToday ? accent : .secondary)
Text("\(cal.component(.day, from: day))")
.font(.system(size: 10, weight: isToday ? .bold : .semibold))
.foregroundStyle(isToday ? Color.white : Color.primary)
.frame(width: 16, height: 16)
.background(isToday ? primary : Color.clear)
.clipShape(Circle())
ForEach(evs.prefix(8)) { ev in
eventPill(ev)
}
if evs.count > 8 {
Text("+\(evs.count - 8)")
.font(.system(size: 6.5))
.foregroundStyle(accent)
}
Spacer(minLength: 0)
}
.padding(.horizontal, 1)
}
private func eventPill(_ ev: WidgetEvent) -> some View {
Text(ev.title)
.font(.system(size: 7, weight: .medium))
.lineLimit(1)
.foregroundStyle(.white)
.padding(.horizontal, 2)
.padding(.vertical, 0.5)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(widgetHex: ev.colorHex))
.clipShape(RoundedRectangle(cornerRadius: 1.5))
}
}
@@ -0,0 +1,131 @@
import SwiftUI
import WidgetKit
struct ThreeDaysWidgetView: View {
let entry: CalendarrEntry
private var snapshot: WidgetSnapshot? { entry.snapshot }
private var lang: String { snapshot?.language ?? "system" }
private var cal: Calendar {
var c = Calendar(identifier: .gregorian)
c.locale = WidgetL10n.locale(lang)
return c
}
private var days: [Date] {
let today = cal.startOfDay(for: entry.date)
return (0..<3).compactMap { cal.date(byAdding: .day, value: $0, to: today) }
}
private var monthHeader: String {
let f = DateFormatter()
f.locale = WidgetL10n.locale(lang)
f.dateFormat = "LLLL yyyy"
return f.string(from: entry.date).uppercased()
}
private var weekdayFmt: DateFormatter {
let f = DateFormatter()
f.locale = WidgetL10n.locale(lang)
f.dateFormat = "EEE"
return f
}
private var timeFmt: DateFormatter {
let f = DateFormatter()
f.locale = WidgetL10n.locale(lang)
f.dateFormat = "HH:mm"
return f
}
var body: some View {
if let s = snapshot {
let primary = Color(widgetHex: s.primaryColorHex)
let accent = Color(widgetHex: s.accentColorHex)
VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(monthHeader.split(separator: " ").first.map(String.init) ?? "")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(primary)
Text(monthHeader.split(separator: " ").dropFirst().joined(separator: " "))
.font(.system(size: 11, weight: .semibold))
}
HStack(spacing: 0) {
ForEach(Array(days.enumerated()), id: \.offset) { idx, day in
column(for: day, snapshot: s, primary: primary, accent: accent)
.frame(maxWidth: .infinity, alignment: .topLeading)
.overlay(alignment: .trailing) {
if idx < 2 {
Rectangle()
.fill(Color(widgetHex: s.lineColorHex).opacity(0.4))
.frame(width: 0.5)
}
}
}
}
}
} else {
Text(WidgetL10n.t("widget.no_data", lang))
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private func column(for day: Date, snapshot: WidgetSnapshot, primary: Color, accent: Color) -> some View {
let isToday = cal.isDateInToday(day)
let evs = WidgetHelpers.events(for: day, in: snapshot)
return VStack(alignment: .leading, spacing: 2) {
HStack {
Text(weekdayFmt.string(from: day).uppercased() + ".")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(isToday ? accent : .secondary)
Spacer()
Text("\(cal.component(.day, from: day))")
.font(.system(size: 11, weight: isToday ? .bold : .semibold))
.foregroundStyle(isToday ? Color.white : Color.primary)
.frame(width: 17, height: 17)
.background(isToday ? primary : Color.clear)
.clipShape(Circle())
}
.padding(.horizontal, 3)
if evs.isEmpty {
Text(WidgetL10n.t("widget.no_events", lang))
.font(.system(size: 9))
.foregroundStyle(.tertiary)
.padding(.horizontal, 3)
} else {
ForEach(evs.prefix(4)) { ev in
eventRow(ev)
}
if evs.count > 4 {
Text("+\(evs.count - 4)")
.font(.system(size: 8))
.foregroundStyle(accent)
.padding(.leading, 3)
}
}
Spacer(minLength: 0)
}
}
private func eventRow(_ ev: WidgetEvent) -> some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 3) {
RoundedRectangle(cornerRadius: 1)
.fill(Color(widgetHex: ev.colorHex))
.frame(width: 2)
Text(ev.title)
.font(.system(size: 9, weight: .medium))
.lineLimit(1)
Spacer(minLength: 0)
}
Text(WidgetTime.range(ev, lang: lang))
.font(.system(size: 8))
.foregroundStyle(.secondary)
.padding(.leading, 5)
}
.padding(.horizontal, 3)
}
}
@@ -0,0 +1,84 @@
import SwiftUI
import WidgetKit
struct TodayWidgetView: View {
let entry: CalendarrEntry
private var snapshot: WidgetSnapshot? { entry.snapshot }
private var todayEvents: [WidgetEvent] {
guard let s = snapshot else { return [] }
return WidgetHelpers.events(for: entry.date, in: s)
}
private var lang: String { snapshot?.language ?? "system" }
private var timeFmt: DateFormatter {
let f = DateFormatter()
f.locale = WidgetL10n.locale(lang)
f.dateFormat = "HH:mm"
return f
}
var body: some View {
let primary = Color(widgetHex: snapshot?.primaryColorHex ?? "#4285f4")
let accent = Color(widgetHex: snapshot?.accentColorHex ?? "#ea4335")
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(WidgetL10n.t("widget.today", lang))
.font(.caption.weight(.bold))
.foregroundStyle(primary)
Spacer()
Text(headerDate)
.font(.caption2)
.foregroundStyle(.secondary)
}
if snapshot == nil {
Text(WidgetL10n.t("widget.no_data", lang))
.font(.caption)
.foregroundStyle(.secondary)
} else if todayEvents.isEmpty {
Spacer()
Text(WidgetL10n.t("widget.no_events", lang))
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
} else {
ForEach(todayEvents.prefix(3)) { ev in
eventRow(ev)
}
if todayEvents.count > 3 {
Text(String(format: WidgetL10n.t("widget.more", lang), todayEvents.count - 3))
.font(.caption2)
.foregroundStyle(accent)
}
Spacer(minLength: 0)
}
}
}
private var headerDate: String {
let f = DateFormatter()
f.locale = WidgetL10n.locale(lang)
f.dateFormat = "d. MMM"
return f.string(from: entry.date)
}
private func eventRow(_ ev: WidgetEvent) -> some View {
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 2)
.fill(Color(widgetHex: ev.colorHex))
.frame(width: 3)
VStack(alignment: .leading, spacing: 1) {
Text(ev.title)
.font(.caption.weight(.medium))
.lineLimit(1)
Text(WidgetTime.range(ev, lang: lang))
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
}
}
@@ -0,0 +1,109 @@
import SwiftUI
import WidgetKit
struct TwoDaysWidgetView: View {
let entry: CalendarrEntry
private var snapshot: WidgetSnapshot? { entry.snapshot }
private var lang: String { snapshot?.language ?? "system" }
private var today: Date { Calendar.current.startOfDay(for: entry.date) }
private var tomorrow: Date {
Calendar.current.date(byAdding: .day, value: 1, to: today) ?? today
}
private var timeFmt: DateFormatter {
let f = DateFormatter()
f.locale = WidgetL10n.locale(lang)
f.dateFormat = "HH:mm"
return f
}
var body: some View {
if let s = snapshot {
let primary = Color(widgetHex: s.primaryColorHex)
let accent = Color(widgetHex: s.accentColorHex)
HStack(spacing: 8) {
column(for: today,
title: WidgetL10n.t("widget.today", lang),
isToday: true,
events: WidgetHelpers.events(for: today, in: s),
primary: primary, accent: accent,
lineColor: Color(widgetHex: s.lineColorHex))
Rectangle()
.fill(Color(widgetHex: s.lineColorHex).opacity(0.4))
.frame(width: 0.5)
column(for: tomorrow,
title: WidgetL10n.t("widget.tomorrow", lang),
isToday: false,
events: WidgetHelpers.events(for: tomorrow, in: s),
primary: primary, accent: accent,
lineColor: Color(widgetHex: s.lineColorHex))
}
} else {
Text(WidgetL10n.t("widget.no_data", lang))
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private func column(for day: Date,
title: String,
isToday: Bool,
events: [WidgetEvent],
primary: Color,
accent: Color,
lineColor: Color) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(title)
.font(.caption.weight(.bold))
.foregroundStyle(isToday ? primary : accent)
Text(shortDate(day))
.font(.caption2)
.foregroundStyle(.tertiary)
}
if events.isEmpty {
Text(WidgetL10n.t("widget.no_events", lang))
.font(.caption2)
.foregroundStyle(.secondary)
} else {
ForEach(events.prefix(4)) { ev in
eventRow(ev)
}
if events.count > 4 {
Text(String(format: WidgetL10n.t("widget.more", lang), events.count - 4))
.font(.system(size: 9))
.foregroundStyle(accent)
}
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .topLeading)
}
private func eventRow(_ ev: WidgetEvent) -> some View {
HStack(spacing: 4) {
RoundedRectangle(cornerRadius: 1.5)
.fill(Color(widgetHex: ev.colorHex))
.frame(width: 2)
VStack(alignment: .leading, spacing: 0) {
Text(ev.title)
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
Text(WidgetTime.range(ev, lang: lang))
.font(.system(size: 9))
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
}
private func shortDate(_ d: Date) -> String {
let f = DateFormatter()
f.locale = WidgetL10n.locale(lang)
f.dateFormat = "d. MMM"
return f.string(from: d)
}
}
@@ -0,0 +1,172 @@
import SwiftUI
import WidgetKit
struct TwoMonthWidgetView: View {
let entry: CalendarrEntry
@Environment(\.widgetFamily) private var family
private var snapshot: WidgetSnapshot? { entry.snapshot }
private var lang: String { snapshot?.language ?? "system" }
private var cal: Calendar {
var c = Calendar(identifier: .gregorian)
c.locale = WidgetL10n.locale(lang)
c.firstWeekday = 2
return c
}
private var thisMonth: Date {
cal.date(from: cal.dateComponents([.year, .month], from: entry.date)) ?? entry.date
}
private var nextMonth: Date {
cal.date(byAdding: .month, value: 1, to: thisMonth) ?? thisMonth
}
// Weekday header labels (M T W T F S S)
private var weekdayHeaders: [String] {
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang)
let symbols = f.veryShortWeekdaySymbols ?? cal.veryShortWeekdaySymbols
let start = cal.firstWeekday - 1
return (0..<7).map { String(symbols[(start + $0) % 7]).uppercased() }
}
// Number of date rows to show (5 for medium, 6 for large)
private var rowCount: Int { family == .systemLarge ? 6 : 5 }
var body: some View {
if let s = snapshot {
let primary = Color(widgetHex: s.primaryColorHex)
let accent = Color(widgetHex: s.accentColorHex)
let line = Color(widgetHex: s.lineColorHex)
HStack(alignment: .top, spacing: 0) {
monthColumn(monthDate: thisMonth, snapshot: s,
primary: primary, accent: accent, line: line)
.frame(maxWidth: .infinity)
line.opacity(0.35).frame(width: 0.5)
.padding(.horizontal, 3)
monthColumn(monthDate: nextMonth, snapshot: s,
primary: primary, accent: accent, line: line)
.frame(maxWidth: .infinity)
}
} else {
Text(WidgetL10n.t("widget.no_data", lang))
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
// MARK: – One month column
private func monthColumn(monthDate: Date,
snapshot: WidgetSnapshot,
primary: Color,
accent: Color,
line: Color) -> some View {
let monthFmt = DateFormatter()
monthFmt.locale = WidgetL10n.locale(lang)
monthFmt.dateFormat = "LLLL"
let name = monthFmt.string(from: monthDate).uppercased()
let start = gridStart(for: monthDate)
let wn = WidgetL10n.t("widget.cw", lang)
return VStack(alignment: .leading, spacing: 1) {
// Month name
Text(name)
.font(.system(size: 9, weight: .bold))
.foregroundStyle(primary)
// Column headers: KW + 7 weekdays
HStack(spacing: 0) {
Text(wn)
.font(.system(size: 6, weight: .bold))
.foregroundStyle(.secondary)
.frame(width: 14, alignment: .center)
ForEach(weekdayHeaders, id: \.self) { h in
Text(h)
.font(.system(size: 6.5, weight: .bold))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
}
}
// Date rows
ForEach(0..<rowCount, id: \.self) { row in
let rowStart = cal.date(byAdding: .day, value: row * 7, to: start)!
let weekNum = cal.component(.weekOfYear, from: rowStart)
let inMonth = cal.isDate(rowStart, equalTo: monthDate, toGranularity: .month)
|| cal.isDate(cal.date(byAdding: .day, value: 6, to: rowStart)!,
equalTo: monthDate, toGranularity: .month)
if inMonth {
HStack(spacing: 0) {
Text("\(weekNum)")
.font(.system(size: 6))
.foregroundStyle(.secondary.opacity(0.6))
.frame(width: 14, alignment: .center)
ForEach(0..<7, id: \.self) { col in
let day = cal.date(byAdding: .day, value: col, to: rowStart)!
dayCell(day, monthDate: monthDate, snapshot: snapshot,
primary: primary, accent: accent)
}
}
// Distribute weeks across the full column height instead of
// top-packing them (which left the lower portion empty).
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
}
// MARK: – Day cell
private func dayCell(_ day: Date,
monthDate: Date,
snapshot: WidgetSnapshot,
primary: Color,
accent: Color) -> some View {
let isToday = cal.isDateInToday(day)
let inMonth = cal.isDate(day, equalTo: monthDate, toGranularity: .month)
let evs = inMonth ? WidgetHelpers.events(for: day, in: snapshot) : []
let isWeekend = { () -> Bool in
let wd = cal.component(.weekday, from: day)
return wd == 1 || wd == 7
}()
return VStack(spacing: 1) {
ZStack {
if isToday { Circle().fill(primary) }
Text("\(cal.component(.day, from: day))")
.font(.system(size: 7.5, weight: isToday ? .bold : .medium))
.foregroundStyle(
isToday ? .white :
!inMonth ? Color.secondary.opacity(0.3) :
isWeekend ? Color.primary.opacity(0.5) :
Color.primary
)
}
.frame(maxWidth: .infinity)
.frame(height: 11)
// Event dots
HStack(spacing: 1) {
ForEach(evs.prefix(3).indices, id: \.self) { i in
Circle()
.fill(Color(widgetHex: evs[i].colorHex))
.frame(width: 2.5, height: 2.5)
}
}
.frame(height: 3)
}
.padding(.bottom, 1)
}
// MARK: – Grid helpers
private func gridStart(for monthDate: Date) -> Date {
let first = cal.date(from: cal.dateComponents([.year, .month], from: monthDate)) ?? monthDate
let weekday = cal.component(.weekday, from: first)
let offset = ((weekday - cal.firstWeekday) + 7) % 7
return cal.date(byAdding: .day, value: -offset, to: first) ?? first
}
}

Some files were not shown because too many files have changed in this diff Show More