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:
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>
|
||||||
+122
@@ -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>
|
||||||
+52
@@ -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 |
@@ -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>
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user