From e5529ca653950fbbce2086600050e90447554fa9 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 17 May 2026 08:32:34 +0200 Subject: [PATCH 01/90] Initial Commit --- Calendarr iOS.xcodeproj/project.pbxproj | 340 ++++++++++++ .../contents.xcworkspacedata | 7 + .../xcschemes/xcschememanagement.plist | 14 + .../AccentColor.colorset/Contents.json | 20 + .../AppIcon.appiconset/AppIcon-1024.png | Bin 0 -> 38025 bytes .../AppIcon.appiconset/Contents.json | 14 + Calendarr iOS/Assets.xcassets/Contents.json | 6 + Calendarr iOS/CalendarrApp.swift | 61 +++ Calendarr iOS/Models/AppSettings.swift | 165 ++++++ Calendarr iOS/Models/CalEvent.swift | 115 ++++ Calendarr iOS/Models/CalendarStore.swift | 248 +++++++++ Calendarr iOS/Services/CalendarrAPI.swift | 368 +++++++++++++ Calendarr iOS/Views/AccountsView.swift | 489 ++++++++++++++++++ Calendarr iOS/Views/Calendar/AgendaView.swift | 107 ++++ .../Views/Calendar/CalendarHostView.swift | 295 +++++++++++ Calendarr iOS/Views/Calendar/DayView.swift | 125 +++++ .../Views/Calendar/EventDetailSheet.swift | 143 +++++ .../Views/Calendar/EventEditorSheet.swift | 182 +++++++ Calendarr iOS/Views/Calendar/MonthView.swift | 150 ++++++ .../Views/Calendar/QuarterView.swift | 118 +++++ .../Views/Calendar/TimeGridView.swift | 45 ++ Calendarr iOS/Views/Calendar/WeekView.swift | 196 +++++++ Calendarr iOS/Views/LoginView.swift | 135 +++++ Calendarr iOS/Views/MainTabView.swift | 18 + Calendarr iOS/Views/MenuSheet.swift | 91 ++++ Calendarr iOS/Views/ProfileView.swift | 306 +++++++++++ Calendarr iOS/Views/RootView.swift | 15 + Calendarr iOS/Views/ServerSetupView.swift | 94 ++++ Calendarr iOS/Views/ServerView.swift | 168 ++++++ Calendarr iOS/Views/SettingsView.swift | 316 +++++++++++ 30 files changed, 4351 insertions(+) create mode 100644 Calendarr iOS.xcodeproj/project.pbxproj create mode 100644 Calendarr iOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist create mode 100644 Calendarr iOS/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Calendarr iOS/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png create mode 100644 Calendarr iOS/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Calendarr iOS/Assets.xcassets/Contents.json create mode 100644 Calendarr iOS/CalendarrApp.swift create mode 100644 Calendarr iOS/Models/AppSettings.swift create mode 100644 Calendarr iOS/Models/CalEvent.swift create mode 100644 Calendarr iOS/Models/CalendarStore.swift create mode 100644 Calendarr iOS/Services/CalendarrAPI.swift create mode 100644 Calendarr iOS/Views/AccountsView.swift create mode 100644 Calendarr iOS/Views/Calendar/AgendaView.swift create mode 100644 Calendarr iOS/Views/Calendar/CalendarHostView.swift create mode 100644 Calendarr iOS/Views/Calendar/DayView.swift create mode 100644 Calendarr iOS/Views/Calendar/EventDetailSheet.swift create mode 100644 Calendarr iOS/Views/Calendar/EventEditorSheet.swift create mode 100644 Calendarr iOS/Views/Calendar/MonthView.swift create mode 100644 Calendarr iOS/Views/Calendar/QuarterView.swift create mode 100644 Calendarr iOS/Views/Calendar/TimeGridView.swift create mode 100644 Calendarr iOS/Views/Calendar/WeekView.swift create mode 100644 Calendarr iOS/Views/LoginView.swift create mode 100644 Calendarr iOS/Views/MainTabView.swift create mode 100644 Calendarr iOS/Views/MenuSheet.swift create mode 100644 Calendarr iOS/Views/ProfileView.swift create mode 100644 Calendarr iOS/Views/RootView.swift create mode 100644 Calendarr iOS/Views/ServerSetupView.swift create mode 100644 Calendarr iOS/Views/ServerView.swift create mode 100644 Calendarr iOS/Views/SettingsView.swift diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj new file mode 100644 index 0000000..dfdf95f --- /dev/null +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -0,0 +1,340 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + C0000B01FC4E10100AB5001 /* Calendarr iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Calendarr iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + C0000D01FC4E10100AB5001 /* Calendarr iOS */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "Calendarr iOS"; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + C0000801FB4E10100AB5001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + C0000201FB4E10100AB5001 = { + isa = PBXGroup; + children = ( + C0000D01FC4E10100AB5001 /* Calendarr iOS */, + C0000C01FB4E10100AB5001 /* Products */, + ); + sourceTree = ""; + }; + C0000C01FB4E10100AB5001 /* Products */ = { + isa = PBXGroup; + children = ( + C0000B01FC4E10100AB5001 /* Calendarr iOS.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + C0000A01FB4E10100AB5001 /* Calendarr iOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = C0001601FB4E10100AB5001 /* Build configuration list for PBXNativeTarget "Calendarr iOS" */; + buildPhases = ( + C0000701FB4E10100AB5001 /* Sources */, + C0000801FB4E10100AB5001 /* Frameworks */, + C0000901FB4E10100AB5001 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + C0000D01FC4E10100AB5001 /* Calendarr iOS */, + ); + name = "Calendarr iOS"; + packageProductDependencies = ( + ); + productName = "Calendarr iOS"; + productReference = C0000B01FC4E10100AB5001 /* Calendarr iOS.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + C0000301FB4E10100AB5001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2640; + LastUpgradeCheck = 2640; + TargetAttributes = { + C0000A01FB4E10100AB5001 = { + CreatedOnToolsVersion = 26.4.1; + }; + }; + }; + buildConfigurationList = C0000601FB4E10100AB5001 /* Build configuration list for PBXProject "Calendarr iOS" */; + developmentRegion = de; + hasScannedForEncodings = 0; + knownRegions = ( + de, + en, + Base, + ); + mainGroup = C0000201FB4E10100AB5001; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = C0000C01FB4E10100AB5001 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + C0000A01FB4E10100AB5001 /* Calendarr iOS */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + C0000901FB4E10100AB5001 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + C0000701FB4E10100AB5001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + C0001401FB4E10100AB5001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + C0001501FB4E10100AB5001 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + TARGETED_DEVICE_FAMILY = 1; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + C0001701FB4E10100AB5001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Calendarr; + INFOPLIST_KEY_CFBundleName = Calendarr; + INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices"; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; + PRODUCT_NAME = "Calendarr iOS"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + C0001801FB4E10100AB5001 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Calendarr; + INFOPLIST_KEY_CFBundleName = Calendarr; + INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices"; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; + PRODUCT_NAME = "Calendarr iOS"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + C0000601FB4E10100AB5001 /* Build configuration list for PBXProject "Calendarr iOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C0001401FB4E10100AB5001 /* Debug */, + C0001501FB4E10100AB5001 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C0001601FB4E10100AB5001 /* Build configuration list for PBXNativeTarget "Calendarr iOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C0001701FB4E10100AB5001 /* Debug */, + C0001801FB4E10100AB5001 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = C0000301FB4E10100AB5001 /* Project object */; +} diff --git a/Calendarr iOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Calendarr iOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Calendarr iOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist b/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..9c5c084 --- /dev/null +++ b/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + Calendarr iOS.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/Calendarr iOS/Assets.xcassets/AccentColor.colorset/Contents.json b/Calendarr iOS/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..43b8a78 --- /dev/null +++ b/Calendarr iOS/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.957", + "green" : "0.522", + "red" : "0.259" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Calendarr iOS/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/Calendarr iOS/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..9e7a0977cc783b33877ed18da631af36d21943ed GIT binary patch literal 38025 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 literal 0 HcmV?d00001 diff --git a/Calendarr iOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/Calendarr iOS/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..f22e10c --- /dev/null +++ b/Calendarr iOS/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Calendarr iOS/Assets.xcassets/Contents.json b/Calendarr iOS/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Calendarr iOS/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Calendarr iOS/CalendarrApp.swift b/Calendarr iOS/CalendarrApp.swift new file mode 100644 index 0000000..e8c9a6b --- /dev/null +++ b/Calendarr iOS/CalendarrApp.swift @@ -0,0 +1,61 @@ +import SwiftUI + +@main +struct CalendarrApp: App { + @State private var appState = AppState() + + var body: some Scene { + WindowGroup { + RootView() + .environment(appState) + } + } +} + +@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 = UserDefaults.standard.string(forKey: "authToken") ?? "" + username = UserDefaults.standard.string(forKey: "username") ?? "" + isAdmin = UserDefaults.standard.bool(forKey: "isAdmin") + } + + 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 + UserDefaults.standard.set(token, forKey: "authToken") + UserDefaults.standard.set(user, forKey: "username") + UserDefaults.standard.set(admin, forKey: "isAdmin") + } + + func logout() { + authToken = "" + username = "" + isAdmin = false + UserDefaults.standard.removeObject(forKey: "authToken") + UserDefaults.standard.removeObject(forKey: "username") + UserDefaults.standard.removeObject(forKey: "isAdmin") + } + + func resetServer() { + logout() + serverURL = "" + UserDefaults.standard.removeObject(forKey: "serverURL") + } +} diff --git a/Calendarr iOS/Models/AppSettings.swift b/Calendarr iOS/Models/AppSettings.swift new file mode 100644 index 0000000..2f82346 --- /dev/null +++ b/Calendarr iOS/Models/AppSettings.swift @@ -0,0 +1,165 @@ +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" + + 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" + } +} + +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 + + enum CodingKeys: String, CodingKey { + case id, name, color, enabled + case sidebarHidden = "sidebar_hidden" + } +} + +struct LocalCalendar: Codable, Identifiable { + let id: Int + var name: String + var color: String + var enabled: Bool +} + +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? + + enum CodingKeys: String, CodingKey { + case id, name, url, color, enabled + case refreshMinutes = "refresh_minutes" + case lastFetched = "last_fetched" + } +} + +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 + + enum CodingKeys: String, CodingKey { + case id, name, color, enabled + case sidebarHidden = "sidebar_hidden" + } +} + +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 + + enum CodingKeys: String, CodingKey { + case id, name, color, enabled + case entityId = "entity_id" + } +} + +struct UserProfile: Codable { + let id: Int + let username: String + var email: String? + let isAdmin: Bool + let hasAvatar: Bool + let totpEnabled: Bool + + enum CodingKeys: String, CodingKey { + case id, username, email + case isAdmin = "is_admin" + case hasAvatar = "has_avatar" + case totpEnabled = "totp_enabled" + } +} + +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)) + } +} diff --git a/Calendarr iOS/Models/CalEvent.swift b/Calendarr iOS/Models/CalEvent.swift new file mode 100644 index 0000000..195e522 --- /dev/null +++ b/Calendarr iOS/Models/CalEvent.swift @@ -0,0 +1,115 @@ +import Foundation +import SwiftUI + +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 effectiveColor: String { color ?? calendarColor } + + 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 } + + let isAllDay = json["allDay"] as? Bool ?? false + let startDate = parseDate(startStr, allDay: isAllDay) + let endDate = parseDate(endStr, allDay: isAllDay) + guard let s = startDate, let e = endDate else { return nil } + + 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" + ) + } +} + +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) +} diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift new file mode 100644 index 0000000..9dfde14 --- /dev/null +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -0,0 +1,248 @@ +import Foundation +import SwiftUI + +enum CalViewType: String, CaseIterable { + case month, week, day, quarter, agenda + + var label: String { + switch self { + case .month: return "Monat" + case .week: return "Woche" + case .day: return "Tag" + case .quarter: return "Quartal" + case .agenda: return "Termine" + } + } + + 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 + var isLoading = false + var isCachingBackground = false + var lastError: String? = nil + var weekStartsOnMonday = true + var writableCalendars: [WritableCalendar] = [] + + // Cache bookkeeping + private var cachedStart: Date? = nil + private var cachedEnd: Date? = nil + private var allCachedEvents: [CalEvent] = [] + + 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 + } + + /// Fast in-memory refresh of `events` for the current visible range. + /// Call this after navigation without hitting the network. + func refreshFromCache(start: Date, end: Date) { + events = allCachedEvents.filter { ev in + ev.startDate < end && ev.endDate > start + } + } + + // MARK: – Network loading + + /// Load events for a specific range – skips network if already cached. + func loadEvents(api: CalendarrAPI, start: Date, end: Date) async { + if isCached(start: start, end: end) { + refreshFromCache(start: start, end: end) + return + } + isLoading = true + lastError = nil + defer { isLoading = false } + do { + let fetched = try await api.fetchEvents(start: start, end: end) + mergeIntoCache(fetched, rangeStart: start, rangeEnd: end) + refreshFromCache(start: start, end: end) + } catch { + lastError = error.localizedDescription + } + } + + /// 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 = try await api.fetchEvents(start: start, end: end) + mergeIntoCache(fetched, rangeStart: start, rangeEnd: end) + // 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). + func invalidateCache() { + cachedStart = nil + cachedEnd = nil + allCachedEvents = [] + events = [] + } + + private func mergeIntoCache(_ newEvents: [CalEvent], rangeStart: Date, rangeEnd: Date) { + // Remove old events that overlap with the newly fetched range (avoid duplicates) + let retained = allCachedEvents.filter { ev in + ev.startDate >= rangeEnd || ev.endDate <= rangeStart + } + allCachedEvents = retained + newEvents + + // Extend cached range + if let cs = cachedStart, let ce = cachedEnd { + cachedStart = min(cs, rangeStart) + cachedEnd = max(ce, rangeEnd) + } else { + cachedStart = rangeStart + cachedEnd = rangeEnd + } + } + + // 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] = [] + for cal in await localCals { + 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() -> String { + let cal = userCalendar + let fmt = DateFormatter() + switch viewType { + case .month: + fmt.dateFormat = "MMMM yyyy" + return fmt.string(from: currentDate) + case .quarter: + fmt.dateFormat = "MMM yyyy" + let m3 = cal.date(byAdding: .month, value: 2, to: currentDate) ?? currentDate + return "\(fmt.string(from: currentDate)) – \(fmt.string(from: m3))" + case .week: + let weekStart = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: currentDate))! + let weekEnd = cal.date(byAdding: .day, value: 6, to: weekStart)! + fmt.dateFormat = "d. MMM" + let ef = DateFormatter(); ef.dateFormat = "d. MMM yyyy" + return "\(fmt.string(from: weekStart)) – \(ef.string(from: weekEnd))" + case .day: + fmt.dateFormat = "EEEE, d. MMMM yyyy" + return fmt.string(from: currentDate) + case .agenda: + return "Termine" + } + } +} diff --git a/Calendarr iOS/Services/CalendarrAPI.swift b/Calendarr iOS/Services/CalendarrAPI.swift new file mode 100644 index 0000000..b123f9e --- /dev/null +++ b/Calendarr iOS/Services/CalendarrAPI.swift @@ -0,0 +1,368 @@ +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 + 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) async throws -> LocalCalendar { + let data = try await request("/api/local/calendars", method: "POST", body: ["name": name, "color": color]) + 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") + } + + 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 + + func fetchEvents(start: Date, end: Date) async throws -> [CalEvent] { + // 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)") + } + return arr.compactMap { CalEvent.from(json: $0) } + } + + func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date, + isAllDay: Bool, location: String, description: String, color: String?) 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 + ] + if let c = color, !c.isEmpty { body["color"] = c } + 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?) 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 } + _ = 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) + } +} diff --git a/Calendarr iOS/Views/AccountsView.swift b/Calendarr iOS/Views/AccountsView.swift new file mode 100644 index 0000000..874b05d --- /dev/null +++ b/Calendarr iOS/Views/AccountsView.swift @@ -0,0 +1,489 @@ +import SwiftUI + +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? + + var body: some View { + NavigationStack { + Group { + if isLoading { + ProgressView("Lade Konten…") + } else { + List { + caldavSection + localSection + icalSection + googleSection + haSection + } + } + } + .navigationTitle("Konten") + .navigationBarTitleDisplayMode(.large) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Menu { + Button("CalDAV-Konto") { showAddCalDAV = true } + Button("Lokaler Kalender") { showAddLocal = true } + Button("iCal-URL abonnieren") { showAddICal = true } + Button("Home Assistant") { showAddHA = true } + } 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("Fehler", isPresented: .constant(errorAlert != nil), actions: { + Button("OK") { errorAlert = nil } + }, message: { + Text(errorAlert ?? "") + }) + } + .task { await load() } + } + + // MARK: – Sections + + var caldavSection: some View { + Section { + if caldavAccounts.isEmpty { + Text("Keine CalDAV-Konten") + .foregroundStyle(.secondary) + } else { + ForEach(caldavAccounts) { acc in + 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) + } + } + } + .onDelete { offsets in + Task { await deleteCalDAV(offsets: offsets) } + } + } + Button("CalDAV hinzufügen") { showAddCalDAV = true } + .foregroundStyle(Color.accentColor) + } header: { + Text("CalDAV-Konten") + } + } + + var localSection: some View { + Section { + if localCalendars.isEmpty { + Text("Keine lokalen Kalender") + .foregroundStyle(.secondary) + } else { + ForEach(localCalendars) { cal in + HStack { + Circle() + .fill(Color(hex: cal.color)) + .frame(width: 12, height: 12) + Text(cal.name) + } + } + .onDelete { offsets in + Task { await deleteLocal(offsets: offsets) } + } + } + Button("Lokalen Kalender erstellen") { showAddLocal = true } + .foregroundStyle(Color.accentColor) + } header: { + Text("Lokale Kalender") + } + } + + var icalSection: some View { + Section { + if icalSubs.isEmpty { + Text("Keine Abonnements") + .foregroundStyle(.secondary) + } else { + ForEach(icalSubs) { sub in + HStack { + Circle() + .fill(Color(hex: sub.color)) + .frame(width: 12, height: 12) + VStack(alignment: .leading, spacing: 2) { + Text(sub.name).font(.body) + Text("Alle \(sub.refreshMinutes) Min.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .onDelete { offsets in + Task { await deleteICal(offsets: offsets) } + } + } + Button("iCal-URL abonnieren") { showAddICal = true } + .foregroundStyle(Color.accentColor) + } header: { + Text("iCal-Abonnements") + } + } + + var googleSection: some View { + Section { + if googleAccounts.isEmpty { + Text("Keine Google-Konten") + .foregroundStyle(.secondary) + } else { + ForEach(googleAccounts) { acc in + HStack { + Image(systemName: "g.circle.fill") + .foregroundStyle(.red) + Text(acc.email) + } + } + .onDelete { offsets in + Task { await deleteGoogle(offsets: offsets) } + } + } + Text("Google-Konten werden über den Browser verknüpft") + .font(.caption) + .foregroundStyle(.secondary) + } header: { + Text("Google-Konten") + } + } + + var haSection: some View { + Section { + if haAccounts.isEmpty { + Text("Keine Home Assistant-Konten") + .foregroundStyle(.secondary) + } else { + ForEach(haAccounts) { acc in + VStack(alignment: .leading, spacing: 2) { + Text(acc.name).font(.body) + Text(acc.url) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .onDelete { offsets in + Task { await deleteHA(offsets: offsets) } + } + } + Button("Home Assistant hinzufügen") { showAddHA = true } + .foregroundStyle(Color.accentColor) + } header: { + Text("Home Assistant") + } + } + + // MARK: – Actions + + private func load() async { + isLoading = true + 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) + 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 + + @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("Konto-Details") { + TextField("Anzeigename", text: $name) + TextField("CalDAV-URL", text: $url) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + TextField("Benutzername", text: $username) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + SecureField("Passwort", text: $password) + } + Section("Farbe") { + ColorPicker("Farbe", selection: $color, supportsOpacity: false) + } + if !error.isEmpty { + Section { + Text(error).foregroundStyle(.red) + } + } + } + .navigationTitle("CalDAV-Konto") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { dismiss() } + } + ToolbarItem(placement: .primaryAction) { + Button("Verbinden") { + 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 + + @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("Name", text: $name) + ColorPicker("Farbe", selection: $color, supportsOpacity: false) + } + if !error.isEmpty { + Section { Text(error).foregroundStyle(.red) } + } + } + .navigationTitle("Lokaler Kalender") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } } + ToolbarItem(placement: .primaryAction) { + Button("Erstellen") { + 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 + } +} + +struct AddICalSheet: View { + let api: CalendarrAPI + let onDone: () async -> Void + @Environment(\.dismiss) var dismiss + + @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 = "" + + let refreshOptions = [(15, "Alle 15 Min."), (30, "Alle 30 Min."), (60, "Stündlich"), (360, "Alle 6 Std."), (1440, "Täglich")] + + var body: some View { + NavigationStack { + Form { + Section("Abonnement") { + TextField("Name", text: $name) + TextField("iCal-URL", text: $url) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + ColorPicker("Farbe", selection: $color, supportsOpacity: false) + } + Section("Aktualisierung") { + Picker("Intervall", selection: $refreshMinutes) { + ForEach(refreshOptions, id: \.0) { opt in + Text(opt.1).tag(opt.0) + } + } + } + if !error.isEmpty { + Section { Text(error).foregroundStyle(.red) } + } + } + .navigationTitle("iCal abonnieren") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } } + ToolbarItem(placement: .primaryAction) { + Button("Abonnieren") { + 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 + + @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("Home Assistant") { + TextField("Anzeigename", text: $name) + TextField("URL (z.B. http://homeassistant.local:8123)", text: $url) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + } + Section("Authentifizierung") { + SecureField("Long-Lived Access Token", text: $token) + Text("Token erstellen unter: Profil → Sicherheit → Long-Lived Access Tokens") + .font(.caption) + .foregroundStyle(.secondary) + } + if !error.isEmpty { + Section { Text(error).foregroundStyle(.red) } + } + } + .navigationTitle("Home Assistant") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } } + ToolbarItem(placement: .primaryAction) { + Button("Verbinden") { + 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 + } +} diff --git a/Calendarr iOS/Views/Calendar/AgendaView.swift b/Calendarr iOS/Views/Calendar/AgendaView.swift new file mode 100644 index 0000000..5230bfb --- /dev/null +++ b/Calendarr iOS/Views/Calendar/AgendaView.swift @@ -0,0 +1,107 @@ +import SwiftUI + +struct AgendaView: View { + let store: CalendarStore + let onEventTap: (CalEvent) -> Void + + 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 let dayFmt: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "EEEE, d. MMMM yyyy" + return f + }() + + private let timeFmt: DateFormatter = { + let f = DateFormatter() + f.timeStyle = .short + f.dateStyle = .none + return f + }() + + var body: some View { + if grouped.isEmpty { + ContentUnavailableView( + "Keine Termine", + systemImage: "calendar", + description: Text("In den nächsten 90 Tagen sind keine Termine vorhanden.") + ) + } else { + List { + ForEach(grouped, id: \.0) { day, evs in + Section { + ForEach(evs) { ev in + Button { onEventTap(ev) } label: { + AgendaEventRow(event: ev, timeFmt: timeFmt) + } + .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 + + var timeString: String { + if event.isAllDay { return "Ganztägig" } + 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) { + Text(event.title) + .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) + } +} diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift new file mode 100644 index 0000000..f91ddff --- /dev/null +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -0,0 +1,295 @@ +import SwiftUI + +struct CalendarHostView: View { + let api: CalendarrAPI + @Binding var showMenu: Bool + + @AppStorage("liquidGlass") private var liquidGlass = false + @AppStorage("cacheMonths") private var cacheMonths = 3 + + @State private var store = CalendarStore() + @State private var showEditor = false + @State private var editorDate: Date = .now + @State private var editingEvent: CalEvent? = nil + @State private var selectedEvent: CalEvent? = nil + + var body: some View { + if liquidGlass { + glassVariant + } else { + flatVariant + } + } + + // MARK: – Flat variant + + private var flatVariant: some View { + VStack(spacing: 0) { + topBar + Divider() + errorBanner + calendarContent + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(alignment: .top) { + if store.isLoading { + ProgressView().padding(.top, 10).transition(.opacity) + } + } + } + .overlay(alignment: .bottomTrailing) { solidFAB } + // Subtle background cache indicator (top-leading) + .overlay(alignment: .topLeading) { + if store.isCachingBackground { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.caption2) + .foregroundStyle(.secondary) + .padding(6) + .transition(.opacity) + } + } + .modifier(calendarSheets) + .task { await startup() } + .onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } } + .onChange(of: store.viewType) { _, _ in Task { await onNavigate() } } + .onChange(of: cacheMonths) { _, _ in Task { await recache() } } + } + + // MARK: – Liquid Glass variant + + private var glassVariant: some View { + NavigationStack { + calendarContent + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(alignment: .top) { + if store.isLoading { + ProgressView().padding(.top, 10).transition(.opacity) + } + } + .overlay(alignment: .top) { + if let err = store.lastError { errorBannerView(err).padding(.top, 8) } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + HStack(spacing: 2) { + Button { store.navigatePrev() } label: { Image(systemName: "chevron.left") } + Button { store.navigateNext() } label: { Image(systemName: "chevron.right") } + Button("Heute") { store.moveToToday() }.font(.callout) + } + } + ToolbarItem(placement: .principal) { viewPickerMenu } + ToolbarItem(placement: .navigationBarTrailing) { + Button { showMenu = true } label: { Image(systemName: "line.3.horizontal") } + } + } + } + .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() } } + } + + // MARK: – Top bar (flat mode) + + private var topBar: some View { + HStack(spacing: 0) { + 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) + } + Button("Heute") { store.moveToToday() } + .font(.callout).padding(.horizontal, 6) + } + .padding(.leading, 8) + Spacer() + viewPickerMenu + Spacer() + Button { showMenu = true } label: { + Image(systemName: "line.3.horizontal") + .font(.system(size: 18, weight: .medium)) + .frame(width: 44, height: 44) + } + .padding(.trailing, 4) + } + .frame(height: 48) + .background(.bar) + } + + private var viewPickerMenu: some View { + Menu { + ForEach(CalViewType.allCases, id: \.self) { vt in + Button { store.viewType = vt } label: { + Label(vt.label, systemImage: vt.systemImage) + } + } + } label: { + HStack(spacing: 4) { + Text(store.viewType.label).font(.headline) + Image(systemName: "chevron.down").font(.caption2.weight(.semibold)) + } + .foregroundStyle(.primary) + .padding(.horizontal, 12).padding(.vertical, 7) + .background(.quaternary, in: Capsule()) + } + } + + // MARK: – Error banner + + @ViewBuilder private var errorBanner: some View { + if let err = store.lastError { errorBannerView(err) } + } + + 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)) + } + + // MARK: – Calendar content (with swipe) + + @ViewBuilder + private var calendarContent: some View { + let swipe = DragGesture(minimumDistance: 35, coordinateSpace: .global) + .onEnded { val in + let h = val.translation.width + let v = val.translation.height + guard abs(h) > abs(v) * 1.1, abs(h) > 50 else { return } + withAnimation(.easeInOut(duration: 0.2)) { + if h < 0 { store.navigateNext() } else { store.navigatePrev() } + } + } + switch store.viewType { + case .month: + MonthView(store: store, onDayTap: { editorDate = $0 }, onEventTap: { selectedEvent = $0 }) + .simultaneousGesture(swipe) + case .week: + WeekView(store: store, onEventTap: { selectedEvent = $0 }, onTimeTap: { editorDate = $0 }) + .simultaneousGesture(swipe) + case .day: + DayView(store: store, onEventTap: { selectedEvent = $0 }, onTimeTap: { editorDate = $0 }) + .simultaneousGesture(swipe) + case .quarter: + QuarterView(store: store, onEventTap: { selectedEvent = $0 }) + .simultaneousGesture(swipe) + case .agenda: + AgendaView(store: store, onEventTap: { selectedEvent = $0 }) + } + } + + // MARK: – FAB buttons + + /// Standard solid FAB (flat mode) + private var solidFAB: some View { + Button { + editingEvent = nil; editorDate = .now; showEditor = true + } 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) + } + .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 { + editingEvent = nil; editorDate = .now; showEditor = true + } label: { + Image(systemName: "plus") + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(.primary) + .frame(width: 56, height: 56) + } + .buttonStyle(.plain) + .glassEffect(in: Circle()) + .padding(.trailing, 20).padding(.bottom, 20) + } else { + solidFAB + } + } + + // MARK: – Sheets modifier + + private var calendarSheets: CalendarSheets { + CalendarSheets(store: store, showEditor: $showEditor, + editorDate: $editorDate, editingEvent: $editingEvent, + selectedEvent: $selectedEvent, api: api, + reload: { await onNavigate() }) + } + + // MARK: – Loading logic + + private func startup() async { + await store.loadWritableCalendars(api: api) + // 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) + } + } + + /// 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) + } + + /// Called when cacheMonths setting changes – clear cache and re-prefetch. + private func recache() async { + store.invalidateCache() + await startup() + } +} + +// MARK: – Shared sheet modifier + +private struct CalendarSheets: ViewModifier { + let store: CalendarStore + @Binding var showEditor: Bool + @Binding var editorDate: Date + @Binding var editingEvent: CalEvent? + @Binding var selectedEvent: CalEvent? + let api: CalendarrAPI + let reload: () async -> Void + + func body(content: Content) -> some View { + content + .sheet(isPresented: $showEditor) { + EventEditorSheet(api: api, store: store, + initialDate: editorDate, editingEvent: editingEvent) { + editingEvent = nil; await reload() + } + } + .sheet(item: $selectedEvent) { ev in + EventDetailSheet(event: ev, api: api, store: store) { updated in + selectedEvent = nil + if let u = updated { editingEvent = u; showEditor = true } + await reload() + } + } + } +} diff --git a/Calendarr iOS/Views/Calendar/DayView.swift b/Calendarr iOS/Views/Calendar/DayView.swift new file mode 100644 index 0000000..630dbe1 --- /dev/null +++ b/Calendarr iOS/Views/Calendar/DayView.swift @@ -0,0 +1,125 @@ +import SwiftUI + +struct DayView: View { + let store: CalendarStore + let onEventTap: (CalEvent) -> Void + let onTimeTap: (Date) -> Void + + private var cal: Calendar { store.userCalendar } + private var allDayEvents: [CalEvent] { store.events(on: store.currentDate).filter(\.isAllDay) } + private var timedEvents: [CalEvent] { store.events(on: store.currentDate).filter { !$0.isAllDay } } + + var body: some View { + VStack(spacing: 0) { + if !allDayEvents.isEmpty { allDayStrip } + + GeometryReader { geo in + ScrollViewReader { proxy in + ScrollView { + ZStack(alignment: .topLeading) { + // Background grid + HStack(alignment: .top, spacing: 0) { + timeLabels + VStack(spacing: 0) { + ForEach(hours, id: \.self) { _ in + Rectangle() + .fill(Color(.separator).opacity(0.4)) + .frame(height: 0.5) + Color.clear.frame(height: hourHeight - 0.5) + } + } + .frame(width: geo.size.width - timeColumnWidth) + .contentShape(Rectangle()) + .onTapGesture { loc in + let h = Int(loc.y / hourHeight) + let m = Int((loc.y.truncatingRemainder(dividingBy: hourHeight)) / hourHeight * 60) + let date = cal.date(bySettingHour: h, minute: m, second: 0, of: store.currentDate) ?? store.currentDate + onTimeTap(date) + } + } + + // Events + 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() + HStack(spacing: 0) { + Spacer().frame(width: timeColumnWidth - 4) + Circle().fill(Color.red).frame(width: 8, height: 8) + Rectangle().fill(Color.red) + .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) }) { + Text(ev.title) + .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(.secondary) + .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)) + } + } + } +} diff --git a/Calendarr iOS/Views/Calendar/EventDetailSheet.swift b/Calendarr iOS/Views/Calendar/EventDetailSheet.swift new file mode 100644 index 0000000..b057b1f --- /dev/null +++ b/Calendarr iOS/Views/Calendar/EventDetailSheet.swift @@ -0,0 +1,143 @@ +import SwiftUI + +struct EventDetailSheet: View { + let event: CalEvent + let api: CalendarrAPI + let store: CalendarStore + let onDone: (CalEvent?) async -> Void + + @Environment(\.dismiss) var dismiss + @State private var showDeleteConfirm = false + @State private var isDeleting = 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 "Ganztägig · \(dateFmt.string(from: event.startDate))" + } + let end = Calendar.current.date(byAdding: .day, value: -1, to: event.endDate) ?? event.endDate + return "Ganztägig · \(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" + } + + 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.title) + .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("Kalender", systemImage: "calendar") + Spacer() + Text(event.calendarName) + .foregroundStyle(.secondary) + } + HStack { + Label("Quelle", systemImage: "server.rack") + Spacer() + Text(event.source.capitalized) + .foregroundStyle(.secondary) + } + } + + if canEdit { + Section { + Button(role: .destructive) { + showDeleteConfirm = true + } label: { + Label("Termin löschen", systemImage: "trash") + .foregroundStyle(.red) + } + .disabled(isDeleting) + } + } + } + .listStyle(.insetGrouped) + .navigationTitle("Termin") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Schliessen") { + Task { await onDone(nil) } + } + } + if canEdit { + ToolbarItem(placement: .primaryAction) { + Button("Bearbeiten") { + Task { await onDone(event) } + } + } + } + } + .confirmationDialog("Termin löschen?", isPresented: $showDeleteConfirm, titleVisibility: .visible) { + Button("Löschen", role: .destructive) { + Task { await deleteEvent() } + } + Button("Abbrechen", role: .cancel) {} + } message: { + Text("\"\(event.title)\" wird dauerhaft gelöscht.") + } + } + } + + private func deleteEvent() async { + isDeleting = true + do { + if event.source == "local" { + try await api.deleteLocalEvent(uid: event.id) + } else { + let calId = Int(event.calendarId) + try await api.deleteCalDAVEvent(uid: event.id, url: event.url, calendarId: calId) + } + await onDone(nil) + } catch { + isDeleting = false + } + } +} diff --git a/Calendarr iOS/Views/Calendar/EventEditorSheet.swift b/Calendarr iOS/Views/Calendar/EventEditorSheet.swift new file mode 100644 index 0000000..c3b8117 --- /dev/null +++ b/Calendarr iOS/Views/Calendar/EventEditorSheet.swift @@ -0,0 +1,182 @@ +import SwiftUI + +struct EventEditorSheet: View { + let api: CalendarrAPI + let store: CalendarStore + let initialDate: Date + let editingEvent: CalEvent? + let onSaved: () async -> Void + + @Environment(\.dismiss) var dismiss + @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 isSaving = false + @State private var error = "" + + private var isEditing: Bool { editingEvent != nil } + + private var selectedCal: WritableCalendar? { + store.writableCalendars.first { $0.id == selectedCalendarId } + } + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Titel", text: $title) + .font(.body.weight(.medium)) + } + + Section { + Toggle("Ganztägig", isOn: $isAllDay.animation()) + .tint(Color.accentColor) + + if isAllDay { + DatePicker("Start", selection: $startDate, displayedComponents: .date) + DatePicker("Ende", selection: $endDate, displayedComponents: .date) + } else { + DatePicker("Start", selection: $startDate) + DatePicker("Ende", selection: $endDate) + } + } + + Section { + TextField("Ort", text: $location) + TextField("Beschreibung", text: $notes, axis: .vertical) + .lineLimit(3...6) + } + + Section("Kalender") { + if store.writableCalendars.isEmpty { + Text("Keine beschreibbaren Kalender vorhanden") + .foregroundStyle(.secondary) + .font(.callout) + } else { + Picker("Kalender", 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) + } + } + } + } + + Section("Farbe") { + HStack { + Text("Terminfarbe") + Spacer() + ColorPicker("", selection: Binding( + get: { Color(hex: color.isEmpty ? (selectedCal?.color ?? "#4285f4") : color) }, + set: { color = $0.toHex() } + ), supportsOpacity: false) + .labelsHidden() + if !color.isEmpty { + Button("Zurücksetzen") { color = "" } + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + if !error.isEmpty { + Section { + Text(error).foregroundStyle(.red) + } + } + } + .navigationTitle(isEditing ? "Termin bearbeiten" : "Neuer Termin") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Abbrechen") { dismiss() } + } + ToolbarItem(placement: .primaryAction) { + Button(isEditing ? "Sichern" : "Hinzufügen") { + Task { await save() } + } + .bold() + .disabled(title.isEmpty || selectedCalendarId.isEmpty || isSaving) + } + } + } + .onAppear { setup() } + } + + private func setup() { + if let ev = editingEvent { + title = ev.title + isAllDay = ev.isAllDay + startDate = ev.startDate + endDate = ev.endDate + location = ev.location + notes = ev.notes + color = ev.color ?? "" + selectedCalendarId = ev.calendarId + } else { + let cal = Calendar.current + startDate = cal.date(bySettingHour: cal.component(.hour, from: initialDate), + minute: 0, second: 0, of: initialDate) ?? initialDate + endDate = startDate.addingTimeInterval(3600) + selectedCalendarId = store.writableCalendars.first?.id ?? "" + } + } + + 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 { + if ev.source == "local" { + try await api.updateLocalEvent(uid: ev.id, title: title, start: start, end: end, + isAllDay: isAllDay, location: location, description: notes, color: colorVal) + } else { + 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) + 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 + } + } +} diff --git a/Calendarr iOS/Views/Calendar/MonthView.swift b/Calendarr iOS/Views/Calendar/MonthView.swift new file mode 100644 index 0000000..4b49812 --- /dev/null +++ b/Calendarr iOS/Views/Calendar/MonthView.swift @@ -0,0 +1,150 @@ +import SwiftUI + +struct MonthView: View { + let store: CalendarStore + let onDayTap: (Date) -> Void + let onEventTap: (CalEvent) -> Void + + private var cal: Calendar { store.userCalendar } + + private var monthStart: Date { + cal.date(from: cal.dateComponents([.year, .month], from: store.currentDate))! + } + + private var gridDays: [Date] { + let firstWeekday = cal.firstWeekday + let weekday = cal.component(.weekday, from: monthStart) + let offset = ((weekday - firstWeekday) + 7) % 7 + let gridStart = cal.date(byAdding: .day, value: -offset, to: monthStart)! + return (0..<42).compactMap { cal.date(byAdding: .day, value: $0, to: gridStart) } + } + + private var rowCount: Int { gridDays.count / 7 } // always 6 + + private var weekdayHeaders: [String] { + let symbols = cal.shortWeekdaySymbols + let start = cal.firstWeekday - 1 + return (0..<7).map { String(symbols[(start + $0) % 7].prefix(2)) } + } + + var body: some View { + VStack(spacing: 0) { + // Day-of-week header row (fixed height) + HStack(spacing: 0) { + ForEach(weekdayHeaders, id: \.self) { d in + Text(d) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 28) + } + } + Divider() + + // Grid fills all remaining space using GeometryReader + GeometryReader { geo in + let rowH = geo.size.height / CGFloat(rowCount) + VStack(spacing: 0) { + ForEach(0.. Void + let onEventTap: (CalEvent) -> Void + + private var maxVisible: Int { + max(1, Int((rowHeight - 32) / 16)) + } + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + // Day number + Button(action: onTap) { + Text("\(Calendar.current.component(.day, from: date))") + .font(.system(size: 13, weight: isToday ? .bold : .regular)) + .foregroundStyle( + isToday ? Color.white : + isCurrentMonth ? Color.primary : Color.secondary.opacity(0.4) + ) + .frame(width: 26, height: 26) + .background(isToday ? Color.accentColor : Color.clear) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .padding(.leading, 4) + .padding(.top, 2) + + // Events + ForEach(events.prefix(maxVisible)) { ev in + Button { onEventTap(ev) } label: { + EventChip(event: ev) + } + .buttonStyle(.plain) + } + + if events.count > maxVisible { + Text("+\(events.count - maxVisible)") + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(.secondary) + .padding(.leading, 4) + } + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .overlay(alignment: .trailing) { + Rectangle().fill(Color(.separator)).frame(width: 0.5) + } + .overlay(alignment: .bottom) { + Rectangle().fill(Color(.separator)).frame(height: 0.5) + } + } +} + +private struct EventChip: View { + let event: CalEvent + + var body: some View { + HStack(spacing: 3) { + if !event.isAllDay { + Circle() + .fill(Color(hex: event.effectiveColor)) + .frame(width: 6, height: 6) + } + Text(event.title) + .font(.system(size: 10, weight: .medium)) + .lineLimit(1) + .foregroundStyle(event.isAllDay ? .white : .primary) + } + .padding(.horizontal, event.isAllDay ? 4 : 2) + .padding(.vertical, 1) + .frame(maxWidth: .infinity, alignment: .leading) + .background(event.isAllDay ? Color(hex: event.effectiveColor) : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 3)) + .padding(.horizontal, 2) + } +} diff --git a/Calendarr iOS/Views/Calendar/QuarterView.swift b/Calendarr iOS/Views/Calendar/QuarterView.swift new file mode 100644 index 0000000..735caf3 --- /dev/null +++ b/Calendarr iOS/Views/Calendar/QuarterView.swift @@ -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) + } +} diff --git a/Calendarr iOS/Views/Calendar/TimeGridView.swift b/Calendarr iOS/Views/Calendar/TimeGridView.swift new file mode 100644 index 0000000..f183fba --- /dev/null +++ b/Calendarr iOS/Views/Calendar/TimeGridView.swift @@ -0,0 +1,45 @@ +import SwiftUI + +// Shared constants used by WeekView, DayView, EventEditorSheet +let hourHeight: CGFloat = 60 +let timeColumnWidth: CGFloat = 44 +let hours = Array(0..<24) + +// 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 + + var body: some View { + RoundedRectangle(cornerRadius: 4) + .fill(Color(hex: event.effectiveColor).opacity(0.85)) + .overlay(alignment: .topLeading) { + VStack(alignment: .leading, spacing: 1) { + Text(event.title) + .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) + } +} diff --git a/Calendarr iOS/Views/Calendar/WeekView.swift b/Calendarr iOS/Views/Calendar/WeekView.swift new file mode 100644 index 0000000..613f4b7 --- /dev/null +++ b/Calendarr iOS/Views/Calendar/WeekView.swift @@ -0,0 +1,196 @@ +import SwiftUI + +struct WeekView: View { + let store: CalendarStore + let onEventTap: (CalEvent) -> Void + let onTimeTap: (Date) -> Void + + 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 }.map { (idx, $0) } + } + } + + private var allDayEvents: [CalEvent] { + let s = weekDays.first ?? .now + let e = cal.date(byAdding: .day, value: 1, to: weekDays.last ?? .now)! + return store.events(in: s, end: e).filter(\.isAllDay) + } + + 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.accentColor : .secondary) + .frame(maxWidth: .infinity, minHeight: 36) + .overlay(alignment: .trailing) { + Rectangle().fill(Color(.separator)).frame(width: 0.5) + } + } + } + } + + // MARK: – All-day strip + + private var allDayRow: some View { + HStack(spacing: 0) { + Spacer().frame(width: timeColumnWidth) + ForEach(weekDays, id: \.self) { day in + let dayEvs = allDayEvents.filter { ev in + let ds = cal.startOfDay(for: day) + let de = cal.date(byAdding: .day, value: 1, to: ds)! + return ev.startDate < de && ev.endDate > ds + } + VStack(spacing: 1) { + ForEach(dayEvs.prefix(2)) { ev in + Button { onEventTap(ev) } label: { + Text(ev.title) + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(.white) + .lineLimit(1) + .frame(maxWidth: .infinity) + .padding(.vertical, 2) + .background(Color(hex: ev.effectiveColor)) + .clipShape(RoundedRectangle(cornerRadius: 2)) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 1) + .frame(maxWidth: .infinity) + .overlay(alignment: .trailing) { + Rectangle().fill(Color(.separator)).frame(width: 0.5) + } + } + } + .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 + vertical grid lines + HStack(alignment: .top, spacing: 0) { + timeLabels + ForEach(Array(weekDays.enumerated()), id: \.offset) { _, day in + VStack(spacing: 0) { + ForEach(hours, id: \.self) { _ in + Rectangle() + .fill(Color(.separator).opacity(0.4)) + .frame(height: 0.5) + Color.clear.frame(height: hourHeight - 0.5) + } + } + .frame(width: colW) + .contentShape(Rectangle()) + .onTapGesture { loc in + let h = Int(loc.y / hourHeight) + let m = Int((loc.y.truncatingRemainder(dividingBy: hourHeight)) / hourHeight * 60) + let date = cal.date(bySettingHour: h, minute: m, second: 0, of: day) ?? day + onTimeTap(date) + } + .overlay(alignment: .trailing) { + Rectangle().fill(Color(.separator)).frame(width: 0.5) + } + } + } + + // 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) + HStack(spacing: 0) { + Spacer().frame(width: timeColumnWidth + CGFloat(ti) * colW - 4) + Circle().fill(Color.red).frame(width: 8, height: 8) + Rectangle().fill(Color.red).frame(width: colW - 4, height: 1.5) + } + .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(.secondary) + .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 +} diff --git a/Calendarr iOS/Views/LoginView.swift b/Calendarr iOS/Views/LoginView.swift new file mode 100644 index 0000000..f7f8509 --- /dev/null +++ b/Calendarr iOS/Views/LoginView.swift @@ -0,0 +1,135 @@ +import SwiftUI + +struct LoginView: View { + @Environment(AppState.self) var appState + @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 + + 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("Benutzername") + .font(.footnote.weight(.medium)) + .foregroundStyle(.secondary) + TextField("Benutzername", text: $username) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .padding(12) + .background(.quaternary) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + + VStack(alignment: .leading, spacing: 6) { + Text("Passwort") + .font(.footnote.weight(.medium)) + .foregroundStyle(.secondary) + SecureField("Passwort", text: $password) + .padding(12) + .background(.quaternary) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + + if needsTOTP { + VStack(alignment: .leading, spacing: 6) { + Text("2FA-Code") + .font(.footnote.weight(.medium)) + .foregroundStyle(.secondary) + TextField("6-stelliger Code", text: $totpCode) + .keyboardType(.numberPad) + .padding(12) + .background(.quaternary) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .transition(.move(edge: .top).combined(with: .opacity)) + } + + Toggle("Angemeldet bleiben", 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("Anmelden").fontWeight(.semibold) + } + } + .frame(maxWidth: .infinity) + .padding(14) + .background(Color.accentColor) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .disabled(username.isEmpty || password.isEmpty || isLoading) + } + .padding(.horizontal, 32) + .animation(.easeInOut, value: needsTOTP) + + Spacer().frame(height: 40) + + Button("Anderen Server wählen") { + appState.resetServer() + } + .font(.footnote) + .foregroundStyle(.secondary) + } + } + .navigationBarHidden(true) + } + } + + 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 + } + } +} diff --git a/Calendarr iOS/Views/MainTabView.swift b/Calendarr iOS/Views/MainTabView.swift new file mode 100644 index 0000000..8e840f2 --- /dev/null +++ b/Calendarr iOS/Views/MainTabView.swift @@ -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) + } + } +} diff --git a/Calendarr iOS/Views/MenuSheet.swift b/Calendarr iOS/Views/MenuSheet.swift new file mode 100644 index 0000000..3187db1 --- /dev/null +++ b/Calendarr iOS/Views/MenuSheet.swift @@ -0,0 +1,91 @@ +import SwiftUI + +struct MenuSheet: View { + let api: CalendarrAPI + @Environment(AppState.self) var appState + @Environment(\.dismiss) var dismiss + + 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("Admin") + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 8).padding(.vertical, 3) + .background(Color.accentColor.opacity(0.15)) + .foregroundStyle(Color.accentColor) + .clipShape(Capsule()) + } + } + .padding(.vertical, 4) + } + + // Navigation links – direct destination syntax (no value-based nav) + Section("Einstellungen") { + NavigationLink { + ProfileView(api: api) + } label: { + Label("Profil", systemImage: "person.circle") + } + + NavigationLink { + SettingsView(api: api) + } label: { + Label("Darstellung", systemImage: "paintpalette") + } + + NavigationLink { + AccountsView(api: api) + } label: { + Label("Konten & Kalender", systemImage: "tray.2") + } + + NavigationLink { + ServerView() + } label: { + Label("Server", systemImage: "server.rack") + } + } + + Section { + Button(role: .destructive) { + dismiss() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + appState.logout() + } + } label: { + Label("Abmelden", systemImage: "rectangle.portrait.and.arrow.right") + } + } + } + .navigationTitle("Menü") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("Fertig") { dismiss() } + } + } + } + } +} diff --git a/Calendarr iOS/Views/ProfileView.swift b/Calendarr iOS/Views/ProfileView.swift new file mode 100644 index 0000000..f6aa5ad --- /dev/null +++ b/Calendarr iOS/Views/ProfileView.swift @@ -0,0 +1,306 @@ +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 + + var body: some View { + NavigationStack { + Group { + if isLoading { + ProgressView("Lade Profil…") + } else if let profile { + Form { + kontoSection(profile: profile) + passwordSection + twoFASection(profile: profile) + } + } + } + .navigationTitle("Profil") + .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("Konto") { + HStack { + Text("Benutzername") + Spacer() + Text(profile.username) + .foregroundStyle(.secondary) + } + HStack { + Text("Rolle") + Spacer() + Text(profile.isAdmin ? "Administrator" : "Benutzer") + .foregroundStyle(.secondary) + } + HStack { + Text("E-Mail") + Spacer() + TextField("Keine E-Mail", text: $newEmail) + .multilineTextAlignment(.trailing) + .foregroundStyle(.secondary) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + } + Button("E-Mail speichern") { + Task { await saveEmail() } + } + .foregroundStyle(Color.accentColor) + } + } + + var passwordSection: some View { + Section("Passwort ändern") { + SecureField("Aktuelles Passwort", text: $currentPW) + SecureField("Neues Passwort", text: $newPW) + SecureField("Neues Passwort wiederholen", text: $confirmPW) + Button("Passwort ändern") { + Task { await changePassword() } + } + .foregroundStyle(Color.accentColor) + .disabled(currentPW.isEmpty || newPW.isEmpty || confirmPW.isEmpty) + } + } + + func twoFASection(profile: UserProfile) -> some View { + Section("Zwei-Faktor-Authentifizierung") { + if profile.totpEnabled { + HStack { + Image(systemName: "checkmark.shield.fill") + .foregroundStyle(.green) + Text("2FA ist aktiviert") + } + Button("2FA deaktivieren") { + show2FADisable = true + } + .foregroundStyle(.red) + } else { + HStack { + Image(systemName: "shield") + .foregroundStyle(.secondary) + Text("2FA ist deaktiviert") + .foregroundStyle(.secondary) + } + Button("2FA einrichten") { + Task { await setup2FA() } + } + .foregroundStyle(Color.accentColor) + } + } + } + + 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("E-Mail gespeichert") + } catch { + showNotice(error.localizedDescription) + } + } + + private func changePassword() async { + guard newPW == confirmPW else { + showNotice("Passwörter stimmen nicht überein") + return + } + do { + try await api.changePassword(current: currentPW, new: newPW) + currentPW = ""; newPW = ""; confirmPW = "" + showNotice("Passwort geändert") + } 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("2FA aktiviert") + await load() + } catch { + showNotice(error.localizedDescription) + } + isSaving2FA = false + } + + private func disable2FA() async { + do { + try await api.disable2FA(password: disablePW) + show2FADisable = false + showNotice("2FA deaktiviert") + 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 + + var body: some View { + NavigationStack { + Form { + Section { + Text("Scanne den QR-Code mit deiner Authenticator-App (z.B. Bitwarden, Google Authenticator).") + .font(.body) + } + Section("QR-Code / Manueller Schlüssel") { + 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("Bestätigung") { + TextField("6-stelliger Code", text: $code) + .keyboardType(.numberPad) + } + } + .navigationTitle("2FA einrichten") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } } + ToolbarItem(placement: .primaryAction) { + Button("Aktivieren") { onEnable() } + .bold() + .disabled(code.count < 6 || isSaving) + } + } + } + } +} + +struct TwoFADisableSheet: View { + @Binding var password: String + let onDisable: () -> Void + @Environment(\.dismiss) var dismiss + + var body: some View { + NavigationStack { + Form { + Section("Passwort zum Deaktivieren") { + SecureField("Passwort", text: $password) + } + } + .navigationTitle("2FA deaktivieren") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } } + ToolbarItem(placement: .primaryAction) { + Button("Deaktivieren") { onDisable() } + .bold() + .foregroundStyle(.red) + .disabled(password.isEmpty) + } + } + } + } +} diff --git a/Calendarr iOS/Views/RootView.swift b/Calendarr iOS/Views/RootView.swift new file mode 100644 index 0000000..e3649c7 --- /dev/null +++ b/Calendarr iOS/Views/RootView.swift @@ -0,0 +1,15 @@ +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() + } + } +} diff --git a/Calendarr iOS/Views/ServerSetupView.swift b/Calendarr iOS/Views/ServerSetupView.swift new file mode 100644 index 0000000..9918266 --- /dev/null +++ b/Calendarr iOS/Views/ServerSetupView.swift @@ -0,0 +1,94 @@ +import SwiftUI + +struct ServerSetupView: View { + @Environment(AppState.self) var appState + @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("Server verbinden") + .font(.subheadline) + .foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: 8) { + Text("Server-URL") + .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("Verbinden") + .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 = "Server nicht erreichbar. URL prüfen." + } + } +} diff --git a/Calendarr iOS/Views/ServerView.swift b/Calendarr iOS/Views/ServerView.swift new file mode 100644 index 0000000..33a5bc5 --- /dev/null +++ b/Calendarr iOS/Views/ServerView.swift @@ -0,0 +1,168 @@ +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 + + var serverHost: String { + appState.serverURL + .replacingOccurrences(of: "https://", with: "") + .replacingOccurrences(of: "http://", with: "") + } + + var body: some View { + NavigationStack { + Form { + Section("Verbundener Server") { + 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("Admin") + .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("Server wechseln") + } + } + .foregroundStyle(.primary) + + Button(role: .destructive) { + showLogoutConfirm = true + } label: { + HStack { + Image(systemName: "rectangle.portrait.and.arrow.right") + .frame(width: 28) + Text("Abmelden") + } + } + } + + Section("Info") { + Button { + showImpressum = true + } label: { + HStack { + Image(systemName: "info.circle") + .frame(width: 28) + Text("Impressum") + } + } + .foregroundStyle(.secondary) + + HStack { + Text("Version") + Spacer() + Text("1.0") + .foregroundStyle(.secondary) + } + } + } + .navigationTitle("Server") + .navigationBarTitleDisplayMode(.large) + .confirmationDialog("Abmelden", isPresented: $showLogoutConfirm, titleVisibility: .visible) { + Button("Abmelden", role: .destructive) { + appState.logout() + } + Button("Abbrechen", role: .cancel) {} + } message: { + Text("Du wirst von \(serverHost) abgemeldet.") + } + .confirmationDialog("Server wechseln", isPresented: $showChangeServer, titleVisibility: .visible) { + Button("Server wechseln", role: .destructive) { + appState.resetServer() + } + Button("Abbrechen", role: .cancel) {} + } message: { + Text("Verbindung zu \(serverHost) wird getrennt und alle lokalen Anmeldedaten werden gelöscht.") + } + .sheet(isPresented: $showImpressum) { + ImpressumView() + } + } + } +} + +struct ImpressumView: View { + @Environment(\.dismiss) var dismiss + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Group { + Text("Scarriffleservices") + .font(.title2.bold()) + Text("Software & Webentwicklung") + .foregroundStyle(.secondary) + } + + Divider() + + Text("Diese Software wurde von Scarriffleservices mit grösster Sorgfalt entwickelt und bereitgestellt. Alle Rechte vorbehalten © 2026 Scarriffleservices.") + + VStack(alignment: .leading, spacing: 6) { + Text("Datenspeicherung").font(.headline) + Text("Alle Anwendungsdaten werden auf dem Server gespeichert und verarbeitet, auf dem diese Calendarr-Instanz betrieben wird. Der Speicherort hängt damit vom Betreiber des jeweiligen Servers ab. Bei Nutzung der Google Kalender-Anbindung werden Daten über die Google API ausgetauscht; für diese Daten gelten die Datenschutzbestimmungen von Google. Bei Nutzung der Home Assistant-Anbindung werden Daten mit der jeweiligen Home Assistant-Instanz ausgetauscht. Home Assistant ist ein Projekt der Open Home Foundation.") + } + + VStack(alignment: .leading, spacing: 6) { + Text("Haftungsausschluss").font(.headline) + Text("Trotz sorgfältiger Erstellung wird keine Haftung für die Richtigkeit, Vollständigkeit oder Aktualität der bereitgestellten Inhalte übernommen. Die Nutzung erfolgt auf eigene Verantwortung.") + } + + VStack(alignment: .leading, spacing: 6) { + Text("Kontakt").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("Impressum") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("Schliessen") { dismiss() } + } + } + } + } +} diff --git a/Calendarr iOS/Views/SettingsView.swift b/Calendarr iOS/Views/SettingsView.swift new file mode 100644 index 0000000..646e4de --- /dev/null +++ b/Calendarr iOS/Views/SettingsView.swift @@ -0,0 +1,316 @@ +import SwiftUI + +struct SettingsView: View { + let api: CalendarrAPI + @State private var settings = AppSettings() + @State private var isLoading = true + @State private var isSaving = false + @State private var toast = "" + @State private var showToast = false + @AppStorage("liquidGlass") private var liquidGlass = false + @AppStorage("cacheMonths") private var cacheMonths = 3 + + var body: some View { + NavigationStack { + Group { + if isLoading { + ProgressView("Lade Einstellungen…") + } else { + Form { + liquidGlassSection + cacheSection + spracheSection + farbenSection + schriftSection + linienSection + ansichtSection + stundenSection + } + } + } + .navigationTitle("Darstellung") + .navigationBarTitleDisplayMode(.large) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + Task { await save() } + } label: { + if isSaving { + ProgressView() + } else { + Text("Speichern").bold() + } + } + .disabled(isSaving) + } + } + .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) + } + .task { await load() } + } + + // MARK: – Liquid Glass + + var liquidGlassSection: some View { + Section { + Toggle(isOn: $liquidGlass) { + Label { + VStack(alignment: .leading, spacing: 2) { + Text("Liquid Glass") + Text("Verwendet die neue iOS\u{202F}26 Glasoptik mit transparenter Navigationsleiste") + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "sparkles") + .foregroundStyle(.blue) + } + } + .tint(Color.accentColor) + } header: { + Text("App-Design") + } footer: { + Text("Änderung wirkt sofort – kein Neustart nötig.") + .font(.caption) + } + } + + // MARK: – Cache + + var cacheSection: some View { + Section { + VStack(alignment: .leading, spacing: 10) { + Label { + VStack(alignment: .leading, spacing: 2) { + Text("Vorladen") + Text("Events werden beim Start im Hintergrund für diesen Zeitraum geladen, danach ist Wischen sofort.") + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "arrow.down.circle") + .foregroundStyle(.green) + } + + Picker("Zeitraum", selection: $cacheMonths) { + Text("±1 Monat").tag(1) + Text("±3 Monate").tag(3) + Text("±6 Monate").tag(6) + Text("±1 Jahr").tag(12) + } + .pickerStyle(.segmented) + } + .padding(.vertical, 4) + } header: { + Text("Vorladen") + } footer: { + Text("Mehr Monate = längerer initialer Ladevorgang, danach komplett ohne Wartezeiten navigierbar.") + .font(.caption) + } + } + + // MARK: – Sprache + + var spracheSection: some View { + Section("Sprache") { + Picker("Sprache", selection: $settings.language) { + Text("Deutsch").tag("de") + Text("English").tag("en") + } + } + } + + // MARK: – Farben + + var farbenSection: some View { + Section("Farben") { + ColorPickerRow(label: "Primärfarbe", hex: $settings.primaryColor) + ColorPickerRow(label: "Akzentfarbe", hex: $settings.accentColor) + ColorPickerRow(label: "Heutige-Tag-Farbe", hex: $settings.todayColor) + ColorPickerRow(label: "Monatswechsel-Linie", hex: $settings.monthDividerColor) + ColorPickerRow(label: "Monatskürzel", hex: $settings.monthLabelColor) + } + } + + // MARK: – Schriftkontrast + + var schriftSection: some View { + Section { + VStack(alignment: .leading, spacing: 10) { + Text("Schriftkontrast") + .font(.headline) + Text("Helligkeit der Beschriftungen und Texte") + .font(.caption) + .foregroundStyle(.secondary) + ContrastSelector( + value: $settings.textContrast, + options: [ + (1, "Dunkel"), + (2, "Mittel"), + (3, "Hell"), + (4, "Maximum") + ] + ) + } + .padding(.vertical, 4) + } + } + + // MARK: – Linienkontrast + + var linienSection: some View { + Section { + VStack(alignment: .leading, spacing: 10) { + Text("Linienkontrast") + .font(.headline) + Text("Sichtbarkeit von Trennlinien und Rahmen") + .font(.caption) + .foregroundStyle(.secondary) + ContrastSelector( + value: $settings.lineContrast, + options: [ + (1, "Kaum"), + (2, "Subtil"), + (3, "Normal"), + (4, "Stark") + ] + ) + } + .padding(.vertical, 4) + } + } + + // MARK: – Ansicht + + var ansichtSection: some View { + Section("Kalenderansicht") { + Picker("Standardansicht", selection: $settings.defaultView) { + Text("Monat").tag("month") + Text("Woche").tag("week") + Text("Tag").tag("day") + Text("Quartal").tag("quarter") + Text("Termine").tag("agenda") + } + Picker("Erster Wochentag", selection: $settings.weekStartDay) { + Text("Montag").tag("monday") + Text("Sonntag").tag("sunday") + } + Toggle("Vergangene Termine ausgrauen", isOn: $settings.dimPastEvents) + .tint(Color.accentColor) + } + } + + // MARK: – Stundenhöhe + + var stundenSection: some View { + Section { + VStack(alignment: .leading, spacing: 10) { + Text("Stundenhöhe") + .font(.headline) + Text("Platz pro Stunde in der Wochen- & Tagesansicht") + .font(.caption) + .foregroundStyle(.secondary) + ContrastSelector( + value: $settings.hourHeight, + options: [ + (28, "Kompakt"), + (44, "Normal"), + (60, "Komfort"), + (80, "Gross") + ] + ) + } + .padding(.vertical, 4) + } + } + + // MARK: – Actions + + private func load() async { + isLoading = true + defer { isLoading = false } + if let s = try? await api.getSettings() { settings = s } + } + + private func save() async { + isSaving = true + defer { isSaving = false } + do { + try await api.updateSettings(settings) + showNotice("Gespeichert") + } catch { + showNotice(error.localizedDescription) + } + } + + private func showNotice(_ msg: String) { + toast = msg + withAnimation { showToast = true } + Task { + try? await Task.sleep(for: .seconds(2)) + withAnimation { showToast = false } + } + } +} + +// MARK: – Reusable Components + +struct ColorPickerRow: View { + let label: String + @Binding var hex: String + + var color: Binding { + Binding( + get: { Color(hex: hex) }, + set: { hex = $0.toHex() } + ) + } + + var body: some View { + HStack { + Text(label) + Spacer() + ColorPicker("", selection: color, supportsOpacity: false) + .labelsHidden() + Text(hex.uppercased()) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 68, alignment: .trailing) + } + } +} + +struct ContrastSelector: View { + @Binding var value: T + let options: [(T, String)] + + var body: some View { + HStack(spacing: 8) { + ForEach(Array(options.enumerated()), id: \.offset) { _, opt in + Button { + value = opt.0 + } label: { + Text(opt.1) + .font(.caption.weight(.medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(value == opt.0 ? Color.accentColor : Color(.systemGray5)) + .foregroundStyle(value == opt.0 ? .white : .primary) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + } + } + } +} From 8b3cc11e257785585ada923a48703ccdf704eea9 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 19 May 2026 22:00:49 +0200 Subject: [PATCH 02/90] Add localization (DE/EN), vertical-scroll month view, context menus, custom colors - Vertical-scroll month view with multi-day event spans, zig-zag month divider, CW number per week, on-demand event loading while scrolling - Top bar redesign: icon-only view picker on right, month title centered - Long-press context menus on day cells (month) and hour slots (week/day) for "New event", "Open in week view", "Open in day view", "Open in month view" - Localization system with system/de/en switch covering top bar, view picker, settings, menu, profile, server, accounts, event editor, agenda - Three new color pickers (text/background/line) + today-marker color applied in calendar views; current-time line now uses today color - App icon: removed alpha channel, accent color set to icon green (#20A050) - TestFlight: ITSAppUsesNonExemptEncryption=NO baked into Info.plist keys Co-Authored-By: Claude Opus 4.7 --- Calendarr iOS.xcodeproj/project.pbxproj | 162 +++++- .../xcschemes/Calendarr iOS.xcscheme | 92 ++++ .../xcschemes/xcschememanagement.plist | 13 + .../AccentColor.colorset/Contents.json | 6 +- .../AppIcon.appiconset/AppIcon-1024.png | Bin 38025 -> 22168 bytes Calendarr iOS/Models/AppSettings.swift | 6 + Calendarr iOS/Models/CalendarStore.swift | 27 +- Calendarr iOS/Models/Localization.swift | 520 ++++++++++++++++++ Calendarr iOS/Views/AccountsView.swift | 128 +++-- Calendarr iOS/Views/Calendar/AgendaView.swift | 20 +- .../Views/Calendar/CalendarHostView.swift | 115 +++- Calendarr iOS/Views/Calendar/DayView.swift | 61 +- .../Views/Calendar/EventEditorSheet.swift | 39 +- Calendarr iOS/Views/Calendar/MonthView.swift | 428 ++++++++++---- Calendarr iOS/Views/Calendar/WeekView.swift | 79 ++- Calendarr iOS/Views/MenuSheet.swift | 20 +- Calendarr iOS/Views/ProfileView.swift | 78 +-- Calendarr iOS/Views/ServerView.swift | 54 +- Calendarr iOS/Views/SettingsView.swift | 145 +++-- Calendarr iOSTests/Calendarr_iOSTests.swift | 18 + 20 files changed, 1623 insertions(+), 388 deletions(-) create mode 100644 Calendarr iOS.xcodeproj/xcshareddata/xcschemes/Calendarr iOS.xcscheme create mode 100644 Calendarr iOS/Models/Localization.swift create mode 100644 Calendarr iOSTests/Calendarr_iOSTests.swift diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index dfdf95f..35473f6 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -6,11 +6,27 @@ objectVersion = 77; objects = { +/* Begin PBXContainerItemProxy section */ + 3927C7C02FB99D0F00EAD8ED /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = C0000301FB4E10100AB5001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = C0000A01FB4E10100AB5001; + remoteInfo = "Calendarr iOS"; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ + 3927C7BC2FB99D0E00EAD8ED /* Calendarr iOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Calendarr iOSTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; C0000B01FC4E10100AB5001 /* Calendarr iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Calendarr iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + 3927C7BD2FB99D0F00EAD8ED /* Calendarr iOSTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "Calendarr iOSTests"; + sourceTree = ""; + }; C0000D01FC4E10100AB5001 /* Calendarr iOS */ = { isa = PBXFileSystemSynchronizedRootGroup; path = "Calendarr iOS"; @@ -19,6 +35,13 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 3927C7B92FB99D0E00EAD8ED /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; C0000801FB4E10100AB5001 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -33,6 +56,7 @@ isa = PBXGroup; children = ( C0000D01FC4E10100AB5001 /* Calendarr iOS */, + 3927C7BD2FB99D0F00EAD8ED /* Calendarr iOSTests */, C0000C01FB4E10100AB5001 /* Products */, ); sourceTree = ""; @@ -41,6 +65,7 @@ isa = PBXGroup; children = ( C0000B01FC4E10100AB5001 /* Calendarr iOS.app */, + 3927C7BC2FB99D0E00EAD8ED /* Calendarr iOSTests.xctest */, ); name = Products; sourceTree = ""; @@ -48,6 +73,29 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 3927C7BB2FB99D0E00EAD8ED /* Calendarr iOSTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3927C7C22FB99D0F00EAD8ED /* Build configuration list for PBXNativeTarget "Calendarr iOSTests" */; + buildPhases = ( + 3927C7B82FB99D0E00EAD8ED /* Sources */, + 3927C7B92FB99D0E00EAD8ED /* Frameworks */, + 3927C7BA2FB99D0E00EAD8ED /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 3927C7C12FB99D0F00EAD8ED /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 3927C7BD2FB99D0F00EAD8ED /* Calendarr iOSTests */, + ); + name = "Calendarr iOSTests"; + packageProductDependencies = ( + ); + productName = "Calendarr iOSTests"; + productReference = 3927C7BC2FB99D0E00EAD8ED /* Calendarr iOSTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; C0000A01FB4E10100AB5001 /* Calendarr iOS */ = { isa = PBXNativeTarget; buildConfigurationList = C0001601FB4E10100AB5001 /* Build configuration list for PBXNativeTarget "Calendarr iOS" */; @@ -77,9 +125,13 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 2640; + LastSwiftUpdateCheck = 2650; LastUpgradeCheck = 2640; TargetAttributes = { + 3927C7BB2FB99D0E00EAD8ED = { + CreatedOnToolsVersion = 26.5; + TestTargetID = C0000A01FB4E10100AB5001; + }; C0000A01FB4E10100AB5001 = { CreatedOnToolsVersion = 26.4.1; }; @@ -101,11 +153,19 @@ projectRoot = ""; targets = ( C0000A01FB4E10100AB5001 /* Calendarr iOS */, + 3927C7BB2FB99D0E00EAD8ED /* Calendarr iOSTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 3927C7BA2FB99D0E00EAD8ED /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; C0000901FB4E10100AB5001 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -116,6 +176,13 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 3927C7B82FB99D0E00EAD8ED /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; C0000701FB4E10100AB5001 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -125,7 +192,67 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 3927C7C12FB99D0F00EAD8ED /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C0000A01FB4E10100AB5001 /* Calendarr iOS */; + targetProxy = 3927C7C02FB99D0F00EAD8ED /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + 3927C7C32FB99D0F00EAD8ED /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = PP34X97WS3; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + MACOSX_DEPLOYMENT_TARGET = 26.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.local.scarriffle.Calendarr-iOSTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Calendarr iOS.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Calendarr iOS"; + XROS_DEPLOYMENT_TARGET = 26.5; + }; + name = Debug; + }; + 3927C7C42FB99D0F00EAD8ED /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = PP34X97WS3; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + MACOSX_DEPLOYMENT_TARGET = 26.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.local.scarriffle.Calendarr-iOSTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Calendarr iOS.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Calendarr iOS"; + XROS_DEPLOYMENT_TARGET = 26.5; + }; + name = Release; + }; C0001401FB4E10100AB5001 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -254,16 +381,19 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = PP34X97WS3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = Calendarr; INFOPLIST_KEY_CFBundleName = Calendarr; + "INFOPLIST_KEY_ITSAppUsesNonExemptEncryption" = NO; INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -272,11 +402,16 @@ PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; @@ -287,16 +422,19 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = PP34X97WS3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = Calendarr; INFOPLIST_KEY_CFBundleName = Calendarr; + "INFOPLIST_KEY_ITSAppUsesNonExemptEncryption" = NO; INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -305,17 +443,31 @@ PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 3927C7C22FB99D0F00EAD8ED /* Build configuration list for PBXNativeTarget "Calendarr iOSTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3927C7C32FB99D0F00EAD8ED /* Debug */, + 3927C7C42FB99D0F00EAD8ED /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; C0000601FB4E10100AB5001 /* Build configuration list for PBXProject "Calendarr iOS" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Calendarr iOS.xcodeproj/xcshareddata/xcschemes/Calendarr iOS.xcscheme b/Calendarr iOS.xcodeproj/xcshareddata/xcschemes/Calendarr iOS.xcscheme new file mode 100644 index 0000000..ee8a7c7 --- /dev/null +++ b/Calendarr iOS.xcodeproj/xcshareddata/xcschemes/Calendarr iOS.xcscheme @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist b/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist index 9c5c084..0190ba4 100644 --- a/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist @@ -10,5 +10,18 @@ 0 + SuppressBuildableAutocreation + + 3927C7BB2FB99D0E00EAD8ED + + primary + + + C0000A01FB4E10100AB5001 + + primary + + + diff --git a/Calendarr iOS/Assets.xcassets/AccentColor.colorset/Contents.json b/Calendarr iOS/Assets.xcassets/AccentColor.colorset/Contents.json index 43b8a78..70d4dc3 100644 --- a/Calendarr iOS/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/Calendarr iOS/Assets.xcassets/AccentColor.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.957", - "green" : "0.522", - "red" : "0.259" + "blue" : "0.314", + "green" : "0.627", + "red" : "0.125" } }, "idiom" : "universal" diff --git a/Calendarr iOS/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/Calendarr iOS/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png index 9e7a0977cc783b33877ed18da631af36d21943ed..a88fa5b32c2e7115677de60876983f7c8fb4d3cc 100644 GIT binary patch literal 22168 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 diff --git a/Calendarr iOS/Models/AppSettings.swift b/Calendarr iOS/Models/AppSettings.swift index 2f82346..86f0f37 100644 --- a/Calendarr iOS/Models/AppSettings.swift +++ b/Calendarr iOS/Models/AppSettings.swift @@ -13,6 +13,9 @@ struct AppSettings: Codable { var language: String = "de" var monthDividerColor: String = "#7090c0" var monthLabelColor: String = "#7090c0" + var textColor: String = "#FFFFFF" + var backgroundColor: String = "#000000" + var lineColor: String = "#3A3A3C" enum CodingKeys: String, CodingKey { case defaultView = "default_view" @@ -27,6 +30,9 @@ struct AppSettings: Codable { case language case monthDividerColor = "month_divider_color" case monthLabelColor = "month_label_color" + case textColor = "text_color" + case backgroundColor = "background_color" + case lineColor = "line_color" } } diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index 9dfde14..c3ebf80 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -4,13 +4,13 @@ import SwiftUI enum CalViewType: String, CaseIterable { case month, week, day, quarter, agenda - var label: String { + func label(_ lang: String) -> String { switch self { - case .month: return "Monat" - case .week: return "Woche" - case .day: return "Tag" - case .quarter: return "Quartal" - case .agenda: return "Termine" + case .month: return L10n.t("view.month", lang) + case .week: return L10n.t("view.week", lang) + case .day: return L10n.t("view.day", lang) + case .quarter: return L10n.t("view.quarter", lang) + case .agenda: return L10n.t("view.agenda", lang) } } @@ -221,28 +221,29 @@ class CalendarStore { } } - func titleForCurrentView() -> String { + func titleForCurrentView(language: String) -> String { let cal = userCalendar - let fmt = DateFormatter() + let loc = L10n.locale(language) + let fmt = DateFormatter(); fmt.locale = loc switch viewType { case .month: - fmt.dateFormat = "MMMM yyyy" - return fmt.string(from: currentDate) + fmt.dateFormat = "LLLL yyyy" + return fmt.string(from: currentDate).capitalized(with: loc) case .quarter: - fmt.dateFormat = "MMM yyyy" + fmt.dateFormat = "LLL yyyy" let m3 = cal.date(byAdding: .month, value: 2, to: currentDate) ?? currentDate return "\(fmt.string(from: currentDate)) – \(fmt.string(from: m3))" case .week: let weekStart = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: currentDate))! let weekEnd = cal.date(byAdding: .day, value: 6, to: weekStart)! fmt.dateFormat = "d. MMM" - let ef = DateFormatter(); ef.dateFormat = "d. MMM yyyy" + let ef = DateFormatter(); ef.locale = loc; ef.dateFormat = "d. MMM yyyy" return "\(fmt.string(from: weekStart)) – \(ef.string(from: weekEnd))" case .day: fmt.dateFormat = "EEEE, d. MMMM yyyy" return fmt.string(from: currentDate) case .agenda: - return "Termine" + return L10n.t("view.agenda", language) } } } diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift new file mode 100644 index 0000000..5841fad --- /dev/null +++ b/Calendarr iOS/Models/Localization.swift @@ -0,0 +1,520 @@ +import Foundation +import SwiftUI + +enum AppLanguage: String, CaseIterable { + case system, de, en + + var displayKey: String { + switch self { + case .system: return "lang.system" + case .de: return "lang.german" + case .en: return "lang.english" + } + } +} + +enum L10n { + static func resolved(_ stored: String) -> String { + if stored == "de" || stored == "en" { return stored } + let pref = Locale.preferredLanguages.first ?? "en" + return pref.lowercased().hasPrefix("de") ? "de" : "en" + } + + static func t(_ key: String, _ stored: String) -> String { + let lang = resolved(stored) + return strings[lang]?[key] ?? strings["en"]?[key] ?? key + } + + static func locale(_ stored: String) -> Locale { + Locale(identifier: resolved(stored)) + } +} + +private let strings: [String: [String: String]] = [ + "de": [ + // Top bar / navigation + "nav.today": "Heute", + "nav.menu": "Menü", + "nav.done": "Fertig", + + // View types + "view.month": "Monat", + "view.week": "Woche", + "view.day": "Tag", + "view.quarter": "Quartal", + "view.agenda": "Termine", + "view.change": "Ansicht", + + // Calendar misc + "cal.cw": "KW", + "cal.allday": "Ganztägig", + "cal.no_events_title": "Keine Termine", + "cal.no_events_body": "In den nächsten 90 Tagen sind keine Termine vorhanden.", + "cal.loading_more": "Lade weitere Wochen…", + "cal.new_event": "Neues Ereignis", + "cal.show_in_day_view": "In Tagesansicht öffnen", + "cal.show_in_week_view": "In Wochenansicht öffnen", + "cal.show_in_month_view": "In Monatsansicht öffnen", + + // Menu sheet + "menu.section.settings": "Einstellungen", + "menu.profile": "Profil", + "menu.appearance": "Darstellung", + "menu.accounts": "Konten & Kalender", + "menu.server": "Server", + "menu.logout": "Abmelden", + "menu.admin": "Admin", + + // Settings – chrome + "settings.title": "Darstellung", + "settings.loading": "Lade Einstellungen…", + "settings.save": "Speichern", + "settings.saved": "Gespeichert", + + // Settings – sections + "settings.appdesign": "App-Design", + "settings.liquidglass": "Liquid Glass", + "settings.liquidglass.desc": "Verwendet die neue iOS\u{202F}26 Glasoptik mit transparenter Navigationsleiste", + "settings.liquidglass.footer": "Änderung wirkt sofort – kein Neustart nötig.", + + "settings.cache.header": "Vorladen", + "settings.cache.title": "Vorladen", + "settings.cache.desc": "Events werden beim Start im Hintergrund für diesen Zeitraum geladen, danach ist Wischen sofort.", + "settings.cache.range": "Zeitraum", + "settings.cache.1m": "±1 Monat", + "settings.cache.3m": "±3 Monate", + "settings.cache.6m": "±6 Monate", + "settings.cache.1y": "±1 Jahr", + "settings.cache.footer": "Mehr Monate = längerer initialer Ladevorgang, danach komplett ohne Wartezeiten navigierbar.", + + "settings.language": "Sprache", + "lang.system": "Systemstandard", + "lang.german": "Deutsch", + "lang.english": "English", + + "settings.colors": "Farben", + "settings.color.primary": "Primärfarbe", + "settings.color.accent": "Akzentfarbe", + "settings.color.today": "Heutige-Tag-Farbe", + "settings.color.divider": "Monatswechsel-Linie", + "settings.color.label": "Monatskürzel", + "settings.color.text": "Schriftfarbe", + "settings.color.background": "Hintergrundfarbe", + "settings.color.line": "Linienfarbe", + + "settings.textcontrast": "Schriftkontrast", + "settings.textcontrast.desc": "Helligkeit der Beschriftungen und Texte", + "settings.contrast.dark": "Dunkel", + "settings.contrast.medium": "Mittel", + "settings.contrast.bright": "Hell", + "settings.contrast.max": "Maximum", + + "settings.linecontrast": "Linienkontrast", + "settings.linecontrast.desc": "Sichtbarkeit von Trennlinien und Rahmen", + "settings.linecontrast.barely": "Kaum", + "settings.linecontrast.subtle": "Subtil", + "settings.linecontrast.normal": "Normal", + "settings.linecontrast.strong": "Stark", + + "settings.calview": "Kalenderansicht", + "settings.defaultview": "Standardansicht", + "settings.firstweekday": "Erster Wochentag", + "settings.monday": "Montag", + "settings.sunday": "Sonntag", + "settings.dimpast": "Vergangene Termine ausgrauen", + + "settings.hourheight": "Stundenhöhe", + "settings.hourheight.desc": "Platz pro Stunde in der Wochen- & Tagesansicht", + "settings.hourheight.compact": "Kompakt", + "settings.hourheight.normal": "Normal", + "settings.hourheight.comfort": "Komfort", + "settings.hourheight.large": "Gross", + + // Common buttons + "common.cancel": "Abbrechen", + "common.close": "Schliessen", + "common.ok": "OK", + "common.error": "Fehler", + + // Server view + "server.title": "Server", + "server.connected": "Verbundener Server", + "server.switch": "Server wechseln", + "server.switch_msg": "Verbindung zu %@ wird getrennt und alle lokalen Anmeldedaten werden gelöscht.", + "server.logout_title": "Abmelden", + "server.logout_msg": "Du wirst von %@ abgemeldet.", + "server.info": "Info", + "server.imprint": "Impressum", + "server.version": "Version", + + // Imprint + "imprint.company": "Scarriffleservices", + "imprint.role": "Software & Webentwicklung", + "imprint.copyright": "Diese Software wurde von Scarriffleservices mit grösster Sorgfalt entwickelt und bereitgestellt. Alle Rechte vorbehalten © 2026 Scarriffleservices.", + "imprint.storage.title": "Datenspeicherung", + "imprint.storage.body": "Alle Anwendungsdaten werden auf dem Server gespeichert und verarbeitet, auf dem diese Calendarr-Instanz betrieben wird. Der Speicherort hängt damit vom Betreiber des jeweiligen Servers ab. Bei Nutzung der Google Kalender-Anbindung werden Daten über die Google API ausgetauscht; für diese Daten gelten die Datenschutzbestimmungen von Google. Bei Nutzung der Home Assistant-Anbindung werden Daten mit der jeweiligen Home Assistant-Instanz ausgetauscht. Home Assistant ist ein Projekt der Open Home Foundation.", + "imprint.disclaimer.title": "Haftungsausschluss", + "imprint.disclaimer.body": "Trotz sorgfältiger Erstellung wird keine Haftung für die Richtigkeit, Vollständigkeit oder Aktualität der bereitgestellten Inhalte übernommen. Die Nutzung erfolgt auf eigene Verantwortung.", + "imprint.contact.title": "Kontakt", + + // Profile view + "profile.title": "Profil", + "profile.loading": "Lade Profil…", + "profile.account": "Konto", + "profile.username": "Benutzername", + "profile.role": "Rolle", + "profile.role.admin": "Administrator", + "profile.role.user": "Benutzer", + "profile.email": "E-Mail", + "profile.no_email": "Keine E-Mail", + "profile.save_email": "E-Mail speichern", + "profile.email_saved": "E-Mail gespeichert", + "profile.change_password": "Passwort ändern", + "profile.current_password": "Aktuelles Passwort", + "profile.new_password": "Neues Passwort", + "profile.new_password_repeat": "Neues Passwort wiederholen", + "profile.password_mismatch": "Passwörter stimmen nicht überein", + "profile.password_changed": "Passwort geändert", + "profile.twofa": "Zwei-Faktor-Authentifizierung", + "profile.twofa.active": "2FA ist aktiviert", + "profile.twofa.inactive": "2FA ist deaktiviert", + "profile.twofa.enable": "2FA einrichten", + "profile.twofa.disable": "2FA deaktivieren", + "profile.twofa.enabled_toast": "2FA aktiviert", + "profile.twofa.disabled_toast": "2FA deaktiviert", + "twofa.setup_title": "2FA einrichten", + "twofa.scan_hint": "Scanne den QR-Code mit deiner Authenticator-App (z.B. Bitwarden, Google Authenticator).", + "twofa.qr_section": "QR-Code / Manueller Schlüssel", + "twofa.confirmation": "Bestätigung", + "twofa.code_placeholder": "6-stelliger Code", + "twofa.activate": "Aktivieren", + "twofa.disable_title": "2FA deaktivieren", + "twofa.password_section": "Passwort zum Deaktivieren", + "twofa.password_placeholder": "Passwort", + "twofa.disable": "Deaktivieren", + + // Event editor + "event.title_placeholder": "Titel", + "event.allday": "Ganztägig", + "event.start": "Start", + "event.end": "Ende", + "event.location": "Ort", + "event.description": "Beschreibung", + "event.calendar_section": "Kalender", + "event.no_writable": "Keine beschreibbaren Kalender vorhanden", + "event.calendar_picker": "Kalender", + "event.color_section": "Farbe", + "event.color": "Terminfarbe", + "event.reset_color": "Zurücksetzen", + "event.edit_title": "Termin bearbeiten", + "event.new_title": "Neuer Termin", + "event.save": "Sichern", + "event.add": "Hinzufügen", + + // Accounts + "accounts.title": "Konten", + "accounts.loading": "Lade Konten…", + "accounts.add.caldav": "CalDAV-Konto", + "accounts.add.local": "Lokaler Kalender", + "accounts.add.ical": "iCal-URL abonnieren", + "accounts.add.ha": "Home Assistant", + "accounts.caldav.header": "CalDAV-Konten", + "accounts.caldav.empty": "Keine CalDAV-Konten", + "accounts.caldav.add": "CalDAV hinzufügen", + "accounts.local.header": "Lokale Kalender", + "accounts.local.empty": "Keine lokalen Kalender", + "accounts.local.add": "Lokalen Kalender erstellen", + "accounts.ical.header": "iCal-Abonnements", + "accounts.ical.empty": "Keine Abonnements", + "accounts.ical.every": "Alle %d Min.", + "accounts.ical.add": "iCal-URL abonnieren", + "accounts.google.header": "Google-Konten", + "accounts.google.empty": "Keine Google-Konten", + "accounts.google.hint": "Google-Konten werden über den Browser verknüpft", + "accounts.ha.header": "Home Assistant", + "accounts.ha.empty": "Keine Home Assistant-Konten", + "accounts.ha.add": "Home Assistant hinzufügen", + + // CalDAV add sheet + "caldav.section": "Konto-Details", + "caldav.display_name": "Anzeigename", + "caldav.url": "CalDAV-URL", + "caldav.username": "Benutzername", + "caldav.password": "Passwort", + "caldav.color": "Farbe", + "caldav.color_label": "Farbe", + "caldav.color_section": "Farbe", + "caldav.connect": "Verbinden", + "caldav.title": "CalDAV-Konto", + + // Local cal add sheet + "local.title": "Lokaler Kalender", + "local.name": "Name", + "local.color": "Farbe", + "local.create": "Erstellen", + + // iCal add sheet + "ical.title": "iCal abonnieren", + "ical.subscription": "Abonnement", + "ical.name": "Name", + "ical.url": "iCal-URL", + "ical.color": "Farbe", + "ical.refresh_section": "Aktualisierung", + "ical.interval": "Intervall", + "ical.subscribe": "Abonnieren", + "ical.refresh.15m": "Alle 15 Min.", + "ical.refresh.30m": "Alle 30 Min.", + "ical.refresh.1h": "Stündlich", + "ical.refresh.6h": "Alle 6 Std.", + "ical.refresh.1d": "Täglich", + + // HA add sheet + "ha.section": "Home Assistant", + "ha.display_name": "Anzeigename", + "ha.url_placeholder": "URL (z.B. http://homeassistant.local:8123)", + "ha.auth_section": "Authentifizierung", + "ha.token": "Long-Lived Access Token", + "ha.token_hint": "Token erstellen unter: Profil → Sicherheit → Long-Lived Access Tokens", + "ha.connect": "Verbinden" + ], + "en": [ + "nav.today": "Today", + "nav.menu": "Menu", + "nav.done": "Done", + + "view.month": "Month", + "view.week": "Week", + "view.day": "Day", + "view.quarter": "Quarter", + "view.agenda": "Agenda", + "view.change": "View", + + "cal.cw": "W", + "cal.allday": "All-day", + "cal.no_events_title": "No events", + "cal.no_events_body": "No events in the next 90 days.", + "cal.loading_more": "Loading more weeks…", + "cal.new_event": "New event", + "cal.show_in_day_view": "Open in day view", + "cal.show_in_week_view": "Open in week view", + "cal.show_in_month_view": "Open in month view", + + "menu.section.settings": "Settings", + "menu.profile": "Profile", + "menu.appearance": "Appearance", + "menu.accounts": "Accounts & Calendars", + "menu.server": "Server", + "menu.logout": "Sign out", + "menu.admin": "Admin", + + "settings.title": "Appearance", + "settings.loading": "Loading settings…", + "settings.save": "Save", + "settings.saved": "Saved", + + "settings.appdesign": "App design", + "settings.liquidglass": "Liquid Glass", + "settings.liquidglass.desc": "Uses the new iOS\u{202F}26 glass look with a translucent navigation bar", + "settings.liquidglass.footer": "Takes effect immediately – no restart required.", + + "settings.cache.header": "Preloading", + "settings.cache.title": "Preloading", + "settings.cache.desc": "Events are loaded in the background for this range on launch, so swiping is instant afterwards.", + "settings.cache.range": "Range", + "settings.cache.1m": "±1 month", + "settings.cache.3m": "±3 months", + "settings.cache.6m": "±6 months", + "settings.cache.1y": "±1 year", + "settings.cache.footer": "More months = longer initial load, but then fully wait-free navigation.", + + "settings.language": "Language", + "lang.system": "System default", + "lang.german": "Deutsch", + "lang.english": "English", + + "settings.colors": "Colors", + "settings.color.primary": "Primary color", + "settings.color.accent": "Accent color", + "settings.color.today": "Today color", + "settings.color.divider": "Month divider line", + "settings.color.label": "Month abbreviation", + "settings.color.text": "Text color", + "settings.color.background": "Background color", + "settings.color.line": "Line color", + + "settings.textcontrast": "Text contrast", + "settings.textcontrast.desc": "Brightness of labels and text", + "settings.contrast.dark": "Dark", + "settings.contrast.medium": "Medium", + "settings.contrast.bright": "Bright", + "settings.contrast.max": "Maximum", + + "settings.linecontrast": "Line contrast", + "settings.linecontrast.desc": "Visibility of dividers and borders", + "settings.linecontrast.barely": "Barely", + "settings.linecontrast.subtle": "Subtle", + "settings.linecontrast.normal": "Normal", + "settings.linecontrast.strong": "Strong", + + "settings.calview": "Calendar view", + "settings.defaultview": "Default view", + "settings.firstweekday": "First day of week", + "settings.monday": "Monday", + "settings.sunday": "Sunday", + "settings.dimpast": "Dim past events", + + "settings.hourheight": "Hour height", + "settings.hourheight.desc": "Space per hour in week & day view", + "settings.hourheight.compact": "Compact", + "settings.hourheight.normal": "Normal", + "settings.hourheight.comfort": "Comfort", + "settings.hourheight.large": "Large", + + // Common buttons + "common.cancel": "Cancel", + "common.close": "Close", + "common.ok": "OK", + "common.error": "Error", + + // Server view + "server.title": "Server", + "server.connected": "Connected server", + "server.switch": "Switch server", + "server.switch_msg": "The connection to %@ will be closed and all local credentials will be removed.", + "server.logout_title": "Sign out", + "server.logout_msg": "You will be signed out of %@.", + "server.info": "Info", + "server.imprint": "Legal notice", + "server.version": "Version", + + // Imprint + "imprint.company": "Scarriffleservices", + "imprint.role": "Software & web development", + "imprint.copyright": "This software was carefully developed and provided by Scarriffleservices. All rights reserved © 2026 Scarriffleservices.", + "imprint.storage.title": "Data storage", + "imprint.storage.body": "All application data is stored and processed on the server on which this Calendarr instance is hosted. The storage location therefore depends on the operator of the respective server. When using the Google Calendar integration, data is exchanged via the Google API; Google's privacy policy applies to that data. When using the Home Assistant integration, data is exchanged with the respective Home Assistant instance. Home Assistant is a project of the Open Home Foundation.", + "imprint.disclaimer.title": "Disclaimer", + "imprint.disclaimer.body": "Despite careful preparation, no liability is assumed for the accuracy, completeness or topicality of the content provided. Use is at your own risk.", + "imprint.contact.title": "Contact", + + // Profile view + "profile.title": "Profile", + "profile.loading": "Loading profile…", + "profile.account": "Account", + "profile.username": "Username", + "profile.role": "Role", + "profile.role.admin": "Administrator", + "profile.role.user": "User", + "profile.email": "Email", + "profile.no_email": "No email", + "profile.save_email": "Save email", + "profile.email_saved": "Email saved", + "profile.change_password": "Change password", + "profile.current_password": "Current password", + "profile.new_password": "New password", + "profile.new_password_repeat": "Repeat new password", + "profile.password_mismatch": "Passwords don't match", + "profile.password_changed": "Password changed", + "profile.twofa": "Two-factor authentication", + "profile.twofa.active": "2FA is enabled", + "profile.twofa.inactive": "2FA is disabled", + "profile.twofa.enable": "Set up 2FA", + "profile.twofa.disable": "Disable 2FA", + "profile.twofa.enabled_toast": "2FA enabled", + "profile.twofa.disabled_toast": "2FA disabled", + "twofa.setup_title": "Set up 2FA", + "twofa.scan_hint": "Scan the QR code with your authenticator app (e.g. Bitwarden, Google Authenticator).", + "twofa.qr_section": "QR code / Manual key", + "twofa.confirmation": "Verification", + "twofa.code_placeholder": "6-digit code", + "twofa.activate": "Activate", + "twofa.disable_title": "Disable 2FA", + "twofa.password_section": "Password to disable", + "twofa.password_placeholder": "Password", + "twofa.disable": "Disable", + + // Event editor + "event.title_placeholder": "Title", + "event.allday": "All-day", + "event.start": "Start", + "event.end": "End", + "event.location": "Location", + "event.description": "Description", + "event.calendar_section": "Calendar", + "event.no_writable": "No writable calendars available", + "event.calendar_picker": "Calendar", + "event.color_section": "Color", + "event.color": "Event color", + "event.reset_color": "Reset", + "event.edit_title": "Edit event", + "event.new_title": "New event", + "event.save": "Save", + "event.add": "Add", + + // Accounts + "accounts.title": "Accounts", + "accounts.loading": "Loading accounts…", + "accounts.add.caldav": "CalDAV account", + "accounts.add.local": "Local calendar", + "accounts.add.ical": "Subscribe to iCal URL", + "accounts.add.ha": "Home Assistant", + "accounts.caldav.header": "CalDAV accounts", + "accounts.caldav.empty": "No CalDAV accounts", + "accounts.caldav.add": "Add CalDAV", + "accounts.local.header": "Local calendars", + "accounts.local.empty": "No local calendars", + "accounts.local.add": "Create local calendar", + "accounts.ical.header": "iCal subscriptions", + "accounts.ical.empty": "No subscriptions", + "accounts.ical.every": "Every %d min", + "accounts.ical.add": "Subscribe to iCal URL", + "accounts.google.header": "Google accounts", + "accounts.google.empty": "No Google accounts", + "accounts.google.hint": "Google accounts are linked via the browser", + "accounts.ha.header": "Home Assistant", + "accounts.ha.empty": "No Home Assistant accounts", + "accounts.ha.add": "Add Home Assistant", + + // CalDAV add sheet + "caldav.section": "Account details", + "caldav.display_name": "Display name", + "caldav.url": "CalDAV URL", + "caldav.username": "Username", + "caldav.password": "Password", + "caldav.color": "Color", + "caldav.color_label": "Color", + "caldav.color_section": "Color", + "caldav.connect": "Connect", + "caldav.title": "CalDAV account", + + // Local cal add sheet + "local.title": "Local calendar", + "local.name": "Name", + "local.color": "Color", + "local.create": "Create", + + // iCal add sheet + "ical.title": "Subscribe to iCal", + "ical.subscription": "Subscription", + "ical.name": "Name", + "ical.url": "iCal URL", + "ical.color": "Color", + "ical.refresh_section": "Refresh", + "ical.interval": "Interval", + "ical.subscribe": "Subscribe", + "ical.refresh.15m": "Every 15 min", + "ical.refresh.30m": "Every 30 min", + "ical.refresh.1h": "Hourly", + "ical.refresh.6h": "Every 6 hours", + "ical.refresh.1d": "Daily", + + // HA add sheet + "ha.section": "Home Assistant", + "ha.display_name": "Display name", + "ha.url_placeholder": "URL (e.g. http://homeassistant.local:8123)", + "ha.auth_section": "Authentication", + "ha.token": "Long-Lived Access Token", + "ha.token_hint": "Create a token under: Profile → Security → Long-Lived Access Tokens", + "ha.connect": "Connect" + ] +] diff --git a/Calendarr iOS/Views/AccountsView.swift b/Calendarr iOS/Views/AccountsView.swift index 874b05d..9e9bf35 100644 --- a/Calendarr iOS/Views/AccountsView.swift +++ b/Calendarr iOS/Views/AccountsView.swift @@ -15,11 +15,13 @@ struct AccountsView: View { @State private var showAddHA = false @State private var errorAlert: String? + @AppStorage("appLanguage") private var appLang = "system" + var body: some View { NavigationStack { Group { if isLoading { - ProgressView("Lade Konten…") + ProgressView(L10n.t("accounts.loading", appLang)) } else { List { caldavSection @@ -30,15 +32,15 @@ struct AccountsView: View { } } } - .navigationTitle("Konten") + .navigationTitle(L10n.t("accounts.title", appLang)) .navigationBarTitleDisplayMode(.large) .toolbar { ToolbarItem(placement: .primaryAction) { Menu { - Button("CalDAV-Konto") { showAddCalDAV = true } - Button("Lokaler Kalender") { showAddLocal = true } - Button("iCal-URL abonnieren") { showAddICal = true } - Button("Home Assistant") { showAddHA = true } + Button(L10n.t("accounts.add.caldav", appLang)) { showAddCalDAV = true } + Button(L10n.t("accounts.add.local", appLang)) { showAddLocal = true } + Button(L10n.t("accounts.add.ical", appLang)) { showAddICal = true } + Button(L10n.t("accounts.add.ha", appLang)) { showAddHA = true } } label: { Image(systemName: "plus") } @@ -56,8 +58,8 @@ struct AccountsView: View { .sheet(isPresented: $showAddHA) { AddHASheet(api: api) { await load() } } - .alert("Fehler", isPresented: .constant(errorAlert != nil), actions: { - Button("OK") { errorAlert = nil } + .alert(L10n.t("common.error", appLang), isPresented: .constant(errorAlert != nil), actions: { + Button(L10n.t("common.ok", appLang)) { errorAlert = nil } }, message: { Text(errorAlert ?? "") }) @@ -70,7 +72,7 @@ struct AccountsView: View { var caldavSection: some View { Section { if caldavAccounts.isEmpty { - Text("Keine CalDAV-Konten") + Text(L10n.t("accounts.caldav.empty", appLang)) .foregroundStyle(.secondary) } else { ForEach(caldavAccounts) { acc in @@ -91,17 +93,17 @@ struct AccountsView: View { Task { await deleteCalDAV(offsets: offsets) } } } - Button("CalDAV hinzufügen") { showAddCalDAV = true } + Button(L10n.t("accounts.caldav.add", appLang)) { showAddCalDAV = true } .foregroundStyle(Color.accentColor) } header: { - Text("CalDAV-Konten") + Text(L10n.t("accounts.caldav.header", appLang)) } } var localSection: some View { Section { if localCalendars.isEmpty { - Text("Keine lokalen Kalender") + Text(L10n.t("accounts.local.empty", appLang)) .foregroundStyle(.secondary) } else { ForEach(localCalendars) { cal in @@ -116,17 +118,17 @@ struct AccountsView: View { Task { await deleteLocal(offsets: offsets) } } } - Button("Lokalen Kalender erstellen") { showAddLocal = true } + Button(L10n.t("accounts.local.add", appLang)) { showAddLocal = true } .foregroundStyle(Color.accentColor) } header: { - Text("Lokale Kalender") + Text(L10n.t("accounts.local.header", appLang)) } } var icalSection: some View { Section { if icalSubs.isEmpty { - Text("Keine Abonnements") + Text(L10n.t("accounts.ical.empty", appLang)) .foregroundStyle(.secondary) } else { ForEach(icalSubs) { sub in @@ -136,7 +138,7 @@ struct AccountsView: View { .frame(width: 12, height: 12) VStack(alignment: .leading, spacing: 2) { Text(sub.name).font(.body) - Text("Alle \(sub.refreshMinutes) Min.") + Text(String(format: L10n.t("accounts.ical.every", appLang), sub.refreshMinutes)) .font(.caption) .foregroundStyle(.secondary) } @@ -146,17 +148,17 @@ struct AccountsView: View { Task { await deleteICal(offsets: offsets) } } } - Button("iCal-URL abonnieren") { showAddICal = true } + Button(L10n.t("accounts.ical.add", appLang)) { showAddICal = true } .foregroundStyle(Color.accentColor) } header: { - Text("iCal-Abonnements") + Text(L10n.t("accounts.ical.header", appLang)) } } var googleSection: some View { Section { if googleAccounts.isEmpty { - Text("Keine Google-Konten") + Text(L10n.t("accounts.google.empty", appLang)) .foregroundStyle(.secondary) } else { ForEach(googleAccounts) { acc in @@ -170,18 +172,18 @@ struct AccountsView: View { Task { await deleteGoogle(offsets: offsets) } } } - Text("Google-Konten werden über den Browser verknüpft") + Text(L10n.t("accounts.google.hint", appLang)) .font(.caption) .foregroundStyle(.secondary) } header: { - Text("Google-Konten") + Text(L10n.t("accounts.google.header", appLang)) } } var haSection: some View { Section { if haAccounts.isEmpty { - Text("Keine Home Assistant-Konten") + Text(L10n.t("accounts.ha.empty", appLang)) .foregroundStyle(.secondary) } else { ForEach(haAccounts) { acc in @@ -197,10 +199,10 @@ struct AccountsView: View { Task { await deleteHA(offsets: offsets) } } } - Button("Home Assistant hinzufügen") { showAddHA = true } + Button(L10n.t("accounts.ha.add", appLang)) { showAddHA = true } .foregroundStyle(Color.accentColor) } header: { - Text("Home Assistant") + Text(L10n.t("accounts.ha.header", appLang)) } } @@ -259,6 +261,7 @@ struct AddCalDAVSheet: View { let api: CalendarrAPI let onDone: () async -> Void @Environment(\.dismiss) var dismiss + @AppStorage("appLanguage") private var appLang = "system" @State private var name = "" @State private var url = "" @@ -271,19 +274,19 @@ struct AddCalDAVSheet: View { var body: some View { NavigationStack { Form { - Section("Konto-Details") { - TextField("Anzeigename", text: $name) - TextField("CalDAV-URL", text: $url) + Section(L10n.t("caldav.section", appLang)) { + TextField(L10n.t("caldav.display_name", appLang), text: $name) + TextField(L10n.t("caldav.url", appLang), text: $url) .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.URL) - TextField("Benutzername", text: $username) + TextField(L10n.t("caldav.username", appLang), text: $username) .textInputAutocapitalization(.never) .autocorrectionDisabled() - SecureField("Passwort", text: $password) + SecureField(L10n.t("caldav.password", appLang), text: $password) } - Section("Farbe") { - ColorPicker("Farbe", selection: $color, supportsOpacity: false) + Section(L10n.t("caldav.color_section", appLang)) { + ColorPicker(L10n.t("caldav.color_label", appLang), selection: $color, supportsOpacity: false) } if !error.isEmpty { Section { @@ -291,14 +294,14 @@ struct AddCalDAVSheet: View { } } } - .navigationTitle("CalDAV-Konto") + .navigationTitle(L10n.t("caldav.title", appLang)) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button("Abbrechen") { dismiss() } + Button(L10n.t("common.cancel", appLang)) { dismiss() } } ToolbarItem(placement: .primaryAction) { - Button("Verbinden") { + Button(L10n.t("caldav.connect", appLang)) { Task { await save() } } .bold() @@ -326,6 +329,7 @@ struct AddLocalCalSheet: View { let api: CalendarrAPI let onDone: () async -> Void @Environment(\.dismiss) var dismiss + @AppStorage("appLanguage") private var appLang = "system" @State private var name = "" @State private var color = Color(hex: "#34a853") @@ -336,19 +340,19 @@ struct AddLocalCalSheet: View { NavigationStack { Form { Section { - TextField("Name", text: $name) - ColorPicker("Farbe", selection: $color, supportsOpacity: false) + TextField(L10n.t("local.name", appLang), text: $name) + ColorPicker(L10n.t("local.color", appLang), selection: $color, supportsOpacity: false) } if !error.isEmpty { Section { Text(error).foregroundStyle(.red) } } } - .navigationTitle("Lokaler Kalender") + .navigationTitle(L10n.t("local.title", appLang)) .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } } + ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } } ToolbarItem(placement: .primaryAction) { - Button("Erstellen") { + Button(L10n.t("local.create", appLang)) { Task { await save() } } .bold() @@ -373,6 +377,7 @@ struct AddICalSheet: View { let api: CalendarrAPI let onDone: () async -> Void @Environment(\.dismiss) var dismiss + @AppStorage("appLanguage") private var appLang = "system" @State private var name = "" @State private var url = "" @@ -381,21 +386,27 @@ struct AddICalSheet: View { @State private var isLoading = false @State private var error = "" - let refreshOptions = [(15, "Alle 15 Min."), (30, "Alle 30 Min."), (60, "Stündlich"), (360, "Alle 6 Std."), (1440, "Täglich")] + private var refreshOptions: [(Int, String)] { + [(15, L10n.t("ical.refresh.15m", appLang)), + (30, L10n.t("ical.refresh.30m", appLang)), + (60, L10n.t("ical.refresh.1h", appLang)), + (360, L10n.t("ical.refresh.6h", appLang)), + (1440, L10n.t("ical.refresh.1d", appLang))] + } var body: some View { NavigationStack { Form { - Section("Abonnement") { - TextField("Name", text: $name) - TextField("iCal-URL", text: $url) + Section(L10n.t("ical.subscription", appLang)) { + TextField(L10n.t("ical.name", appLang), text: $name) + TextField(L10n.t("ical.url", appLang), text: $url) .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.URL) - ColorPicker("Farbe", selection: $color, supportsOpacity: false) + ColorPicker(L10n.t("ical.color", appLang), selection: $color, supportsOpacity: false) } - Section("Aktualisierung") { - Picker("Intervall", selection: $refreshMinutes) { + Section(L10n.t("ical.refresh_section", appLang)) { + Picker(L10n.t("ical.interval", appLang), selection: $refreshMinutes) { ForEach(refreshOptions, id: \.0) { opt in Text(opt.1).tag(opt.0) } @@ -405,12 +416,12 @@ struct AddICalSheet: View { Section { Text(error).foregroundStyle(.red) } } } - .navigationTitle("iCal abonnieren") + .navigationTitle(L10n.t("ical.title", appLang)) .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } } + ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } } ToolbarItem(placement: .primaryAction) { - Button("Abonnieren") { + Button(L10n.t("ical.subscribe", appLang)) { Task { await save() } } .bold() @@ -435,6 +446,7 @@ struct AddHASheet: View { let api: CalendarrAPI let onDone: () async -> Void @Environment(\.dismiss) var dismiss + @AppStorage("appLanguage") private var appLang = "system" @State private var name = "" @State private var url = "" @@ -445,16 +457,16 @@ struct AddHASheet: View { var body: some View { NavigationStack { Form { - Section("Home Assistant") { - TextField("Anzeigename", text: $name) - TextField("URL (z.B. http://homeassistant.local:8123)", text: $url) + Section(L10n.t("ha.section", appLang)) { + TextField(L10n.t("ha.display_name", appLang), text: $name) + TextField(L10n.t("ha.url_placeholder", appLang), text: $url) .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.URL) } - Section("Authentifizierung") { - SecureField("Long-Lived Access Token", text: $token) - Text("Token erstellen unter: Profil → Sicherheit → Long-Lived Access Tokens") + Section(L10n.t("ha.auth_section", appLang)) { + SecureField(L10n.t("ha.token", appLang), text: $token) + Text(L10n.t("ha.token_hint", appLang)) .font(.caption) .foregroundStyle(.secondary) } @@ -462,12 +474,12 @@ struct AddHASheet: View { Section { Text(error).foregroundStyle(.red) } } } - .navigationTitle("Home Assistant") + .navigationTitle(L10n.t("accounts.add.ha", appLang)) .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } } + ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } } ToolbarItem(placement: .primaryAction) { - Button("Verbinden") { + Button(L10n.t("ha.connect", appLang)) { Task { await save() } } .bold() diff --git a/Calendarr iOS/Views/Calendar/AgendaView.swift b/Calendarr iOS/Views/Calendar/AgendaView.swift index 5230bfb..081650d 100644 --- a/Calendarr iOS/Views/Calendar/AgendaView.swift +++ b/Calendarr iOS/Views/Calendar/AgendaView.swift @@ -3,6 +3,7 @@ import SwiftUI struct AgendaView: View { let store: CalendarStore let onEventTap: (CalEvent) -> Void + @AppStorage("appLanguage") private var appLang = "system" private var cal: Calendar { store.userCalendar } @@ -17,25 +18,27 @@ struct AgendaView: View { return dict.keys.sorted().map { ($0, dict[$0]!.sorted { $0.startDate < $1.startDate }) } } - private let dayFmt: DateFormatter = { + private var dayFmt: DateFormatter { let f = DateFormatter() + f.locale = L10n.locale(appLang) f.dateFormat = "EEEE, d. MMMM yyyy" return f - }() + } - private let timeFmt: DateFormatter = { + private var timeFmt: DateFormatter { let f = DateFormatter() + f.locale = L10n.locale(appLang) f.timeStyle = .short f.dateStyle = .none return f - }() + } var body: some View { if grouped.isEmpty { ContentUnavailableView( - "Keine Termine", + L10n.t("cal.no_events_title", appLang), systemImage: "calendar", - description: Text("In den nächsten 90 Tagen sind keine Termine vorhanden.") + description: Text(L10n.t("cal.no_events_body", appLang)) ) } else { List { @@ -43,7 +46,7 @@ struct AgendaView: View { Section { ForEach(evs) { ev in Button { onEventTap(ev) } label: { - AgendaEventRow(event: ev, timeFmt: timeFmt) + AgendaEventRow(event: ev, timeFmt: timeFmt, allDayLabel: L10n.t("cal.allday", appLang)) } .buttonStyle(.plain) } @@ -62,9 +65,10 @@ struct AgendaView: View { private struct AgendaEventRow: View { let event: CalEvent let timeFmt: DateFormatter + let allDayLabel: String var timeString: String { - if event.isAllDay { return "Ganztägig" } + if event.isAllDay { return allDayLabel } return timeFmt.string(from: event.startDate) } diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index f91ddff..3fb638e 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -6,12 +6,25 @@ struct CalendarHostView: View { @AppStorage("liquidGlass") private var liquidGlass = false @AppStorage("cacheMonths") private var cacheMonths = 3 + @AppStorage("appLanguage") private var appLang = "system" + @AppStorage("backgroundColor") private var bgHex = "#000000" @State private var store = CalendarStore() @State private var showEditor = false @State private var editorDate: Date = .now @State private var editingEvent: CalEvent? = nil @State private var selectedEvent: CalEvent? = nil + @State private var visibleMonth: Date = .now + + private var titleString: String { + if store.viewType == .month { + let f = DateFormatter() + f.locale = L10n.locale(appLang) + f.dateFormat = "LLLL yyyy" + return f.string(from: visibleMonth).capitalized(with: L10n.locale(appLang)) + } + return store.titleForCurrentView(language: appLang) + } var body: some View { if liquidGlass { @@ -30,6 +43,7 @@ struct CalendarHostView: View { errorBanner calendarContent .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(hex: bgHex)) .overlay(alignment: .top) { if store.isLoading { ProgressView().padding(.top, 10).transition(.opacity) @@ -52,6 +66,7 @@ struct CalendarHostView: View { .onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } } .onChange(of: store.viewType) { _, _ in Task { await onNavigate() } } .onChange(of: cacheMonths) { _, _ in Task { await recache() } } + .onChange(of: visibleMonth) { _, new in Task { await ensureLoaded(around: new) } } } // MARK: – Liquid Glass variant @@ -60,6 +75,7 @@ struct CalendarHostView: View { NavigationStack { calendarContent .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(hex: bgHex)) .overlay(alignment: .top) { if store.isLoading { ProgressView().padding(.top, 10).transition(.opacity) @@ -74,12 +90,20 @@ struct CalendarHostView: View { HStack(spacing: 2) { Button { store.navigatePrev() } label: { Image(systemName: "chevron.left") } Button { store.navigateNext() } label: { Image(systemName: "chevron.right") } - Button("Heute") { store.moveToToday() }.font(.callout) + Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout) } } - ToolbarItem(placement: .principal) { viewPickerMenu } + ToolbarItem(placement: .principal) { + Text(titleString) + .font(.headline) + .lineLimit(1) + .minimumScaleFactor(0.7) + } ToolbarItem(placement: .navigationBarTrailing) { - Button { showMenu = true } label: { Image(systemName: "line.3.horizontal") } + HStack(spacing: 8) { + viewPickerMenu + Button { showMenu = true } label: { Image(systemName: "line.3.horizontal") } + } } } } @@ -89,6 +113,7 @@ struct CalendarHostView: View { .onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } } .onChange(of: store.viewType) { _, _ in Task { await onNavigate() } } .onChange(of: cacheMonths) { _, _ in Task { await recache() } } + .onChange(of: visibleMonth) { _, new in Task { await ensureLoaded(around: new) } } } // MARK: – Top bar (flat mode) @@ -106,17 +131,21 @@ struct CalendarHostView: View { .font(.system(size: 17, weight: .medium)) .frame(width: 36, height: 36) } - Button("Heute") { store.moveToToday() } + Button(L10n.t("nav.today", appLang)) { store.moveToToday() } .font(.callout).padding(.horizontal, 6) } .padding(.leading, 8) - Spacer() + Spacer(minLength: 8) + Text(titleString) + .font(.headline) + .lineLimit(1) + .minimumScaleFactor(0.7) + Spacer(minLength: 8) viewPickerMenu - Spacer() Button { showMenu = true } label: { Image(systemName: "line.3.horizontal") .font(.system(size: 18, weight: .medium)) - .frame(width: 44, height: 44) + .frame(width: 40, height: 40) } .padding(.trailing, 4) } @@ -128,18 +157,16 @@ struct CalendarHostView: View { Menu { ForEach(CalViewType.allCases, id: \.self) { vt in Button { store.viewType = vt } label: { - Label(vt.label, systemImage: vt.systemImage) + Label(vt.label(appLang), systemImage: vt.systemImage) } } } label: { - HStack(spacing: 4) { - Text(store.viewType.label).font(.headline) - Image(systemName: "chevron.down").font(.caption2.weight(.semibold)) - } - .foregroundStyle(.primary) - .padding(.horizontal, 12).padding(.vertical, 7) - .background(.quaternary, in: Capsule()) + Image(systemName: store.viewType.systemImage) + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(.primary) + .frame(width: 40, height: 40) } + .accessibilityLabel(L10n.t("view.change", appLang)) } // MARK: – Error banner @@ -165,24 +192,60 @@ struct CalendarHostView: View { @ViewBuilder private var calendarContent: some View { - let swipe = DragGesture(minimumDistance: 35, coordinateSpace: .global) + let swipe = DragGesture(minimumDistance: 14, coordinateSpace: .local) .onEnded { val in let h = val.translation.width let v = val.translation.height - guard abs(h) > abs(v) * 1.1, abs(h) > 50 else { return } + guard abs(h) > abs(v) * 1.2, abs(h) > 28 else { return } withAnimation(.easeInOut(duration: 0.2)) { if h < 0 { store.navigateNext() } else { store.navigatePrev() } } } switch store.viewType { case .month: - MonthView(store: store, onDayTap: { editorDate = $0 }, onEventTap: { selectedEvent = $0 }) - .simultaneousGesture(swipe) + // Month view uses vertical scroll – no horizontal swipe. + MonthView(store: store, + onDayTap: { editorDate = $0 }, + onEventTap: { selectedEvent = $0 }, + onCreateEvent: { day in + editingEvent = nil + editorDate = day + showEditor = true + }, + onShowWeek: { day in + store.currentDate = day + store.viewType = .week + }, + onShowDay: { day in + store.currentDate = day + store.viewType = .day + }, + visibleMonth: $visibleMonth) case .week: - WeekView(store: store, onEventTap: { selectedEvent = $0 }, onTimeTap: { editorDate = $0 }) + WeekView(store: store, + onEventTap: { selectedEvent = $0 }, + onCreateEvent: { date in + editingEvent = nil + editorDate = date + showEditor = true + }, + onShowMonth: { date in + store.currentDate = date + store.viewType = .month + }, + onShowDay: { date in + store.currentDate = date + store.viewType = .day + }) .simultaneousGesture(swipe) case .day: - DayView(store: store, onEventTap: { selectedEvent = $0 }, onTimeTap: { editorDate = $0 }) + DayView(store: store, + onEventTap: { selectedEvent = $0 }, + onCreateEvent: { date in + editingEvent = nil + editorDate = date + showEditor = true + }) .simultaneousGesture(swipe) case .quarter: QuarterView(store: store, onEventTap: { selectedEvent = $0 }) @@ -263,6 +326,16 @@ struct CalendarHostView: View { store.invalidateCache() await startup() } + + /// Called when the user scrolls into a new month – fetches a ±1 month window + /// around it on demand. `loadEvents` skips the network if cached. + private func ensureLoaded(around month: Date) async { + let cal = store.userCalendar + let monthStart = cal.date(from: cal.dateComponents([.year, .month], from: month)) ?? month + let s = cal.date(byAdding: .month, value: -1, to: monthStart) ?? monthStart + let e = cal.date(byAdding: .month, value: 2, to: monthStart) ?? monthStart + await store.loadEvents(api: api, start: s, end: e) + } } // MARK: – Shared sheet modifier diff --git a/Calendarr iOS/Views/Calendar/DayView.swift b/Calendarr iOS/Views/Calendar/DayView.swift index 630dbe1..7e9c25e 100644 --- a/Calendarr iOS/Views/Calendar/DayView.swift +++ b/Calendarr iOS/Views/Calendar/DayView.swift @@ -3,7 +3,12 @@ import SwiftUI struct DayView: View { let store: CalendarStore let onEventTap: (CalEvent) -> Void - let onTimeTap: (Date) -> Void + let onCreateEvent: (Date) -> Void + + @AppStorage("appLanguage") private var appLang = "system" + @AppStorage("todayColor") private var todayHex = "#4285f4" + @AppStorage("textColor") private var textHex = "#FFFFFF" + @AppStorage("lineColor") private var lineHex = "#3A3A3C" private var cal: Calendar { store.userCalendar } private var allDayEvents: [CalEvent] { store.events(on: store.currentDate).filter(\.isAllDay) } @@ -17,25 +22,18 @@ struct DayView: View { ScrollViewReader { proxy in ScrollView { ZStack(alignment: .topLeading) { - // Background grid + // Background grid with per-hour context menus HStack(alignment: .top, spacing: 0) { timeLabels VStack(spacing: 0) { - ForEach(hours, id: \.self) { _ in - Rectangle() - .fill(Color(.separator).opacity(0.4)) - .frame(height: 0.5) - Color.clear.frame(height: hourHeight - 0.5) + ForEach(hours, id: \.self) { hour in + DayHourSlot(day: store.currentDate, hour: hour, + hourHeight: hourHeight, + language: appLang, + onCreateEvent: onCreateEvent) } } .frame(width: geo.size.width - timeColumnWidth) - .contentShape(Rectangle()) - .onTapGesture { loc in - let h = Int(loc.y / hourHeight) - let m = Int((loc.y.truncatingRemainder(dividingBy: hourHeight)) / hourHeight * 60) - let date = cal.date(bySettingHour: h, minute: m, second: 0, of: store.currentDate) ?? store.currentDate - onTimeTap(date) - } } // Events @@ -52,10 +50,11 @@ struct DayView: View { // Current time if cal.isDateInToday(store.currentDate) { let lineY = nowLineY() + let nowColor = Color(hex: todayHex) HStack(spacing: 0) { Spacer().frame(width: timeColumnWidth - 4) - Circle().fill(Color.red).frame(width: 8, height: 8) - Rectangle().fill(Color.red) + Circle().fill(nowColor).frame(width: 8, height: 8) + Rectangle().fill(nowColor) .frame(width: geo.size.width - timeColumnWidth - 4, height: 1.5) } .offset(y: lineY - 0.75) @@ -98,7 +97,7 @@ struct DayView: View { Color.clear.frame(height: hourHeight) Text(String(format: "%02d:00", h)) .font(.system(size: 10)) - .foregroundStyle(.secondary) + .foregroundStyle(Color(hex: textHex).opacity(0.6)) .offset(y: -6) } } @@ -123,3 +122,31 @@ struct DayView: View { } } } + +// One-hour slot for the single-column day view. +private struct DayHourSlot: View { + let day: Date + let hour: Int + let hourHeight: CGFloat + let language: String + let onCreateEvent: (Date) -> Void + + @AppStorage("lineColor") private var lineHex = "#3A3A3C" + + private var date: Date { + Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day + } + + var body: some View { + VStack(spacing: 0) { + Rectangle().fill(Color(hex: lineHex).opacity(0.4)).frame(height: 0.5) + Color.clear.frame(height: hourHeight - 0.5) + } + .contentShape(Rectangle()) + .contextMenu { + Button { onCreateEvent(date) } label: { + Label(L10n.t("cal.new_event", language), systemImage: "plus") + } + } + } +} diff --git a/Calendarr iOS/Views/Calendar/EventEditorSheet.swift b/Calendarr iOS/Views/Calendar/EventEditorSheet.swift index c3b8117..1cf0f51 100644 --- a/Calendarr iOS/Views/Calendar/EventEditorSheet.swift +++ b/Calendarr iOS/Views/Calendar/EventEditorSheet.swift @@ -8,6 +8,7 @@ struct EventEditorSheet: View { let onSaved: () async -> Void @Environment(\.dismiss) var dismiss + @AppStorage("appLanguage") private var appLang = "system" @State private var title = "" @State private var isAllDay = false @State private var startDate = Date() @@ -29,36 +30,36 @@ struct EventEditorSheet: View { NavigationStack { Form { Section { - TextField("Titel", text: $title) + TextField(L10n.t("event.title_placeholder", appLang), text: $title) .font(.body.weight(.medium)) } Section { - Toggle("Ganztägig", isOn: $isAllDay.animation()) + Toggle(L10n.t("event.allday", appLang), isOn: $isAllDay.animation()) .tint(Color.accentColor) if isAllDay { - DatePicker("Start", selection: $startDate, displayedComponents: .date) - DatePicker("Ende", selection: $endDate, displayedComponents: .date) + DatePicker(L10n.t("event.start", appLang), selection: $startDate, displayedComponents: .date) + DatePicker(L10n.t("event.end", appLang), selection: $endDate, displayedComponents: .date) } else { - DatePicker("Start", selection: $startDate) - DatePicker("Ende", selection: $endDate) + DatePicker(L10n.t("event.start", appLang), selection: $startDate) + DatePicker(L10n.t("event.end", appLang), selection: $endDate) } } Section { - TextField("Ort", text: $location) - TextField("Beschreibung", text: $notes, axis: .vertical) + TextField(L10n.t("event.location", appLang), text: $location) + TextField(L10n.t("event.description", appLang), text: $notes, axis: .vertical) .lineLimit(3...6) } - Section("Kalender") { + Section(L10n.t("event.calendar_section", appLang)) { if store.writableCalendars.isEmpty { - Text("Keine beschreibbaren Kalender vorhanden") + Text(L10n.t("event.no_writable", appLang)) .foregroundStyle(.secondary) .font(.callout) } else { - Picker("Kalender", selection: $selectedCalendarId) { + Picker(L10n.t("event.calendar_picker", appLang), selection: $selectedCalendarId) { ForEach(store.writableCalendars) { cal in HStack { Circle() @@ -72,9 +73,9 @@ struct EventEditorSheet: View { } } - Section("Farbe") { + Section(L10n.t("event.color_section", appLang)) { HStack { - Text("Terminfarbe") + Text(L10n.t("event.color", appLang)) Spacer() ColorPicker("", selection: Binding( get: { Color(hex: color.isEmpty ? (selectedCal?.color ?? "#4285f4") : color) }, @@ -82,7 +83,7 @@ struct EventEditorSheet: View { ), supportsOpacity: false) .labelsHidden() if !color.isEmpty { - Button("Zurücksetzen") { color = "" } + Button(L10n.t("event.reset_color", appLang)) { color = "" } .font(.caption) .foregroundStyle(.secondary) } @@ -95,14 +96,18 @@ struct EventEditorSheet: View { } } } - .navigationTitle(isEditing ? "Termin bearbeiten" : "Neuer Termin") + .navigationTitle(isEditing + ? L10n.t("event.edit_title", appLang) + : L10n.t("event.new_title", appLang)) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button("Abbrechen") { dismiss() } + Button(L10n.t("common.cancel", appLang)) { dismiss() } } ToolbarItem(placement: .primaryAction) { - Button(isEditing ? "Sichern" : "Hinzufügen") { + Button(isEditing + ? L10n.t("event.save", appLang) + : L10n.t("event.add", appLang)) { Task { await save() } } .bold() diff --git a/Calendarr iOS/Views/Calendar/MonthView.swift b/Calendarr iOS/Views/Calendar/MonthView.swift index 4b49812..8413407 100644 --- a/Calendarr iOS/Views/Calendar/MonthView.swift +++ b/Calendarr iOS/Views/Calendar/MonthView.swift @@ -1,150 +1,388 @@ import SwiftUI +private let weeksBack = 104 +private let weeksAhead = 104 +private let weekdayHeaderHeight: CGFloat = 28 +private let dayNumberRowHeight: CGFloat = 22 +private let laneHeight: CGFloat = 16 +private let laneSpacing: CGFloat = 2 +private let maxLanesPerWeek = 5 + +private enum DividerEdge { case none, topHighlight, bottomHighlight } + struct MonthView: View { let store: CalendarStore let onDayTap: (Date) -> Void let onEventTap: (CalEvent) -> Void + let onCreateEvent: (Date) -> Void + let onShowWeek: (Date) -> Void + let onShowDay: (Date) -> Void + @Binding var visibleMonth: Date + + @AppStorage("appLanguage") private var appLang = "system" + @AppStorage("monthDividerColor") private var dividerHex = "#7090c0" + @AppStorage("monthLabelColor") private var labelHex = "#7090c0" + @AppStorage("textColor") private var textHex = "#FFFFFF" + @AppStorage("lineColor") private var lineHex = "#3A3A3C" + + @State private var scrolledWeek: Date? = nil + @State private var didInitialScroll = false private var cal: Calendar { store.userCalendar } - private var monthStart: Date { - cal.date(from: cal.dateComponents([.year, .month], from: store.currentDate))! + private var weekStarts: [Date] { + let today = cal.startOfDay(for: .now) + let thisWeek = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: today))! + return (-weeksBack...weeksAhead).compactMap { + cal.date(byAdding: .weekOfYear, value: $0, to: thisWeek) + } } - private var gridDays: [Date] { - let firstWeekday = cal.firstWeekday - let weekday = cal.component(.weekday, from: monthStart) - let offset = ((weekday - firstWeekday) + 7) % 7 - let gridStart = cal.date(byAdding: .day, value: -offset, to: monthStart)! - return (0..<42).compactMap { cal.date(byAdding: .day, value: $0, to: gridStart) } - } - - private var rowCount: Int { gridDays.count / 7 } // always 6 - private var weekdayHeaders: [String] { - let symbols = cal.shortWeekdaySymbols + let fmt = DateFormatter(); fmt.locale = L10n.locale(appLang) + let symbols = fmt.shortWeekdaySymbols ?? cal.shortWeekdaySymbols let start = cal.firstWeekday - 1 - return (0..<7).map { String(symbols[(start + $0) % 7].prefix(2)) } + return (0..<7).map { i in String(symbols[(start + i) % 7].prefix(2)) } } var body: some View { VStack(spacing: 0) { - // Day-of-week header row (fixed height) - HStack(spacing: 0) { - ForEach(weekdayHeaders, id: \.self) { d in - Text(d) - .font(.caption2.weight(.semibold)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, minHeight: 28) + headerRow + Divider() + ScrollView { + LazyVStack(spacing: 0) { + ForEach(weekStarts, id: \.self) { ws in + WeekRow(weekStart: ws, + store: store, + dividerColor: Color(hex: dividerHex), + labelColor: Color(hex: labelHex), + textColor: Color(hex: textHex), + lineColor: Color(hex: lineHex), + language: appLang, + onDayTap: onDayTap, + onEventTap: onEventTap, + onCreateEvent: onCreateEvent, + onShowWeek: onShowWeek, + onShowDay: onShowDay) + .id(ws) + } + } + .scrollTargetLayout() + } + .scrollPosition(id: $scrolledWeek, anchor: .top) + .onAppear { + if !didInitialScroll { + didInitialScroll = true + scrolledWeek = weekStart(for: store.currentDate) + publishVisibleMonth(from: scrolledWeek) } } - Divider() - - // Grid fills all remaining space using GeometryReader - GeometryReader { geo in - let rowH = geo.size.height / CGFloat(rowCount) - VStack(spacing: 0) { - ForEach(0.. Date { + cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date))! + } + + /// Treat the visible month as the one that "owns" Thursday of the current week — + /// matches ISO week-month conventions and avoids flicker on month boundaries. + private func publishVisibleMonth(from week: Date?) { + guard let w = week else { return } + let thursday = cal.date(byAdding: .day, value: 3, to: w) ?? w + let m = cal.date(from: cal.dateComponents([.year, .month], from: thursday)) ?? thursday + if visibleMonth != m { visibleMonth = m } + } } -private struct DayCell: View { - let date: Date - let isCurrentMonth: Bool - let isToday: Bool - let events: [CalEvent] - let rowHeight: CGFloat - let onTap: () -> Void - let onEventTap: (CalEvent) -> Void +// MARK: – Week Row - private var maxVisible: Int { - max(1, Int((rowHeight - 32) / 16)) +private struct WeekRow: View { + let weekStart: Date + let store: CalendarStore + let dividerColor: Color + let labelColor: Color + let textColor: Color + let lineColor: Color + let language: String + let onDayTap: (Date) -> Void + let onEventTap: (CalEvent) -> Void + let onCreateEvent: (Date) -> Void + let onShowWeek: (Date) -> Void + let onShowDay: (Date) -> Void + + private var cal: Calendar { store.userCalendar } + + private var days: [Date] { + (0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: weekStart) } + } + + private var weekNumber: Int { cal.component(.weekOfYear, from: weekStart) } + + private func columnRange(for ev: CalEvent) -> (startCol: Int, span: Int) { + let weekEnd = cal.date(byAdding: .day, value: 7, to: weekStart)! + let evStart = max(cal.startOfDay(for: ev.startDate), weekStart) + // All-day end is already exclusive; timed end-of-day-on-same-day shouldn't add a column. + let rawEnd: Date + if ev.isAllDay { + rawEnd = ev.endDate + } else { + // Treat timed events as occupying days from start up to and including the day of end. + rawEnd = cal.date(byAdding: .day, value: 1, to: cal.startOfDay(for: ev.endDate))! + } + let evEnd = min(rawEnd, weekEnd) + let sc = max(0, cal.dateComponents([.day], from: weekStart, to: evStart).day ?? 0) + let lastIncl = (cal.dateComponents([.day], from: weekStart, to: evEnd).day ?? 0) - 1 + let ec = min(6, lastIncl) + return (sc, max(1, ec - sc + 1)) + } + + /// Greedy lane packing for events overlapping this week. + private func packEvents() -> (placed: [(event: CalEvent, lane: Int, startCol: Int, span: Int)], + extraPerCol: [Int]) { + let weekEndExclusive = cal.date(byAdding: .day, value: 7, to: weekStart)! + let evs = store.events(in: weekStart, end: weekEndExclusive) + .sorted { a, b in + if a.startDate != b.startDate { return a.startDate < b.startDate } + return a.endDate > b.endDate + } + var laneLastEnd: [Int] = [] + var placed: [(CalEvent, Int, Int, Int)] = [] + var overflowPerCol = [Int](repeating: 0, count: 7) + + for ev in evs { + let (sc, sp) = columnRange(for: ev) + var assigned: Int? = nil + for laneIdx in 0.. Void + let onCreateEvent: () -> Void + let onShowWeek: () -> Void + let onShowDay: () -> Void + + private var cal: Calendar { Calendar.current } + private var dayNum: Int { cal.component(.day, from: date) } + private var isFirstOfMonth: Bool { dayNum == 1 } + + private var monthAbbrev: String { + let f = DateFormatter() + f.locale = L10n.locale(language) + f.dateFormat = "LLL" + return f.string(from: date).uppercased() + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { Button(action: onTap) { - Text("\(Calendar.current.component(.day, from: date))") - .font(.system(size: 13, weight: isToday ? .bold : .regular)) - .foregroundStyle( - isToday ? Color.white : - isCurrentMonth ? Color.primary : Color.secondary.opacity(0.4) - ) - .frame(width: 26, height: 26) - .background(isToday ? Color.accentColor : Color.clear) - .clipShape(Circle()) + HStack(spacing: 4) { + Text("\(dayNum)") + .font(.system(size: 13, weight: isToday ? .bold : .regular)) + .foregroundStyle(isToday ? Color.white : textColor) + .frame(width: 22, height: 22) + .background(isToday ? Color.accentColor : Color.clear) + .clipShape(Circle()) + if isFirstOfMonth { + Text(monthAbbrev) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(monthLabelColor) + .lineLimit(1) + .fixedSize() + } + Spacer(minLength: 0) + } + .padding(.leading, 4) + .padding(.top, 2) } .buttonStyle(.plain) - .padding(.leading, 4) - .padding(.top, 2) - - // Events - ForEach(events.prefix(maxVisible)) { ev in - Button { onEventTap(ev) } label: { - EventChip(event: ev) - } - .buttonStyle(.plain) - } - - if events.count > maxVisible { - Text("+\(events.count - maxVisible)") - .font(.system(size: 9, weight: .medium)) - .foregroundStyle(.secondary) - .padding(.leading, 4) - } - Spacer(minLength: 0) + HStack(spacing: 0) { + if extraCount > 0 { + Text("+\(extraCount)") + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(textColor.opacity(0.6)) + .padding(.leading, 4) + } + Spacer(minLength: 0) + if let wn = weekNumber { + Text("\(cwLabel) \(wn)") + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(textColor.opacity(0.6)) + .padding(.trailing, 4) + } + } + .padding(.bottom, 1) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .overlay(alignment: .trailing) { - Rectangle().fill(Color(.separator)).frame(width: 0.5) + Rectangle().fill(lineColor.opacity(0.4)).frame(width: 0.5) + } + .overlay(alignment: .top) { + Rectangle() + .fill(edge == .topHighlight ? dividerColor : lineColor.opacity(0.3)) + .frame(height: edge == .topHighlight ? 1.5 : 0.5) } .overlay(alignment: .bottom) { - Rectangle().fill(Color(.separator)).frame(height: 0.5) + if edge == .bottomHighlight { + Rectangle().fill(dividerColor).frame(height: 1.5) + } + } + .contentShape(Rectangle()) + .contextMenu { + Button { onCreateEvent() } label: { + Label(L10n.t("cal.new_event", language), systemImage: "plus") + } + Button { onShowWeek() } label: { + Label(L10n.t("cal.show_in_week_view", language), + systemImage: "calendar.day.timeline.leading") + } + Button { onShowDay() } label: { + Label(L10n.t("cal.show_in_day_view", language), systemImage: "sun.max") + } } } } -private struct EventChip: View { +// MARK: – Event Bar + +private struct EventBar: View { let event: CalEvent var body: some View { HStack(spacing: 3) { - if !event.isAllDay { - Circle() - .fill(Color(hex: event.effectiveColor)) - .frame(width: 6, height: 6) - } Text(event.title) .font(.system(size: 10, weight: .medium)) .lineLimit(1) - .foregroundStyle(event.isAllDay ? .white : .primary) + .foregroundStyle(.white) + .padding(.leading, 4) + Spacer(minLength: 0) } - .padding(.horizontal, event.isAllDay ? 4 : 2) - .padding(.vertical, 1) .frame(maxWidth: .infinity, alignment: .leading) - .background(event.isAllDay ? Color(hex: event.effectiveColor) : Color.clear) + .background(Color(hex: event.effectiveColor)) .clipShape(RoundedRectangle(cornerRadius: 3)) - .padding(.horizontal, 2) } } diff --git a/Calendarr iOS/Views/Calendar/WeekView.swift b/Calendarr iOS/Views/Calendar/WeekView.swift index 613f4b7..fb0d0d1 100644 --- a/Calendarr iOS/Views/Calendar/WeekView.swift +++ b/Calendarr iOS/Views/Calendar/WeekView.swift @@ -3,7 +3,14 @@ import SwiftUI struct WeekView: View { let store: CalendarStore let onEventTap: (CalEvent) -> Void - let onTimeTap: (Date) -> Void + let onCreateEvent: (Date) -> Void + let onShowMonth: (Date) -> Void + let onShowDay: (Date) -> Void + + @AppStorage("appLanguage") private var appLang = "system" + @AppStorage("todayColor") private var todayHex = "#4285f4" + @AppStorage("textColor") private var textHex = "#FFFFFF" + @AppStorage("lineColor") private var lineHex = "#3A3A3C" private var cal: Calendar { store.userCalendar } @@ -49,10 +56,10 @@ struct WeekView: View { ForEach(weekDays, id: \.self) { day in Text(headerFmt.string(from: day).uppercased()) .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(cal.isDateInToday(day) ? Color.accentColor : .secondary) + .foregroundStyle(cal.isDateInToday(day) ? Color.accentColor : Color(hex: textHex).opacity(0.7)) .frame(maxWidth: .infinity, minHeight: 36) .overlay(alignment: .trailing) { - Rectangle().fill(Color(.separator)).frame(width: 0.5) + Rectangle().fill(Color(hex: lineHex)).frame(width: 0.5) } } } @@ -104,28 +111,23 @@ struct WeekView: View { ScrollViewReader { proxy in ScrollView { ZStack(alignment: .topLeading) { - // Background: time labels + vertical grid lines + // Background: time labels + per-hour cells per day HStack(alignment: .top, spacing: 0) { timeLabels ForEach(Array(weekDays.enumerated()), id: \.offset) { _, day in VStack(spacing: 0) { - ForEach(hours, id: \.self) { _ in - Rectangle() - .fill(Color(.separator).opacity(0.4)) - .frame(height: 0.5) - Color.clear.frame(height: hourHeight - 0.5) + ForEach(hours, id: \.self) { hour in + HourSlot(day: day, hour: hour, + hourHeight: hourHeight, + language: appLang, + onCreateEvent: onCreateEvent, + onShowMonth: onShowMonth, + onShowDay: onShowDay) } } .frame(width: colW) - .contentShape(Rectangle()) - .onTapGesture { loc in - let h = Int(loc.y / hourHeight) - let m = Int((loc.y.truncatingRemainder(dividingBy: hourHeight)) / hourHeight * 60) - let date = cal.date(bySettingHour: h, minute: m, second: 0, of: day) ?? day - onTimeTap(date) - } .overlay(alignment: .trailing) { - Rectangle().fill(Color(.separator)).frame(width: 0.5) + Rectangle().fill(Color(hex: lineHex)).frame(width: 0.5) } } } @@ -144,10 +146,11 @@ struct WeekView: View { // Current time line if let ti = todayIndex { let lineY = eventTop(Date.now) + let nowColor = Color(hex: todayHex) HStack(spacing: 0) { Spacer().frame(width: timeColumnWidth + CGFloat(ti) * colW - 4) - Circle().fill(Color.red).frame(width: 8, height: 8) - Rectangle().fill(Color.red).frame(width: colW - 4, height: 1.5) + Circle().fill(nowColor).frame(width: 8, height: 8) + Rectangle().fill(nowColor).frame(width: colW - 4, height: 1.5) } .offset(y: lineY - 0.75) } @@ -168,7 +171,7 @@ struct WeekView: View { Color.clear.frame(height: hourHeight) Text(String(format: "%02d:00", h)) .font(.system(size: 10)) - .foregroundStyle(.secondary) + .foregroundStyle(Color(hex: textHex).opacity(0.6)) .offset(y: -6) } } @@ -194,3 +197,39 @@ private func eventTop(_ date: Date) -> CGFloat { let m = CGFloat(cal.component(.minute, from: date)) return h * hourHeight + m * hourHeight / 60 } + +// One-hour slot with native long-press context menu. +struct HourSlot: View { + let day: Date + let hour: Int + let hourHeight: CGFloat + let language: String + let onCreateEvent: (Date) -> Void + let onShowMonth: (Date) -> Void + let onShowDay: (Date) -> Void + + @AppStorage("lineColor") private var lineHex = "#3A3A3C" + + private var date: Date { + Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day + } + + var body: some View { + VStack(spacing: 0) { + Rectangle().fill(Color(hex: lineHex).opacity(0.4)).frame(height: 0.5) + Color.clear.frame(height: hourHeight - 0.5) + } + .contentShape(Rectangle()) + .contextMenu { + Button { onCreateEvent(date) } label: { + Label(L10n.t("cal.new_event", language), systemImage: "plus") + } + Button { onShowMonth(date) } label: { + Label(L10n.t("cal.show_in_month_view", language), systemImage: "calendar") + } + Button { onShowDay(date) } label: { + Label(L10n.t("cal.show_in_day_view", language), systemImage: "sun.max") + } + } + } +} diff --git a/Calendarr iOS/Views/MenuSheet.swift b/Calendarr iOS/Views/MenuSheet.swift index 3187db1..1bb6a7f 100644 --- a/Calendarr iOS/Views/MenuSheet.swift +++ b/Calendarr iOS/Views/MenuSheet.swift @@ -4,6 +4,7 @@ struct MenuSheet: View { let api: CalendarrAPI @Environment(AppState.self) var appState @Environment(\.dismiss) var dismiss + @AppStorage("appLanguage") private var appLang = "system" var body: some View { NavigationStack { @@ -30,7 +31,7 @@ struct MenuSheet: View { } if appState.isAdmin { Spacer() - Text("Admin") + Text(L10n.t("menu.admin", appLang)) .font(.caption2.weight(.semibold)) .padding(.horizontal, 8).padding(.vertical, 3) .background(Color.accentColor.opacity(0.15)) @@ -41,30 +42,29 @@ struct MenuSheet: View { .padding(.vertical, 4) } - // Navigation links – direct destination syntax (no value-based nav) - Section("Einstellungen") { + Section(L10n.t("menu.section.settings", appLang)) { NavigationLink { ProfileView(api: api) } label: { - Label("Profil", systemImage: "person.circle") + Label(L10n.t("menu.profile", appLang), systemImage: "person.circle") } NavigationLink { SettingsView(api: api) } label: { - Label("Darstellung", systemImage: "paintpalette") + Label(L10n.t("menu.appearance", appLang), systemImage: "paintpalette") } NavigationLink { AccountsView(api: api) } label: { - Label("Konten & Kalender", systemImage: "tray.2") + Label(L10n.t("menu.accounts", appLang), systemImage: "tray.2") } NavigationLink { ServerView() } label: { - Label("Server", systemImage: "server.rack") + Label(L10n.t("menu.server", appLang), systemImage: "server.rack") } } @@ -75,15 +75,15 @@ struct MenuSheet: View { appState.logout() } } label: { - Label("Abmelden", systemImage: "rectangle.portrait.and.arrow.right") + Label(L10n.t("menu.logout", appLang), systemImage: "rectangle.portrait.and.arrow.right") } } } - .navigationTitle("Menü") + .navigationTitle(L10n.t("nav.menu", appLang)) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .primaryAction) { - Button("Fertig") { dismiss() } + Button(L10n.t("nav.done", appLang)) { dismiss() } } } } diff --git a/Calendarr iOS/Views/ProfileView.swift b/Calendarr iOS/Views/ProfileView.swift index f6aa5ad..a03417b 100644 --- a/Calendarr iOS/Views/ProfileView.swift +++ b/Calendarr iOS/Views/ProfileView.swift @@ -21,11 +21,13 @@ struct ProfileView: View { @State private var disablePW = "" @State private var isSaving2FA = false + @AppStorage("appLanguage") private var appLang = "system" + var body: some View { NavigationStack { Group { if isLoading { - ProgressView("Lade Profil…") + ProgressView(L10n.t("profile.loading", appLang)) } else if let profile { Form { kontoSection(profile: profile) @@ -34,7 +36,7 @@ struct ProfileView: View { } } } - .navigationTitle("Profil") + .navigationTitle(L10n.t("profile.title", appLang)) .navigationBarTitleDisplayMode(.large) .overlay(alignment: .bottom) { if showToast { @@ -68,29 +70,31 @@ struct ProfileView: View { } func kontoSection(profile: UserProfile) -> some View { - Section("Konto") { + Section(L10n.t("profile.account", appLang)) { HStack { - Text("Benutzername") + Text(L10n.t("profile.username", appLang)) Spacer() Text(profile.username) .foregroundStyle(.secondary) } HStack { - Text("Rolle") + Text(L10n.t("profile.role", appLang)) Spacer() - Text(profile.isAdmin ? "Administrator" : "Benutzer") + Text(profile.isAdmin + ? L10n.t("profile.role.admin", appLang) + : L10n.t("profile.role.user", appLang)) .foregroundStyle(.secondary) } HStack { - Text("E-Mail") + Text(L10n.t("profile.email", appLang)) Spacer() - TextField("Keine E-Mail", text: $newEmail) + TextField(L10n.t("profile.no_email", appLang), text: $newEmail) .multilineTextAlignment(.trailing) .foregroundStyle(.secondary) .keyboardType(.emailAddress) .textInputAutocapitalization(.never) } - Button("E-Mail speichern") { + Button(L10n.t("profile.save_email", appLang)) { Task { await saveEmail() } } .foregroundStyle(Color.accentColor) @@ -98,11 +102,11 @@ struct ProfileView: View { } var passwordSection: some View { - Section("Passwort ändern") { - SecureField("Aktuelles Passwort", text: $currentPW) - SecureField("Neues Passwort", text: $newPW) - SecureField("Neues Passwort wiederholen", text: $confirmPW) - Button("Passwort ändern") { + Section(L10n.t("profile.change_password", appLang)) { + SecureField(L10n.t("profile.current_password", appLang), text: $currentPW) + SecureField(L10n.t("profile.new_password", appLang), text: $newPW) + SecureField(L10n.t("profile.new_password_repeat", appLang), text: $confirmPW) + Button(L10n.t("profile.change_password", appLang)) { Task { await changePassword() } } .foregroundStyle(Color.accentColor) @@ -111,14 +115,14 @@ struct ProfileView: View { } func twoFASection(profile: UserProfile) -> some View { - Section("Zwei-Faktor-Authentifizierung") { + Section(L10n.t("profile.twofa", appLang)) { if profile.totpEnabled { HStack { Image(systemName: "checkmark.shield.fill") .foregroundStyle(.green) - Text("2FA ist aktiviert") + Text(L10n.t("profile.twofa.active", appLang)) } - Button("2FA deaktivieren") { + Button(L10n.t("profile.twofa.disable", appLang)) { show2FADisable = true } .foregroundStyle(.red) @@ -126,10 +130,10 @@ struct ProfileView: View { HStack { Image(systemName: "shield") .foregroundStyle(.secondary) - Text("2FA ist deaktiviert") + Text(L10n.t("profile.twofa.inactive", appLang)) .foregroundStyle(.secondary) } - Button("2FA einrichten") { + Button(L10n.t("profile.twofa.enable", appLang)) { Task { await setup2FA() } } .foregroundStyle(Color.accentColor) @@ -149,7 +153,7 @@ struct ProfileView: View { private func saveEmail() async { do { try await api.updateEmail(newEmail) - showNotice("E-Mail gespeichert") + showNotice(L10n.t("profile.email_saved", appLang)) } catch { showNotice(error.localizedDescription) } @@ -157,13 +161,13 @@ struct ProfileView: View { private func changePassword() async { guard newPW == confirmPW else { - showNotice("Passwörter stimmen nicht überein") + showNotice(L10n.t("profile.password_mismatch", appLang)) return } do { try await api.changePassword(current: currentPW, new: newPW) currentPW = ""; newPW = ""; confirmPW = "" - showNotice("Passwort geändert") + showNotice(L10n.t("profile.password_changed", appLang)) } catch { showNotice(error.localizedDescription) } @@ -186,7 +190,7 @@ struct ProfileView: View { do { try await api.enable2FA(code: totpCode) show2FASetup = false - showNotice("2FA aktiviert") + showNotice(L10n.t("profile.twofa.enabled_toast", appLang)) await load() } catch { showNotice(error.localizedDescription) @@ -198,7 +202,7 @@ struct ProfileView: View { do { try await api.disable2FA(password: disablePW) show2FADisable = false - showNotice("2FA deaktiviert") + showNotice(L10n.t("profile.twofa.disabled_toast", appLang)) await load() } catch { showNotice(error.localizedDescription) @@ -222,15 +226,16 @@ struct TwoFASetupSheet: View { let isSaving: Bool let onEnable: () -> Void @Environment(\.dismiss) var dismiss + @AppStorage("appLanguage") private var appLang = "system" var body: some View { NavigationStack { Form { Section { - Text("Scanne den QR-Code mit deiner Authenticator-App (z.B. Bitwarden, Google Authenticator).") + Text(L10n.t("twofa.scan_hint", appLang)) .font(.body) } - Section("QR-Code / Manueller Schlüssel") { + Section(L10n.t("twofa.qr_section", appLang)) { if let url = URL(string: qrURL) { AsyncImage(url: url) { phase in switch phase { @@ -259,17 +264,17 @@ struct TwoFASetupSheet: View { .foregroundStyle(Color.accentColor) } } - Section("Bestätigung") { - TextField("6-stelliger Code", text: $code) + Section(L10n.t("twofa.confirmation", appLang)) { + TextField(L10n.t("twofa.code_placeholder", appLang), text: $code) .keyboardType(.numberPad) } } - .navigationTitle("2FA einrichten") + .navigationTitle(L10n.t("twofa.setup_title", appLang)) .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } } + ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } } ToolbarItem(placement: .primaryAction) { - Button("Aktivieren") { onEnable() } + Button(L10n.t("twofa.activate", appLang)) { onEnable() } .bold() .disabled(code.count < 6 || isSaving) } @@ -282,20 +287,21 @@ struct TwoFADisableSheet: View { @Binding var password: String let onDisable: () -> Void @Environment(\.dismiss) var dismiss + @AppStorage("appLanguage") private var appLang = "system" var body: some View { NavigationStack { Form { - Section("Passwort zum Deaktivieren") { - SecureField("Passwort", text: $password) + Section(L10n.t("twofa.password_section", appLang)) { + SecureField(L10n.t("twofa.password_placeholder", appLang), text: $password) } } - .navigationTitle("2FA deaktivieren") + .navigationTitle(L10n.t("twofa.disable_title", appLang)) .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } } + ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } } ToolbarItem(placement: .primaryAction) { - Button("Deaktivieren") { onDisable() } + Button(L10n.t("twofa.disable", appLang)) { onDisable() } .bold() .foregroundStyle(.red) .disabled(password.isEmpty) diff --git a/Calendarr iOS/Views/ServerView.swift b/Calendarr iOS/Views/ServerView.swift index 33a5bc5..746e775 100644 --- a/Calendarr iOS/Views/ServerView.swift +++ b/Calendarr iOS/Views/ServerView.swift @@ -5,6 +5,7 @@ struct ServerView: View { @State private var showLogoutConfirm = false @State private var showChangeServer = false @State private var showImpressum = false + @AppStorage("appLanguage") private var appLang = "system" var serverHost: String { appState.serverURL @@ -15,7 +16,7 @@ struct ServerView: View { var body: some View { NavigationStack { Form { - Section("Verbundener Server") { + Section(L10n.t("server.connected", appLang)) { HStack { Image(systemName: "server.rack") .foregroundStyle(Color.accentColor) @@ -36,7 +37,7 @@ struct ServerView: View { Text(appState.username) if appState.isAdmin { Spacer() - Text("Admin") + Text(L10n.t("menu.admin", appLang)) .font(.caption.weight(.semibold)) .padding(.horizontal, 8) .padding(.vertical, 3) @@ -54,7 +55,7 @@ struct ServerView: View { HStack { Image(systemName: "arrow.triangle.swap") .frame(width: 28) - Text("Server wechseln") + Text(L10n.t("server.switch", appLang)) } } .foregroundStyle(.primary) @@ -65,48 +66,48 @@ struct ServerView: View { HStack { Image(systemName: "rectangle.portrait.and.arrow.right") .frame(width: 28) - Text("Abmelden") + Text(L10n.t("menu.logout", appLang)) } } } - Section("Info") { + Section(L10n.t("server.info", appLang)) { Button { showImpressum = true } label: { HStack { Image(systemName: "info.circle") .frame(width: 28) - Text("Impressum") + Text(L10n.t("server.imprint", appLang)) } } .foregroundStyle(.secondary) HStack { - Text("Version") + Text(L10n.t("server.version", appLang)) Spacer() Text("1.0") .foregroundStyle(.secondary) } } } - .navigationTitle("Server") + .navigationTitle(L10n.t("server.title", appLang)) .navigationBarTitleDisplayMode(.large) - .confirmationDialog("Abmelden", isPresented: $showLogoutConfirm, titleVisibility: .visible) { - Button("Abmelden", role: .destructive) { + .confirmationDialog(L10n.t("server.logout_title", appLang), isPresented: $showLogoutConfirm, titleVisibility: .visible) { + Button(L10n.t("menu.logout", appLang), role: .destructive) { appState.logout() } - Button("Abbrechen", role: .cancel) {} + Button(L10n.t("common.cancel", appLang), role: .cancel) {} } message: { - Text("Du wirst von \(serverHost) abgemeldet.") + Text(String(format: L10n.t("server.logout_msg", appLang), serverHost)) } - .confirmationDialog("Server wechseln", isPresented: $showChangeServer, titleVisibility: .visible) { - Button("Server wechseln", role: .destructive) { + .confirmationDialog(L10n.t("server.switch", appLang), isPresented: $showChangeServer, titleVisibility: .visible) { + Button(L10n.t("server.switch", appLang), role: .destructive) { appState.resetServer() } - Button("Abbrechen", role: .cancel) {} + Button(L10n.t("common.cancel", appLang), role: .cancel) {} } message: { - Text("Verbindung zu \(serverHost) wird getrennt und alle lokalen Anmeldedaten werden gelöscht.") + Text(String(format: L10n.t("server.switch_msg", appLang), serverHost)) } .sheet(isPresented: $showImpressum) { ImpressumView() @@ -117,34 +118,35 @@ struct ServerView: View { struct ImpressumView: View { @Environment(\.dismiss) var dismiss + @AppStorage("appLanguage") private var appLang = "system" var body: some View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 16) { Group { - Text("Scarriffleservices") + Text(L10n.t("imprint.company", appLang)) .font(.title2.bold()) - Text("Software & Webentwicklung") + Text(L10n.t("imprint.role", appLang)) .foregroundStyle(.secondary) } Divider() - Text("Diese Software wurde von Scarriffleservices mit grösster Sorgfalt entwickelt und bereitgestellt. Alle Rechte vorbehalten © 2026 Scarriffleservices.") + Text(L10n.t("imprint.copyright", appLang)) VStack(alignment: .leading, spacing: 6) { - Text("Datenspeicherung").font(.headline) - Text("Alle Anwendungsdaten werden auf dem Server gespeichert und verarbeitet, auf dem diese Calendarr-Instanz betrieben wird. Der Speicherort hängt damit vom Betreiber des jeweiligen Servers ab. Bei Nutzung der Google Kalender-Anbindung werden Daten über die Google API ausgetauscht; für diese Daten gelten die Datenschutzbestimmungen von Google. Bei Nutzung der Home Assistant-Anbindung werden Daten mit der jeweiligen Home Assistant-Instanz ausgetauscht. Home Assistant ist ein Projekt der Open Home Foundation.") + Text(L10n.t("imprint.storage.title", appLang)).font(.headline) + Text(L10n.t("imprint.storage.body", appLang)) } VStack(alignment: .leading, spacing: 6) { - Text("Haftungsausschluss").font(.headline) - Text("Trotz sorgfältiger Erstellung wird keine Haftung für die Richtigkeit, Vollständigkeit oder Aktualität der bereitgestellten Inhalte übernommen. Die Nutzung erfolgt auf eigene Verantwortung.") + Text(L10n.t("imprint.disclaimer.title", appLang)).font(.headline) + Text(L10n.t("imprint.disclaimer.body", appLang)) } VStack(alignment: .leading, spacing: 6) { - Text("Kontakt").font(.headline) + Text(L10n.t("imprint.contact.title", appLang)).font(.headline) Link("scarriffleservices@gmail.com", destination: URL(string: "mailto:scarriffleservices@gmail.com")!) } @@ -156,11 +158,11 @@ struct ImpressumView: View { } .padding(24) } - .navigationTitle("Impressum") + .navigationTitle(L10n.t("server.imprint", appLang)) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .primaryAction) { - Button("Schliessen") { dismiss() } + Button(L10n.t("common.close", appLang)) { dismiss() } } } } diff --git a/Calendarr iOS/Views/SettingsView.swift b/Calendarr iOS/Views/SettingsView.swift index 646e4de..3aaf2c7 100644 --- a/Calendarr iOS/Views/SettingsView.swift +++ b/Calendarr iOS/Views/SettingsView.swift @@ -7,14 +7,21 @@ struct SettingsView: View { @State private var isSaving = false @State private var toast = "" @State private var showToast = false - @AppStorage("liquidGlass") private var liquidGlass = false - @AppStorage("cacheMonths") private var cacheMonths = 3 + @AppStorage("liquidGlass") private var liquidGlass = false + @AppStorage("cacheMonths") private var cacheMonths = 3 + @AppStorage("appLanguage") private var appLang = "system" + @AppStorage("monthDividerColor") private var dividerHex = "#7090c0" + @AppStorage("monthLabelColor") private var labelHex = "#7090c0" + @AppStorage("todayColor") private var todayHex = "#4285f4" + @AppStorage("textColor") private var textHex = "#FFFFFF" + @AppStorage("backgroundColor") private var bgHex = "#000000" + @AppStorage("lineColor") private var lineHex = "#3A3A3C" var body: some View { NavigationStack { Group { if isLoading { - ProgressView("Lade Einstellungen…") + ProgressView(L10n.t("settings.loading", appLang)) } else { Form { liquidGlassSection @@ -28,7 +35,7 @@ struct SettingsView: View { } } } - .navigationTitle("Darstellung") + .navigationTitle(L10n.t("settings.title", appLang)) .navigationBarTitleDisplayMode(.large) .toolbar { ToolbarItem(placement: .primaryAction) { @@ -38,7 +45,7 @@ struct SettingsView: View { if isSaving { ProgressView() } else { - Text("Speichern").bold() + Text(L10n.t("settings.save", appLang)).bold() } } .disabled(isSaving) @@ -67,8 +74,8 @@ struct SettingsView: View { Toggle(isOn: $liquidGlass) { Label { VStack(alignment: .leading, spacing: 2) { - Text("Liquid Glass") - Text("Verwendet die neue iOS\u{202F}26 Glasoptik mit transparenter Navigationsleiste") + Text(L10n.t("settings.liquidglass", appLang)) + Text(L10n.t("settings.liquidglass.desc", appLang)) .font(.caption) .foregroundStyle(.secondary) } @@ -79,9 +86,9 @@ struct SettingsView: View { } .tint(Color.accentColor) } header: { - Text("App-Design") + Text(L10n.t("settings.appdesign", appLang)) } footer: { - Text("Änderung wirkt sofort – kein Neustart nötig.") + Text(L10n.t("settings.liquidglass.footer", appLang)) .font(.caption) } } @@ -93,8 +100,8 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 10) { Label { VStack(alignment: .leading, spacing: 2) { - Text("Vorladen") - Text("Events werden beim Start im Hintergrund für diesen Zeitraum geladen, danach ist Wischen sofort.") + Text(L10n.t("settings.cache.title", appLang)) + Text(L10n.t("settings.cache.desc", appLang)) .font(.caption) .foregroundStyle(.secondary) } @@ -103,19 +110,19 @@ struct SettingsView: View { .foregroundStyle(.green) } - Picker("Zeitraum", selection: $cacheMonths) { - Text("±1 Monat").tag(1) - Text("±3 Monate").tag(3) - Text("±6 Monate").tag(6) - Text("±1 Jahr").tag(12) + Picker(L10n.t("settings.cache.range", appLang), selection: $cacheMonths) { + Text(L10n.t("settings.cache.1m", appLang)).tag(1) + Text(L10n.t("settings.cache.3m", appLang)).tag(3) + Text(L10n.t("settings.cache.6m", appLang)).tag(6) + Text(L10n.t("settings.cache.1y", appLang)).tag(12) } .pickerStyle(.segmented) } .padding(.vertical, 4) } header: { - Text("Vorladen") + Text(L10n.t("settings.cache.header", appLang)) } footer: { - Text("Mehr Monate = längerer initialer Ladevorgang, danach komplett ohne Wartezeiten navigierbar.") + Text(L10n.t("settings.cache.footer", appLang)) .font(.caption) } } @@ -123,10 +130,11 @@ struct SettingsView: View { // MARK: – Sprache var spracheSection: some View { - Section("Sprache") { - Picker("Sprache", selection: $settings.language) { - Text("Deutsch").tag("de") - Text("English").tag("en") + Section(L10n.t("settings.language", appLang)) { + Picker(L10n.t("settings.language", appLang), selection: $appLang) { + Text(L10n.t("lang.system", appLang)).tag("system") + Text(L10n.t("lang.german", appLang)).tag("de") + Text(L10n.t("lang.english", appLang)).tag("en") } } } @@ -134,12 +142,15 @@ struct SettingsView: View { // MARK: – Farben var farbenSection: some View { - Section("Farben") { - ColorPickerRow(label: "Primärfarbe", hex: $settings.primaryColor) - ColorPickerRow(label: "Akzentfarbe", hex: $settings.accentColor) - ColorPickerRow(label: "Heutige-Tag-Farbe", hex: $settings.todayColor) - ColorPickerRow(label: "Monatswechsel-Linie", hex: $settings.monthDividerColor) - ColorPickerRow(label: "Monatskürzel", hex: $settings.monthLabelColor) + Section(L10n.t("settings.colors", appLang)) { + ColorPickerRow(label: L10n.t("settings.color.primary", appLang), hex: $settings.primaryColor) + ColorPickerRow(label: L10n.t("settings.color.accent", appLang), hex: $settings.accentColor) + ColorPickerRow(label: L10n.t("settings.color.today", appLang), hex: $todayHex) + ColorPickerRow(label: L10n.t("settings.color.text", appLang), hex: $textHex) + ColorPickerRow(label: L10n.t("settings.color.background", appLang), hex: $bgHex) + ColorPickerRow(label: L10n.t("settings.color.line", appLang), hex: $lineHex) + ColorPickerRow(label: L10n.t("settings.color.divider", appLang), hex: $dividerHex) + ColorPickerRow(label: L10n.t("settings.color.label", appLang), hex: $labelHex) } } @@ -148,18 +159,18 @@ struct SettingsView: View { var schriftSection: some View { Section { VStack(alignment: .leading, spacing: 10) { - Text("Schriftkontrast") + Text(L10n.t("settings.textcontrast", appLang)) .font(.headline) - Text("Helligkeit der Beschriftungen und Texte") + Text(L10n.t("settings.textcontrast.desc", appLang)) .font(.caption) .foregroundStyle(.secondary) ContrastSelector( value: $settings.textContrast, options: [ - (1, "Dunkel"), - (2, "Mittel"), - (3, "Hell"), - (4, "Maximum") + (1, L10n.t("settings.contrast.dark", appLang)), + (2, L10n.t("settings.contrast.medium", appLang)), + (3, L10n.t("settings.contrast.bright", appLang)), + (4, L10n.t("settings.contrast.max", appLang)) ] ) } @@ -172,18 +183,18 @@ struct SettingsView: View { var linienSection: some View { Section { VStack(alignment: .leading, spacing: 10) { - Text("Linienkontrast") + Text(L10n.t("settings.linecontrast", appLang)) .font(.headline) - Text("Sichtbarkeit von Trennlinien und Rahmen") + Text(L10n.t("settings.linecontrast.desc", appLang)) .font(.caption) .foregroundStyle(.secondary) ContrastSelector( value: $settings.lineContrast, options: [ - (1, "Kaum"), - (2, "Subtil"), - (3, "Normal"), - (4, "Stark") + (1, L10n.t("settings.linecontrast.barely", appLang)), + (2, L10n.t("settings.linecontrast.subtle", appLang)), + (3, L10n.t("settings.linecontrast.normal", appLang)), + (4, L10n.t("settings.linecontrast.strong", appLang)) ] ) } @@ -194,19 +205,19 @@ struct SettingsView: View { // MARK: – Ansicht var ansichtSection: some View { - Section("Kalenderansicht") { - Picker("Standardansicht", selection: $settings.defaultView) { - Text("Monat").tag("month") - Text("Woche").tag("week") - Text("Tag").tag("day") - Text("Quartal").tag("quarter") - Text("Termine").tag("agenda") + Section(L10n.t("settings.calview", appLang)) { + Picker(L10n.t("settings.defaultview", appLang), selection: $settings.defaultView) { + Text(L10n.t("view.month", appLang)).tag("month") + Text(L10n.t("view.week", appLang)).tag("week") + Text(L10n.t("view.day", appLang)).tag("day") + Text(L10n.t("view.quarter", appLang)).tag("quarter") + Text(L10n.t("view.agenda", appLang)).tag("agenda") } - Picker("Erster Wochentag", selection: $settings.weekStartDay) { - Text("Montag").tag("monday") - Text("Sonntag").tag("sunday") + Picker(L10n.t("settings.firstweekday", appLang), selection: $settings.weekStartDay) { + Text(L10n.t("settings.monday", appLang)).tag("monday") + Text(L10n.t("settings.sunday", appLang)).tag("sunday") } - Toggle("Vergangene Termine ausgrauen", isOn: $settings.dimPastEvents) + Toggle(L10n.t("settings.dimpast", appLang), isOn: $settings.dimPastEvents) .tint(Color.accentColor) } } @@ -216,18 +227,18 @@ struct SettingsView: View { var stundenSection: some View { Section { VStack(alignment: .leading, spacing: 10) { - Text("Stundenhöhe") + Text(L10n.t("settings.hourheight", appLang)) .font(.headline) - Text("Platz pro Stunde in der Wochen- & Tagesansicht") + Text(L10n.t("settings.hourheight.desc", appLang)) .font(.caption) .foregroundStyle(.secondary) ContrastSelector( value: $settings.hourHeight, options: [ - (28, "Kompakt"), - (44, "Normal"), - (60, "Komfort"), - (80, "Gross") + (28, L10n.t("settings.hourheight.compact", appLang)), + (44, L10n.t("settings.hourheight.normal", appLang)), + (60, L10n.t("settings.hourheight.comfort", appLang)), + (80, L10n.t("settings.hourheight.large", appLang)) ] ) } @@ -240,15 +251,31 @@ struct SettingsView: View { private func load() async { isLoading = true defer { isLoading = false } - if let s = try? await api.getSettings() { settings = s } + if let s = try? await api.getSettings() { + settings = s + // Mirror server-side color settings so calendar views (which read AppStorage) see them. + dividerHex = s.monthDividerColor + labelHex = s.monthLabelColor + todayHex = s.todayColor + textHex = s.textColor + bgHex = s.backgroundColor + lineHex = s.lineColor + } } private func save() async { isSaving = true defer { isSaving = false } + // Push local AppStorage colors back into the settings struct before saving. + settings.monthDividerColor = dividerHex + settings.monthLabelColor = labelHex + settings.todayColor = todayHex + settings.textColor = textHex + settings.backgroundColor = bgHex + settings.lineColor = lineHex do { try await api.updateSettings(settings) - showNotice("Gespeichert") + showNotice(L10n.t("settings.saved", appLang)) } catch { showNotice(error.localizedDescription) } diff --git a/Calendarr iOSTests/Calendarr_iOSTests.swift b/Calendarr iOSTests/Calendarr_iOSTests.swift new file mode 100644 index 0000000..cf2ca3f --- /dev/null +++ b/Calendarr iOSTests/Calendarr_iOSTests.swift @@ -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 + } + +} From d1004a91116920808ef7ae48836e1cf9fb98b552 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 25 May 2026 10:33:31 +0200 Subject: [PATCH 03/90] Fix month scroll event disappearance by improving visible month detection and cache refresh rules --- Calendarr iOS.xcodeproj/project.pbxproj | 218 ++++++++++++++++-- .../Views/Calendar/CalendarHostView.swift | 43 +++- Calendarr iOS/Views/Calendar/MonthView.swift | 27 ++- 3 files changed, 263 insertions(+), 25 deletions(-) diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index 35473f6..778f47f 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -6,6 +6,10 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + D0001A0CABCDEF0100AB5001 /* CalendarrWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; +/* End PBXBuildFile section */ + /* Begin PBXContainerItemProxy section */ 3927C7C02FB99D0F00EAD8ED /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -14,13 +18,45 @@ remoteGlobalIDString = C0000A01FB4E10100AB5001; remoteInfo = "Calendarr iOS"; }; + D0001A0DABCDEF0100AB5001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = C0000301FB4E10100AB5001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D0001A01ABCDEF0100AB5001; + remoteInfo = CalendarrWidgets; + }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + D0001A0BABCDEF0100AB5001 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + D0001A0CABCDEF0100AB5001 /* CalendarrWidgets.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 3927C7BC2FB99D0E00EAD8ED /* Calendarr iOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Calendarr iOSTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; C0000B01FC4E10100AB5001 /* Calendarr iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Calendarr iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CalendarrWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + D0001A0FABCDEF0100AB5001 /* Exceptions for "CalendarrWidgets" folder in "CalendarrWidgets" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = D0001A01ABCDEF0100AB5001 /* CalendarrWidgets */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 3927C7BD2FB99D0F00EAD8ED /* Calendarr iOSTests */ = { isa = PBXFileSystemSynchronizedRootGroup; @@ -32,6 +68,19 @@ path = "Calendarr iOS"; sourceTree = ""; }; + D0001A03ABCDEF0100AB5001 /* CalendarrWidgets */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + D0001A0FABCDEF0100AB5001 /* Exceptions for "CalendarrWidgets" folder in "CalendarrWidgets" target */, + ); + path = CalendarrWidgets; + sourceTree = ""; + }; + D0001A04ABCDEF0100AB5001 /* Shared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Shared; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -49,6 +98,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D0001A06ABCDEF0100AB5001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -56,6 +112,8 @@ isa = PBXGroup; children = ( C0000D01FC4E10100AB5001 /* Calendarr iOS */, + D0001A03ABCDEF0100AB5001 /* CalendarrWidgets */, + D0001A04ABCDEF0100AB5001 /* Shared */, 3927C7BD2FB99D0F00EAD8ED /* Calendarr iOSTests */, C0000C01FB4E10100AB5001 /* Products */, ); @@ -66,6 +124,7 @@ children = ( C0000B01FC4E10100AB5001 /* Calendarr iOS.app */, 3927C7BC2FB99D0E00EAD8ED /* Calendarr iOSTests.xctest */, + D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */, ); name = Products; sourceTree = ""; @@ -103,13 +162,16 @@ C0000701FB4E10100AB5001 /* Sources */, C0000801FB4E10100AB5001 /* Frameworks */, C0000901FB4E10100AB5001 /* Resources */, + D0001A0BABCDEF0100AB5001 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + D0001A0EABCDEF0100AB5001 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( C0000D01FC4E10100AB5001 /* Calendarr iOS */, + D0001A04ABCDEF0100AB5001 /* Shared */, ); name = "Calendarr iOS"; packageProductDependencies = ( @@ -118,6 +180,29 @@ productReference = C0000B01FC4E10100AB5001 /* Calendarr iOS.app */; productType = "com.apple.product-type.application"; }; + D0001A01ABCDEF0100AB5001 /* CalendarrWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = D0001A08ABCDEF0100AB5001 /* Build configuration list for PBXNativeTarget "CalendarrWidgets" */; + buildPhases = ( + D0001A05ABCDEF0100AB5001 /* Sources */, + D0001A06ABCDEF0100AB5001 /* Frameworks */, + D0001A07ABCDEF0100AB5001 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + D0001A03ABCDEF0100AB5001 /* CalendarrWidgets */, + D0001A04ABCDEF0100AB5001 /* Shared */, + ); + name = CalendarrWidgets; + packageProductDependencies = ( + ); + productName = CalendarrWidgets; + productReference = D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -135,6 +220,9 @@ C0000A01FB4E10100AB5001 = { CreatedOnToolsVersion = 26.4.1; }; + D0001A01ABCDEF0100AB5001 = { + CreatedOnToolsVersion = 26.5; + }; }; }; buildConfigurationList = C0000601FB4E10100AB5001 /* Build configuration list for PBXProject "Calendarr iOS" */; @@ -153,6 +241,7 @@ projectRoot = ""; targets = ( C0000A01FB4E10100AB5001 /* Calendarr iOS */, + D0001A01ABCDEF0100AB5001 /* CalendarrWidgets */, 3927C7BB2FB99D0E00EAD8ED /* Calendarr iOSTests */, ); }; @@ -173,6 +262,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D0001A07ABCDEF0100AB5001 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -190,6 +286,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D0001A05ABCDEF0100AB5001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -198,6 +301,11 @@ target = C0000A01FB4E10100AB5001 /* Calendarr iOS */; targetProxy = 3927C7C02FB99D0F00EAD8ED /* PBXContainerItemProxy */; }; + D0001A0EABCDEF0100AB5001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D0001A01ABCDEF0100AB5001 /* CalendarrWidgets */; + targetProxy = D0001A0DABCDEF0100AB5001 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -206,22 +314,23 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = PP34X97WS3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.5; MACOSX_DEPLOYMENT_TARGET = 26.5; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = "com.local.scarriffle.Calendarr-iOSTests"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; STRING_CATALOG_GENERATE_SYMBOLS = NO; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; + TARGETED_DEVICE_FAMILY = 1; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Calendarr iOS.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Calendarr iOS"; XROS_DEPLOYMENT_TARGET = 26.5; }; @@ -232,22 +341,23 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = PP34X97WS3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.5; MACOSX_DEPLOYMENT_TARGET = 26.5; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = "com.local.scarriffle.Calendarr-iOSTests"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; STRING_CATALOG_GENERATE_SYMBOLS = NO; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; + TARGETED_DEVICE_FAMILY = 1; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Calendarr iOS.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Calendarr iOS"; XROS_DEPLOYMENT_TARGET = 26.5; }; @@ -379,14 +489,15 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = PP34X97WS3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = Calendarr; INFOPLIST_KEY_CFBundleName = Calendarr; - "INFOPLIST_KEY_ITSAppUsesNonExemptEncryption" = NO; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -398,7 +509,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -420,14 +531,15 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = PP34X97WS3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = Calendarr; INFOPLIST_KEY_CFBundleName = Calendarr; - "INFOPLIST_KEY_ITSAppUsesNonExemptEncryption" = NO; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -439,7 +551,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.1; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -456,6 +568,75 @@ }; name = Release; }; + D0001A09ABCDEF0100AB5001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CODE_SIGN_ENTITLEMENTS = CalendarrWidgets/CalendarrWidgets.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 2; + DEVELOPMENT_TEAM = PP34X97WS3; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = CalendarrWidgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Calendarr Widgets"; + INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0.1; + PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.CalendarrWidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + D0001A0AABCDEF0100AB5001 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CODE_SIGN_ENTITLEMENTS = CalendarrWidgets/CalendarrWidgets.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 2; + DEVELOPMENT_TEAM = PP34X97WS3; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = CalendarrWidgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Calendarr Widgets"; + INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0.1; + PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.CalendarrWidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -486,6 +667,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + D0001A08ABCDEF0100AB5001 /* Build configuration list for PBXNativeTarget "CalendarrWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D0001A09ABCDEF0100AB5001 /* Debug */, + D0001A0AABCDEF0100AB5001 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = C0000301FB4E10100AB5001 /* Project object */; diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index 3fb638e..aaa9f5c 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -15,6 +15,7 @@ struct CalendarHostView: View { @State private var editingEvent: CalEvent? = nil @State private var selectedEvent: CalEvent? = nil @State private var visibleMonth: Date = .now + @State private var showFilter = false private var titleString: String { if store.viewType == .month { @@ -67,6 +68,9 @@ struct CalendarHostView: View { .onChange(of: store.viewType) { _, _ in Task { await onNavigate() } } .onChange(of: cacheMonths) { _, _ in Task { await recache() } } .onChange(of: visibleMonth) { _, new in Task { await ensureLoaded(around: new) } } + .onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in + store.syncBanishedFromDefaults() + } } // MARK: – Liquid Glass variant @@ -102,6 +106,11 @@ struct CalendarHostView: View { ToolbarItem(placement: .navigationBarTrailing) { HStack(spacing: 8) { viewPickerMenu + Button { showFilter = true } label: { + Image(systemName: "line.3.horizontal.decrease.circle") + .foregroundStyle(store.hiddenCalendarKeys.isEmpty ? .primary : Color.accentColor) + } + .accessibilityLabel(L10n.t("filter.button", appLang)) Button { showMenu = true } label: { Image(systemName: "line.3.horizontal") } } } @@ -114,6 +123,9 @@ struct CalendarHostView: View { .onChange(of: store.viewType) { _, _ in Task { await onNavigate() } } .onChange(of: cacheMonths) { _, _ in Task { await recache() } } .onChange(of: visibleMonth) { _, new in Task { await ensureLoaded(around: new) } } + .onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in + store.syncBanishedFromDefaults() + } } // MARK: – Top bar (flat mode) @@ -142,6 +154,7 @@ struct CalendarHostView: View { .minimumScaleFactor(0.7) Spacer(minLength: 8) viewPickerMenu + filterButton Button { showMenu = true } label: { Image(systemName: "line.3.horizontal") .font(.system(size: 18, weight: .medium)) @@ -153,6 +166,16 @@ struct CalendarHostView: View { .background(.bar) } + private var filterButton: some View { + Button { showFilter = true } label: { + Image(systemName: "line.3.horizontal.decrease.circle") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(store.hiddenCalendarKeys.isEmpty ? .primary : Color.accentColor) + .frame(width: 40, height: 40) + } + .accessibilityLabel(L10n.t("filter.button", appLang)) + } + private var viewPickerMenu: some View { Menu { ForEach(CalViewType.allCases, id: \.self) { vt in @@ -298,8 +321,8 @@ struct CalendarHostView: View { private var calendarSheets: CalendarSheets { CalendarSheets(store: store, showEditor: $showEditor, editorDate: $editorDate, editingEvent: $editingEvent, - selectedEvent: $selectedEvent, api: api, - reload: { await onNavigate() }) + selectedEvent: $selectedEvent, showFilter: $showFilter, + api: api, reload: { await onNavigate() }) } // MARK: – Loading logic @@ -327,13 +350,21 @@ struct CalendarHostView: View { await startup() } - /// Called when the user scrolls into a new month – fetches a ±1 month window - /// around it on demand. `loadEvents` skips the network if cached. + /// 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) } } @@ -346,6 +377,7 @@ private struct CalendarSheets: ViewModifier { @Binding var editorDate: Date @Binding var editingEvent: CalEvent? @Binding var selectedEvent: CalEvent? + @Binding var showFilter: Bool let api: CalendarrAPI let reload: () async -> Void @@ -364,5 +396,8 @@ private struct CalendarSheets: ViewModifier { await reload() } } + .sheet(isPresented: $showFilter) { + CalendarFilterSheet(api: api, store: store) + } } } diff --git a/Calendarr iOS/Views/Calendar/MonthView.swift b/Calendarr iOS/Views/Calendar/MonthView.swift index 8413407..f4f64c8 100644 --- a/Calendarr iOS/Views/Calendar/MonthView.swift +++ b/Calendarr iOS/Views/Calendar/MonthView.swift @@ -1,7 +1,9 @@ import SwiftUI -private let weeksBack = 104 -private let weeksAhead = 104 +// Cover ~10 years back and ~77 years ahead, so the scrollable range goes well +// past 2100 — enough room for any vacation that's actually getting planned. +private let weeksBack = 520 +private let weeksAhead = 4000 private let weekdayHeaderHeight: CGFloat = 28 private let dayNumberRowHeight: CGFloat = 22 private let laneHeight: CGFloat = 16 @@ -106,13 +108,24 @@ struct MonthView: View { cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date))! } - /// Treat the visible month as the one that "owns" Thursday of the current week — - /// matches ISO week-month conventions and avoids flicker on month boundaries. + /// Determine the visible month from the currently-scrolled week. + /// Instead of switching as soon as a few days of the next month appear, + /// we count the month affiliation of the visible week rows and keep the + /// month that occupies the majority of the viewport. private func publishVisibleMonth(from week: Date?) { guard let w = week else { return } - let thursday = cal.date(byAdding: .day, value: 3, to: w) ?? w - let m = cal.date(from: cal.dateComponents([.year, .month], from: thursday)) ?? thursday - if visibleMonth != m { visibleMonth = m } + + let visibleWeeks = (0..<6).compactMap { cal.date(byAdding: .weekOfYear, value: $0, to: w) } + let monthCounts = visibleWeeks.reduce(into: [Date: Int]()) { acc, weekStart in + guard let midWeek = cal.date(byAdding: .day, value: 3, to: weekStart) else { return } + let month = cal.date(from: cal.dateComponents([.year, .month], from: midWeek)) ?? midWeek + acc[month, default: 0] += 1 + } + + let selectedMonth = monthCounts.max { a, b in a.value < b.value }?.key + if let m = selectedMonth, visibleMonth != m { + visibleMonth = m + } } } From 6c506770ba6ed09c8be7979359a2b1ee024228f0 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 25 May 2026 11:53:02 +0200 Subject: [PATCH 04/90] Widget anpassung vorbereitung --- .../xcschemes/xcschememanagement.plist | 5 + Calendarr iOS/Calendarr iOS.entitlements | 10 + Calendarr iOS/Models/CalendarStore.swift | 160 +++++++++++++- Calendarr iOS/Models/Localization.swift | 28 +++ Calendarr iOS/Views/AccountsView.swift | 66 ++++++ Calendarr iOS/Views/CalendarFilterSheet.swift | 203 ++++++++++++++++++ Calendarr iOS/Views/ProfileView.swift | 15 ++ Calendarr iOS/Views/SettingsView.swift | 20 +- .../CalendarrTimelineProvider.swift | 53 +++++ .../CalendarrWidgets.entitlements | 10 + CalendarrWidgets/CalendarrWidgets.swift | 141 ++++++++++++ CalendarrWidgets/Info.plist | 29 +++ CalendarrWidgets/ThisWeekWidgetView.swift | 117 ++++++++++ CalendarrWidgets/ThreeDaysWidgetView.swift | 131 +++++++++++ CalendarrWidgets/TodayWidgetView.swift | 84 ++++++++ CalendarrWidgets/TwoDaysWidgetView.swift | 109 ++++++++++ CalendarrWidgets/TwoWeeksWidgetView.swift | 132 ++++++++++++ CalendarrWidgets/UpNextWidgetView.swift | 173 +++++++++++++++ CalendarrWidgets/UpcomingWidgetView.swift | 130 +++++++++++ CalendarrWidgets/WidgetSupport.swift | 90 ++++++++ Shared/WidgetData.swift | 135 ++++++++++++ 21 files changed, 1838 insertions(+), 3 deletions(-) create mode 100644 Calendarr iOS/Calendarr iOS.entitlements create mode 100644 Calendarr iOS/Views/CalendarFilterSheet.swift create mode 100644 CalendarrWidgets/CalendarrTimelineProvider.swift create mode 100644 CalendarrWidgets/CalendarrWidgets.entitlements create mode 100644 CalendarrWidgets/CalendarrWidgets.swift create mode 100644 CalendarrWidgets/Info.plist create mode 100644 CalendarrWidgets/ThisWeekWidgetView.swift create mode 100644 CalendarrWidgets/ThreeDaysWidgetView.swift create mode 100644 CalendarrWidgets/TodayWidgetView.swift create mode 100644 CalendarrWidgets/TwoDaysWidgetView.swift create mode 100644 CalendarrWidgets/TwoWeeksWidgetView.swift create mode 100644 CalendarrWidgets/UpNextWidgetView.swift create mode 100644 CalendarrWidgets/UpcomingWidgetView.swift create mode 100644 CalendarrWidgets/WidgetSupport.swift create mode 100644 Shared/WidgetData.swift diff --git a/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist b/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist index 0190ba4..c7f1ae5 100644 --- a/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist @@ -9,6 +9,11 @@ orderHint 0 + CalendarrWidgets.xcscheme_^#shared#^_ + + orderHint + 1 + SuppressBuildableAutocreation diff --git a/Calendarr iOS/Calendarr iOS.entitlements b/Calendarr iOS/Calendarr iOS.entitlements new file mode 100644 index 0000000..bb88d59 --- /dev/null +++ b/Calendarr iOS/Calendarr iOS.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.scarriffleservices.calendarr + + + diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index c3ebf80..431168c 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -1,6 +1,13 @@ import Foundation import SwiftUI +extension Notification.Name { + /// Posted whenever the persistent "banished calendars" set is mutated from + /// outside the active `CalendarStore` (e.g. by `AccountsView`). The store + /// listens for this in `CalendarHostView` and refreshes its filter. + static let banishedCalendarsChanged = Notification.Name("banishedCalendarsChanged") +} + enum CalViewType: String, CaseIterable { case month, week, day, quarter, agenda @@ -45,11 +52,112 @@ class CalendarStore { var weekStartsOnMonday = true var writableCalendars: [WritableCalendar] = [] + /// 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 = 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 = CalendarStore.loadBanishedKeys() + // 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 { + 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) { + hiddenCalendarKeys = keys + saveHiddenKeys() + let (s, e) = rangeForCurrentView() + refreshFromCache(start: s, end: e) + publishWidgetSnapshot() + } + + static func calendarKey(source: String, calendarId: String) -> String { + "\(source):\(calendarId)" + } + + // MARK: – Banished-calendar persistence + + private static let banishedKeysDefaultsKey = "banishedCalendarKeys" + + static func loadBanishedKeys() -> Set { + 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) { + 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() + } + + /// 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() + } + var userCalendar: Calendar { var cal = Calendar.current cal.firstWeekday = weekStartsOnMonday ? 2 : 1 @@ -67,7 +175,10 @@ class CalendarStore { /// Call this after navigation without hitting the network. func refreshFromCache(start: Date, end: Date) { events = allCachedEvents.filter { ev in - ev.startDate < end && ev.endDate > start + let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId) + return ev.startDate < end && ev.endDate > start + && !hiddenCalendarKeys.contains(key) + && !banishedCalendarKeys.contains(key) } } @@ -135,6 +246,53 @@ class CalendarStore { cachedStart = rangeStart cachedEnd = rangeEnd } + + publishWidgetSnapshot() + } + + /// Write a slim snapshot of the next ~6 weeks into the App-Group container + /// so the widget extension can render without a network call. 42 days + /// covers the worst-case month grid (6 rows × 7 cols) for the calendar + /// widget. Also asks the system to refresh the widget timeline. + private func publishWidgetSnapshot() { + let cal = userCalendar + let now = Date() + // Include the week before today so widgets that show the current week + // (e.g. "This Week", "Up Next + Calendar") have data for Monday–today. + let from = cal.date(byAdding: .day, value: -7, to: cal.startOfDay(for: now)) ?? now + let to = cal.date(byAdding: .day, value: 42, to: cal.startOfDay(for: now)) ?? from + let visible = allCachedEvents + .filter { ev in + let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId) + return ev.startDate < to && ev.endDate > from + && !hiddenCalendarKeys.contains(key) + && !banishedCalendarKeys.contains(key) + } + .sorted { $0.startDate < $1.startDate } + .prefix(500) + .map { ev in + WidgetEvent(id: ev.id, + title: ev.title, + start: ev.startDate, + end: ev.endDate, + isAllDay: ev.isAllDay, + colorHex: ev.effectiveColor, + location: ev.location) + } + let defaults = UserDefaults.standard + let snap = WidgetSnapshot( + writtenAt: now, + events: Array(visible), + todayColorHex: defaults.string(forKey: "todayColor") ?? "#4285f4", + textColorHex: defaults.string(forKey: "textColor") ?? "#FFFFFF", + backgroundColorHex: defaults.string(forKey: "backgroundColor") ?? "#000000", + lineColorHex: defaults.string(forKey: "lineColor") ?? "#3A3A3C", + primaryColorHex: defaults.string(forKey: "primaryColor") ?? "#4285f4", + accentColorHex: defaults.string(forKey: "accentColor") ?? "#ea4335", + language: defaults.string(forKey: "appLanguage") ?? "system" + ) + WidgetStore.write(snap) + WidgetTimelineNotifier.reload() } // MARK: – Writable calendars diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index 5841fad..515a06b 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -234,6 +234,20 @@ private let strings: [String: [String: String]] = [ "accounts.ha.header": "Home Assistant", "accounts.ha.empty": "Keine Home Assistant-Konten", "accounts.ha.add": "Home Assistant hinzufügen", + "profile.admin_note": "Hinweis: Die Benutzerverwaltung – sowohl das Erstellen als auch das Löschen von Benutzerkonten – erfolgt ausschließlich durch den Administrator des Servers.", + + // Kalender-Filter (Sidebar) + "filter.title": "Kalender", + "filter.loading": "Lade Kalender…", + "filter.empty": "Keine Kalender vorhanden", + "filter.show_all": "Alle anzeigen", + "filter.hide_all": "Alle ausblenden", + "filter.button": "Kalender ein-/ausblenden", + "filter.banish": "Dauerhaft ausblenden", + "filter.banished_footer": "Dauerhaft ausgeblendete Kalender erscheinen unter »Konten & Kalender« und können dort wieder eingeblendet werden.", + "accounts.banished_header": "Ausgeblendete Kalender", + "accounts.banished_unhide": "Wieder einblenden", + "accounts.banished_unknown": "Unbekannter Kalender", // CalDAV add sheet "caldav.section": "Konto-Details", @@ -474,6 +488,20 @@ private let strings: [String: [String: String]] = [ "accounts.ha.header": "Home Assistant", "accounts.ha.empty": "No Home Assistant accounts", "accounts.ha.add": "Add Home Assistant", + "profile.admin_note": "Note: User management — both the creation and deletion of user accounts — is handled exclusively by the server administrator.", + + // Calendar filter (sidebar) + "filter.title": "Calendars", + "filter.loading": "Loading calendars…", + "filter.empty": "No calendars available", + "filter.show_all": "Show all", + "filter.hide_all": "Hide all", + "filter.button": "Show/hide calendars", + "filter.banish": "Hide permanently", + "filter.banished_footer": "Permanently hidden calendars appear under “Accounts & Calendars”, where you can show them again.", + "accounts.banished_header": "Hidden calendars", + "accounts.banished_unhide": "Show again", + "accounts.banished_unknown": "Unknown calendar", // CalDAV add sheet "caldav.section": "Account details", diff --git a/Calendarr iOS/Views/AccountsView.swift b/Calendarr iOS/Views/AccountsView.swift index 9e9bf35..2817923 100644 --- a/Calendarr iOS/Views/AccountsView.swift +++ b/Calendarr iOS/Views/AccountsView.swift @@ -14,6 +14,7 @@ struct AccountsView: View { @State private var showAddICal = false @State private var showAddHA = false @State private var errorAlert: String? + @State private var banishedKeys: Set = CalendarStore.loadBanishedKeys() @AppStorage("appLanguage") private var appLang = "system" @@ -24,6 +25,7 @@ struct AccountsView: View { ProgressView(L10n.t("accounts.loading", appLang)) } else { List { + if !banishedKeys.isEmpty { banishedSection } caldavSection localSection icalSection @@ -180,6 +182,69 @@ struct AccountsView: View { } } + 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)) { + banishedKeys.remove(key) + CalendarStore.saveBanishedKeys(banishedKeys) + NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil) + } + .font(.callout) + .foregroundStyle(Color.accentColor) + } + } + } header: { + Text(L10n.t("accounts.banished_header", appLang)) + } + } + + 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 { @@ -210,6 +275,7 @@ struct AccountsView: View { private func load() async { isLoading = true + banishedKeys = CalendarStore.loadBanishedKeys() async let c = (try? await api.getCalDAVAccounts()) ?? [] async let l = (try? await api.getLocalCalendars()) ?? [] async let i = (try? await api.getICalSubscriptions()) ?? [] diff --git a/Calendarr iOS/Views/CalendarFilterSheet.swift b/Calendarr iOS/Views/CalendarFilterSheet.swift new file mode 100644 index 0000000..e932c1b --- /dev/null +++ b/Calendarr iOS/Views/CalendarFilterSheet.swift @@ -0,0 +1,203 @@ +import SwiftUI + +/// Lets the user toggle which calendars contribute events to the displayed +/// calendar views (and the home-screen widgets). Filtering is purely +/// client-side: hidden keys live in UserDefaults via `CalendarStore`. No +/// server roundtrip is required to toggle visibility. +struct CalendarFilterSheet: View { + let api: CalendarrAPI + let store: CalendarStore + @Environment(\.dismiss) private var dismiss + @AppStorage("appLanguage") private var appLang = "system" + + @State private var caldavAccounts: [CalDAVAccount] = [] + @State private var localCalendars: [LocalCalendar] = [] + @State private var icalSubs: [ICalSubscription] = [] + @State private var googleAccounts: [GoogleAccount] = [] + @State private var haAccounts: [HomeAssistantAccount] = [] + @State private var isLoading = true + @State private var hidden: Set = [] + @State private var banished: Set = [] + /// All non-banished keys discovered during load — used by bulk show/hide. + @State private var allKeys: Set = [] + + var body: some View { + NavigationStack { + Group { + if isLoading { + ProgressView(L10n.t("filter.loading", appLang)) + } else if allKeys.isEmpty { + Text(L10n.t("filter.empty", appLang)) + .foregroundStyle(.secondary) + } else { + List { + let visibleLocals = localCalendars.filter { + !banished.contains(CalendarStore.calendarKey(source: "local", calendarId: "\($0.id)")) + } + if !visibleLocals.isEmpty { + Section(L10n.t("accounts.local.header", appLang)) { + ForEach(visibleLocals) { cal in + row(name: cal.name, colorHex: cal.color, + key: CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)")) + } + } + } + ForEach(caldavAccounts) { acc in + let cals = (acc.calendars ?? []).filter { + !banished.contains(CalendarStore.calendarKey(source: "caldav", calendarId: "\($0.id)")) + } + if !cals.isEmpty { + Section(acc.name) { + ForEach(cals) { cal in + row(name: cal.name, + colorHex: cal.color ?? acc.color, + key: CalendarStore.calendarKey(source: "caldav", calendarId: "\(cal.id)")) + } + } + } + } + let visibleSubs = icalSubs.filter { + !banished.contains(CalendarStore.calendarKey(source: "ical", calendarId: "\($0.id)")) + } + if !visibleSubs.isEmpty { + Section(L10n.t("accounts.ical.header", appLang)) { + ForEach(visibleSubs) { sub in + row(name: sub.name, colorHex: sub.color, + key: CalendarStore.calendarKey(source: "ical", calendarId: "\(sub.id)")) + } + } + } + ForEach(googleAccounts) { acc in + let cals = (acc.calendars ?? []).filter { + !banished.contains(CalendarStore.calendarKey(source: "google", calendarId: "\($0.id)")) + } + if !cals.isEmpty { + Section(acc.email) { + ForEach(cals) { cal in + row(name: cal.name, + colorHex: cal.color ?? "#4285f4", + key: CalendarStore.calendarKey(source: "google", calendarId: "\(cal.id)")) + } + } + } + } + ForEach(haAccounts) { acc in + let cals = (acc.calendars ?? []).filter { + !banished.contains(CalendarStore.calendarKey(source: "homeassistant", calendarId: "\($0.id)")) + } + if !cals.isEmpty { + Section(acc.name) { + ForEach(cals) { cal in + row(name: cal.name, + colorHex: cal.color ?? "#46bdc6", + key: CalendarStore.calendarKey(source: "homeassistant", calendarId: "\(cal.id)")) + } + } + } + } + if !banished.isEmpty { + Section { + Text(L10n.t("filter.banished_footer", appLang)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + } + .navigationTitle(L10n.t("filter.title", appLang)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Menu { + Button(L10n.t("filter.show_all", appLang)) { + hidden = [] + store.setHiddenCalendars(hidden) + } + Button(L10n.t("filter.hide_all", appLang)) { + hidden = allKeys + store.setHiddenCalendars(hidden) + } + } label: { + Image(systemName: "ellipsis.circle") + } + .disabled(allKeys.isEmpty) + } + ToolbarItem(placement: .primaryAction) { + Button(L10n.t("nav.done", appLang)) { dismiss() } + } + } + } + .task { await load() } + } + + @ViewBuilder + private func row(name: String, colorHex: String, key: String) -> some View { + let isVisible = !hidden.contains(key) + Button { + if isVisible { hidden.insert(key) } else { hidden.remove(key) } + store.setCalendarHidden(key, hidden: !isVisible) + } label: { + HStack(spacing: 12) { + Circle() + .fill(Color(hex: colorHex)) + .frame(width: 14, height: 14) + .opacity(isVisible ? 1.0 : 0.35) + Text(name) + .foregroundStyle(isVisible ? .primary : .secondary) + .strikethrough(!isVisible, color: .secondary) + Spacer() + Image(systemName: isVisible ? "eye" : "eye.slash") + .foregroundStyle(isVisible ? Color.accentColor : .secondary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + hidden.remove(key) + banished.insert(key) + store.setCalendarBanished(key, banished: true) + } label: { + Label(L10n.t("filter.banish", appLang), systemImage: "archivebox") + } + } + } + + private func load() async { + isLoading = true + 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 keys = Set() + for cal in localCalendars { + keys.insert(CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)")) + } + for acc in caldavAccounts { + for cal in acc.calendars ?? [] { + keys.insert(CalendarStore.calendarKey(source: "caldav", calendarId: "\(cal.id)")) + } + } + for sub in icalSubs { + keys.insert(CalendarStore.calendarKey(source: "ical", calendarId: "\(sub.id)")) + } + for acc in googleAccounts { + for cal in acc.calendars ?? [] { + keys.insert(CalendarStore.calendarKey(source: "google", calendarId: "\(cal.id)")) + } + } + for acc in haAccounts { + for cal in acc.calendars ?? [] { + keys.insert(CalendarStore.calendarKey(source: "homeassistant", calendarId: "\(cal.id)")) + } + } + allKeys = keys + isLoading = false + } +} diff --git a/Calendarr iOS/Views/ProfileView.swift b/Calendarr iOS/Views/ProfileView.swift index a03417b..aa4117e 100644 --- a/Calendarr iOS/Views/ProfileView.swift +++ b/Calendarr iOS/Views/ProfileView.swift @@ -33,6 +33,7 @@ struct ProfileView: View { kontoSection(profile: profile) passwordSection twoFASection(profile: profile) + adminNoteSection } } } @@ -141,6 +142,20 @@ struct ProfileView: View { } } + var adminNoteSection: some View { + Section { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + .padding(.top, 1) + Text(L10n.t("profile.admin_note", appLang)) + .font(.footnote) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + } + } + private func load() async { isLoading = true defer { isLoading = false } diff --git a/Calendarr iOS/Views/SettingsView.swift b/Calendarr iOS/Views/SettingsView.swift index 3aaf2c7..f5eb66c 100644 --- a/Calendarr iOS/Views/SettingsView.swift +++ b/Calendarr iOS/Views/SettingsView.swift @@ -16,6 +16,8 @@ struct SettingsView: View { @AppStorage("textColor") private var textHex = "#FFFFFF" @AppStorage("backgroundColor") private var bgHex = "#000000" @AppStorage("lineColor") private var lineHex = "#3A3A3C" + @AppStorage("primaryColor") private var primaryHex = "#4285f4" + @AppStorage("accentColor") private var accentHex = "#ea4335" var body: some View { NavigationStack { @@ -65,6 +67,16 @@ struct SettingsView: View { .animation(.easeInOut, value: showToast) } .task { await load() } + // Live-update widgets the moment any appearance value changes, so the + // user sees the new colours without having to wait for the next event + // sync or save the settings. + .onChange(of: primaryHex) { _, _ in WidgetStore.republishAppearanceOnly() } + .onChange(of: accentHex) { _, _ in WidgetStore.republishAppearanceOnly() } + .onChange(of: todayHex) { _, _ in WidgetStore.republishAppearanceOnly() } + .onChange(of: textHex) { _, _ in WidgetStore.republishAppearanceOnly() } + .onChange(of: bgHex) { _, _ in WidgetStore.republishAppearanceOnly() } + .onChange(of: lineHex) { _, _ in WidgetStore.republishAppearanceOnly() } + .onChange(of: appLang) { _, _ in WidgetStore.republishAppearanceOnly() } } // MARK: – Liquid Glass @@ -143,8 +155,8 @@ struct SettingsView: View { var farbenSection: some View { Section(L10n.t("settings.colors", appLang)) { - ColorPickerRow(label: L10n.t("settings.color.primary", appLang), hex: $settings.primaryColor) - ColorPickerRow(label: L10n.t("settings.color.accent", appLang), hex: $settings.accentColor) + ColorPickerRow(label: L10n.t("settings.color.primary", appLang), hex: $primaryHex) + ColorPickerRow(label: L10n.t("settings.color.accent", appLang), hex: $accentHex) ColorPickerRow(label: L10n.t("settings.color.today", appLang), hex: $todayHex) ColorPickerRow(label: L10n.t("settings.color.text", appLang), hex: $textHex) ColorPickerRow(label: L10n.t("settings.color.background", appLang), hex: $bgHex) @@ -260,6 +272,8 @@ struct SettingsView: View { textHex = s.textColor bgHex = s.backgroundColor lineHex = s.lineColor + primaryHex = s.primaryColor + accentHex = s.accentColor } } @@ -273,6 +287,8 @@ struct SettingsView: View { settings.textColor = textHex settings.backgroundColor = bgHex settings.lineColor = lineHex + settings.primaryColor = primaryHex + settings.accentColor = accentHex do { try await api.updateSettings(settings) showNotice(L10n.t("settings.saved", appLang)) diff --git a/CalendarrWidgets/CalendarrTimelineProvider.swift b/CalendarrWidgets/CalendarrTimelineProvider.swift new file mode 100644 index 0000000..a3e52a2 --- /dev/null +++ b/CalendarrWidgets/CalendarrTimelineProvider.swift @@ -0,0 +1,53 @@ +import WidgetKit + +struct CalendarrEntry: TimelineEntry { + let date: Date + let snapshot: WidgetSnapshot? +} + +struct CalendarrTimelineProvider: TimelineProvider { + func placeholder(in context: Context) -> CalendarrEntry { + CalendarrEntry(date: .now, snapshot: WidgetStore.read()) + } + + func getSnapshot(in context: Context, completion: @escaping (CalendarrEntry) -> Void) { + completion(CalendarrEntry(date: .now, snapshot: WidgetStore.read())) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let snapshot = WidgetStore.read() + let now = Date() + + // Provide one entry per hour for the next 24h so the widget keeps + // re-rendering as time progresses (past events drop off, "now" advances). + var entries: [CalendarrEntry] = [] + for h in 0..<24 { + let date = Calendar.current.date(byAdding: .hour, value: h, to: now) ?? now + entries.append(CalendarrEntry(date: date, snapshot: snapshot)) + } + // Ask iOS to refresh in 30 min to pick up any new data the app wrote. + let refreshAt = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now + completion(Timeline(entries: entries, policy: .after(refreshAt))) + } +} + +// MARK: – Shared helpers used by all widget views + +enum WidgetHelpers { + static func events(for day: Date, in snapshot: WidgetSnapshot) -> [WidgetEvent] { + let cal = Calendar.current + let dayStart = cal.startOfDay(for: day) + let dayEnd = cal.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart + return snapshot.events + .filter { $0.start < dayEnd && $0.end > dayStart } + .sorted { $0.start < $1.start } + } + + static func upcoming(from now: Date, daysAhead: Int, in snapshot: WidgetSnapshot) -> [WidgetEvent] { + let cal = Calendar.current + let end = cal.date(byAdding: .day, value: daysAhead, to: cal.startOfDay(for: now)) ?? now + return snapshot.events + .filter { $0.end > now && $0.start < end } + .sorted { $0.start < $1.start } + } +} diff --git a/CalendarrWidgets/CalendarrWidgets.entitlements b/CalendarrWidgets/CalendarrWidgets.entitlements new file mode 100644 index 0000000..bb88d59 --- /dev/null +++ b/CalendarrWidgets/CalendarrWidgets.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.scarriffleservices.calendarr + + + diff --git a/CalendarrWidgets/CalendarrWidgets.swift b/CalendarrWidgets/CalendarrWidgets.swift new file mode 100644 index 0000000..9511c9e --- /dev/null +++ b/CalendarrWidgets/CalendarrWidgets.swift @@ -0,0 +1,141 @@ +import WidgetKit +import SwiftUI + +@main +struct CalendarrWidgetBundle: WidgetBundle { + var body: some Widget { + TodayWidget() + TwoDaysWidget() + ThreeDaysWidget() + ThisWeekWidget() + TwoWeeksWidget() + UpcomingWidget() + UpNextWidget() + } +} + +// Shared chrome modifier — keeps every widget on the same theme. +private struct CalendarrWidgetChrome: ViewModifier { + let snapshot: WidgetSnapshot? + + func body(content: Content) -> some View { + let lang = snapshot?.language ?? "system" + content + .containerBackground(for: .widget) { + Color(widgetHex: snapshot?.backgroundColorHex ?? "#000000") + } + .foregroundStyle(Color(widgetHex: snapshot?.textColorHex ?? "#FFFFFF")) + .environment(\.locale, WidgetL10n.locale(lang)) + } +} + +private extension View { + func calendarrChrome(_ snapshot: WidgetSnapshot?) -> some View { + modifier(CalendarrWidgetChrome(snapshot: snapshot)) + } +} + +// MARK: – Today (small) + +struct TodayWidget: Widget { + let kind: String = "TodayWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + TodayWidgetView(entry: entry).calendarrChrome(entry.snapshot) + } + .configurationDisplayName(WidgetL10n.t("widget.display.today_title", "system")) + .description(WidgetL10n.t("widget.display.today_desc", "system")) + .supportedFamilies([.systemSmall]) + } +} + +// MARK: – Today & Tomorrow (medium) + +struct TwoDaysWidget: Widget { + let kind: String = "TwoDaysWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + TwoDaysWidgetView(entry: entry).calendarrChrome(entry.snapshot) + } + .configurationDisplayName(WidgetL10n.t("widget.display.days_title", "system")) + .description(WidgetL10n.t("widget.display.days_desc", "system")) + .supportedFamilies([.systemMedium]) + } +} + +// MARK: – Three Days (medium) + +struct ThreeDaysWidget: Widget { + let kind: String = "ThreeDaysWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + ThreeDaysWidgetView(entry: entry).calendarrChrome(entry.snapshot) + } + .configurationDisplayName(WidgetL10n.t("widget.display.threedays_title", "system")) + .description(WidgetL10n.t("widget.display.threedays_desc", "system")) + .supportedFamilies([.systemMedium]) + } +} + +// MARK: – This Week (medium) + +struct ThisWeekWidget: Widget { + let kind: String = "ThisWeekWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + ThisWeekWidgetView(entry: entry).calendarrChrome(entry.snapshot) + } + .configurationDisplayName(WidgetL10n.t("widget.display.thisweek_title", "system")) + .description(WidgetL10n.t("widget.display.thisweek_desc", "system")) + .supportedFamilies([.systemMedium]) + } +} + +// MARK: – Two Weeks (medium) + +struct TwoWeeksWidget: Widget { + let kind: String = "TwoWeeksWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + TwoWeeksWidgetView(entry: entry).calendarrChrome(entry.snapshot) + } + .configurationDisplayName(WidgetL10n.t("widget.display.twoweeks_title", "system")) + .description(WidgetL10n.t("widget.display.twoweeks_desc", "system")) + .supportedFamilies([.systemMedium]) + } +} + +// MARK: – Upcoming (large + extra large on iPad) + +struct UpcomingWidget: Widget { + let kind: String = "UpcomingWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + UpcomingWidgetView(entry: entry).calendarrChrome(entry.snapshot) + } + .configurationDisplayName(WidgetL10n.t("widget.display.upcoming_title", "system")) + .description(WidgetL10n.t("widget.display.upcoming_desc", "system")) + .supportedFamilies([.systemLarge, .systemExtraLarge]) + } +} + +// MARK: – Up Next + Calendar (medium) + +struct UpNextWidget: Widget { + let kind: String = "UpNextWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + UpNextWidgetView(entry: entry).calendarrChrome(entry.snapshot) + } + .configurationDisplayName(WidgetL10n.t("widget.display.upnext_title", "system")) + .description(WidgetL10n.t("widget.display.upnext_desc", "system")) + .supportedFamilies([.systemMedium]) + } +} diff --git a/CalendarrWidgets/Info.plist b/CalendarrWidgets/Info.plist new file mode 100644 index 0000000..ec28a2e --- /dev/null +++ b/CalendarrWidgets/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Calendarr Widgets + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/CalendarrWidgets/ThisWeekWidgetView.swift b/CalendarrWidgets/ThisWeekWidgetView.swift new file mode 100644 index 0000000..e547ade --- /dev/null +++ b/CalendarrWidgets/ThisWeekWidgetView.swift @@ -0,0 +1,117 @@ +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 // Monday + 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: 2) { + 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)) + } + GeometryReader { geo in + let colW = geo.size.width / 7 + HStack(spacing: 0) { + ForEach(Array(weekDays.enumerated()), id: \.offset) { idx, day in + dayColumn(day, snapshot: s, primary: primary, accent: accent) + .frame(width: colW) + .overlay(alignment: .trailing) { + if idx < 6 { + Rectangle() + .fill(Color(widgetHex: s.lineColorHex).opacity(0.35)) + .frame(width: 0.5) + } + } + } + } + } + } + } 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(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: 15, height: 15) + .background(isToday ? primary : Color.clear) + .clipShape(Circle()) + ForEach(evs.prefix(2)) { ev in + eventPill(ev) + } + if evs.count > 2 { + Text("+\(evs.count - 2)") + .font(.system(size: 7)) + .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)) + } +} diff --git a/CalendarrWidgets/ThreeDaysWidgetView.swift b/CalendarrWidgets/ThreeDaysWidgetView.swift new file mode 100644 index 0000000..e303794 --- /dev/null +++ b/CalendarrWidgets/ThreeDaysWidgetView.swift @@ -0,0 +1,131 @@ +import SwiftUI +import WidgetKit + +struct ThreeDaysWidgetView: View { + let entry: CalendarrEntry + + private var snapshot: WidgetSnapshot? { entry.snapshot } + private var lang: String { snapshot?.language ?? "system" } + + private var cal: Calendar { + var c = Calendar(identifier: .gregorian) + c.locale = WidgetL10n.locale(lang) + return c + } + + private var days: [Date] { + let today = cal.startOfDay(for: entry.date) + return (0..<3).compactMap { cal.date(byAdding: .day, value: $0, to: today) } + } + + private var monthHeader: String { + let f = DateFormatter() + f.locale = WidgetL10n.locale(lang) + f.dateFormat = "LLLL yyyy" + return f.string(from: entry.date).uppercased() + } + + private var weekdayFmt: DateFormatter { + let f = DateFormatter() + f.locale = WidgetL10n.locale(lang) + f.dateFormat = "EEE" + return f + } + + private var timeFmt: DateFormatter { + let f = DateFormatter() + f.locale = WidgetL10n.locale(lang) + f.dateFormat = "HH:mm" + return f + } + + var body: some View { + if let s = snapshot { + let primary = Color(widgetHex: s.primaryColorHex) + let accent = Color(widgetHex: s.accentColorHex) + VStack(alignment: .leading, spacing: 3) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(monthHeader.split(separator: " ").first.map(String.init) ?? "") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(primary) + Text(monthHeader.split(separator: " ").dropFirst().joined(separator: " ")) + .font(.system(size: 11, weight: .semibold)) + } + HStack(spacing: 0) { + ForEach(Array(days.enumerated()), id: \.offset) { idx, day in + column(for: day, snapshot: s, primary: primary, accent: accent) + .frame(maxWidth: .infinity, alignment: .topLeading) + .overlay(alignment: .trailing) { + if idx < 2 { + Rectangle() + .fill(Color(widgetHex: s.lineColorHex).opacity(0.4)) + .frame(width: 0.5) + } + } + } + } + } + } else { + Text(WidgetL10n.t("widget.no_data", lang)) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func column(for day: Date, snapshot: WidgetSnapshot, primary: Color, accent: Color) -> some View { + let isToday = cal.isDateInToday(day) + let evs = WidgetHelpers.events(for: day, in: snapshot) + return VStack(alignment: .leading, spacing: 2) { + HStack { + Text(weekdayFmt.string(from: day).uppercased() + ".") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(isToday ? accent : .secondary) + Spacer() + Text("\(cal.component(.day, from: day))") + .font(.system(size: 11, weight: isToday ? .bold : .semibold)) + .foregroundStyle(isToday ? Color.white : Color.primary) + .frame(width: 17, height: 17) + .background(isToday ? primary : Color.clear) + .clipShape(Circle()) + } + .padding(.horizontal, 3) + if evs.isEmpty { + Text(WidgetL10n.t("widget.no_events", lang)) + .font(.system(size: 9)) + .foregroundStyle(.tertiary) + .padding(.horizontal, 3) + } else { + ForEach(evs.prefix(4)) { ev in + eventRow(ev) + } + if evs.count > 4 { + Text("+\(evs.count - 4)") + .font(.system(size: 8)) + .foregroundStyle(accent) + .padding(.leading, 3) + } + } + Spacer(minLength: 0) + } + } + + private func eventRow(_ ev: WidgetEvent) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 3) { + RoundedRectangle(cornerRadius: 1) + .fill(Color(widgetHex: ev.colorHex)) + .frame(width: 2) + Text(ev.title) + .font(.system(size: 9, weight: .medium)) + .lineLimit(1) + Spacer(minLength: 0) + } + Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start)) + .font(.system(size: 8)) + .foregroundStyle(.secondary) + .padding(.leading, 5) + } + .padding(.horizontal, 3) + } +} diff --git a/CalendarrWidgets/TodayWidgetView.swift b/CalendarrWidgets/TodayWidgetView.swift new file mode 100644 index 0000000..2caf11b --- /dev/null +++ b/CalendarrWidgets/TodayWidgetView.swift @@ -0,0 +1,84 @@ +import SwiftUI +import WidgetKit + +struct TodayWidgetView: View { + let entry: CalendarrEntry + + private var snapshot: WidgetSnapshot? { entry.snapshot } + + private var todayEvents: [WidgetEvent] { + guard let s = snapshot else { return [] } + return WidgetHelpers.events(for: entry.date, in: s) + } + + private var lang: String { snapshot?.language ?? "system" } + + private var timeFmt: DateFormatter { + let f = DateFormatter() + f.locale = WidgetL10n.locale(lang) + f.dateFormat = "HH:mm" + return f + } + + var body: some View { + let primary = Color(widgetHex: snapshot?.primaryColorHex ?? "#4285f4") + let accent = Color(widgetHex: snapshot?.accentColorHex ?? "#ea4335") + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(WidgetL10n.t("widget.today", lang)) + .font(.caption.weight(.bold)) + .foregroundStyle(primary) + Spacer() + Text(headerDate) + .font(.caption2) + .foregroundStyle(.secondary) + } + + if snapshot == nil { + Text(WidgetL10n.t("widget.no_data", lang)) + .font(.caption) + .foregroundStyle(.secondary) + } else if todayEvents.isEmpty { + Spacer() + Text(WidgetL10n.t("widget.no_events", lang)) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + } else { + ForEach(todayEvents.prefix(3)) { ev in + eventRow(ev) + } + if todayEvents.count > 3 { + Text(String(format: WidgetL10n.t("widget.more", lang), todayEvents.count - 3)) + .font(.caption2) + .foregroundStyle(accent) + } + Spacer(minLength: 0) + } + } + } + + private var headerDate: String { + let f = DateFormatter() + f.locale = WidgetL10n.locale(lang) + f.dateFormat = "d. MMM" + return f.string(from: entry.date) + } + + private func eventRow(_ ev: WidgetEvent) -> some View { + HStack(spacing: 6) { + RoundedRectangle(cornerRadius: 2) + .fill(Color(widgetHex: ev.colorHex)) + .frame(width: 3) + VStack(alignment: .leading, spacing: 1) { + Text(ev.title) + .font(.caption.weight(.medium)) + .lineLimit(1) + Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start)) + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + } +} diff --git a/CalendarrWidgets/TwoDaysWidgetView.swift b/CalendarrWidgets/TwoDaysWidgetView.swift new file mode 100644 index 0000000..70a258a --- /dev/null +++ b/CalendarrWidgets/TwoDaysWidgetView.swift @@ -0,0 +1,109 @@ +import SwiftUI +import WidgetKit + +struct TwoDaysWidgetView: View { + let entry: CalendarrEntry + + private var snapshot: WidgetSnapshot? { entry.snapshot } + private var lang: String { snapshot?.language ?? "system" } + + private var today: Date { Calendar.current.startOfDay(for: entry.date) } + private var tomorrow: Date { + Calendar.current.date(byAdding: .day, value: 1, to: today) ?? today + } + + private var timeFmt: DateFormatter { + let f = DateFormatter() + f.locale = WidgetL10n.locale(lang) + f.dateFormat = "HH:mm" + return f + } + + var body: some View { + if let s = snapshot { + let primary = Color(widgetHex: s.primaryColorHex) + let accent = Color(widgetHex: s.accentColorHex) + HStack(spacing: 8) { + column(for: today, + title: WidgetL10n.t("widget.today", lang), + isToday: true, + events: WidgetHelpers.events(for: today, in: s), + primary: primary, accent: accent, + lineColor: Color(widgetHex: s.lineColorHex)) + Rectangle() + .fill(Color(widgetHex: s.lineColorHex).opacity(0.4)) + .frame(width: 0.5) + column(for: tomorrow, + title: WidgetL10n.t("widget.tomorrow", lang), + isToday: false, + events: WidgetHelpers.events(for: tomorrow, in: s), + primary: primary, accent: accent, + lineColor: Color(widgetHex: s.lineColorHex)) + } + } else { + Text(WidgetL10n.t("widget.no_data", lang)) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func column(for day: Date, + title: String, + isToday: Bool, + events: [WidgetEvent], + primary: Color, + accent: Color, + lineColor: Color) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(title) + .font(.caption.weight(.bold)) + .foregroundStyle(isToday ? primary : accent) + Text(shortDate(day)) + .font(.caption2) + .foregroundStyle(.tertiary) + } + if events.isEmpty { + Text(WidgetL10n.t("widget.no_events", lang)) + .font(.caption2) + .foregroundStyle(.secondary) + } else { + ForEach(events.prefix(4)) { ev in + eventRow(ev) + } + if events.count > 4 { + Text(String(format: WidgetL10n.t("widget.more", lang), events.count - 4)) + .font(.system(size: 9)) + .foregroundStyle(accent) + } + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .topLeading) + } + + private func eventRow(_ ev: WidgetEvent) -> some View { + HStack(spacing: 4) { + RoundedRectangle(cornerRadius: 1.5) + .fill(Color(widgetHex: ev.colorHex)) + .frame(width: 2) + VStack(alignment: .leading, spacing: 0) { + Text(ev.title) + .font(.system(size: 11, weight: .medium)) + .lineLimit(1) + Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start)) + .font(.system(size: 9)) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + } + + private func shortDate(_ d: Date) -> String { + let f = DateFormatter() + f.locale = WidgetL10n.locale(lang) + f.dateFormat = "d. MMM" + return f.string(from: d) + } +} diff --git a/CalendarrWidgets/TwoWeeksWidgetView.swift b/CalendarrWidgets/TwoWeeksWidgetView.swift new file mode 100644 index 0000000..6d18738 --- /dev/null +++ b/CalendarrWidgets/TwoWeeksWidgetView.swift @@ -0,0 +1,132 @@ +import SwiftUI +import WidgetKit + +struct TwoWeeksWidgetView: View { + let entry: CalendarrEntry + + private var snapshot: WidgetSnapshot? { entry.snapshot } + private var lang: String { snapshot?.language ?? "system" } + + private var cal: Calendar { + var c = Calendar(identifier: .gregorian) + c.locale = WidgetL10n.locale(lang) + c.firstWeekday = 2 + return c + } + + private var weekStart: Date { + cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: entry.date)) ?? entry.date + } + + private var fortnight: [Date] { + (0..<14).compactMap { cal.date(byAdding: .day, value: $0, to: weekStart) } + } + + private var monthHeader: String { + let f = DateFormatter() + f.locale = WidgetL10n.locale(lang) + f.dateFormat = "LLLL yyyy" + return f.string(from: weekStart).uppercased() + } + + private var weekdayHeaders: [String] { + let f = DateFormatter(); f.locale = WidgetL10n.locale(lang) + let symbols = f.veryShortWeekdaySymbols ?? cal.veryShortWeekdaySymbols + let start = cal.firstWeekday - 1 + return (0..<7).map { symbols[(start + $0) % 7] } + } + + var body: some View { + if let s = snapshot { + let primary = Color(widgetHex: s.primaryColorHex) + let accent = Color(widgetHex: s.accentColorHex) + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(monthHeader.split(separator: " ").first.map(String.init) ?? "") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(primary) + Text(monthHeader.split(separator: " ").dropFirst().joined(separator: " ")) + .font(.system(size: 9, weight: .semibold)) + } + weekdayRow(accent: accent) + GeometryReader { geo in + let colW = geo.size.width / 7 + let rowH = geo.size.height / 2 + VStack(spacing: 0) { + ForEach(0..<2, id: \.self) { row in + HStack(spacing: 0) { + ForEach(0..<7, id: \.self) { col in + let day = fortnight[row * 7 + col] + dayCell(day, snapshot: s, primary: primary, accent: accent) + .frame(width: colW, height: rowH) + .overlay(alignment: .trailing) { + if col < 6 { + Rectangle() + .fill(Color(widgetHex: s.lineColorHex).opacity(0.35)) + .frame(width: 0.5) + } + } + .overlay(alignment: .top) { + if row == 1 { + Rectangle() + .fill(Color(widgetHex: s.lineColorHex).opacity(0.35)) + .frame(height: 0.5) + } + } + } + } + } + } + } + } + } else { + Text(WidgetL10n.t("widget.no_data", lang)) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func weekdayRow(accent: Color) -> some View { + HStack(spacing: 0) { + ForEach(weekdayHeaders, id: \.self) { h in + Text(h) + .font(.system(size: 7, weight: .bold)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + } + } + } + + private func dayCell(_ day: Date, + snapshot: WidgetSnapshot, + primary: Color, + accent: Color) -> some View { + let isToday = cal.isDateInToday(day) + let evs = WidgetHelpers.events(for: day, in: snapshot) + return VStack(alignment: .center, spacing: 0.5) { + Text("\(cal.component(.day, from: day))") + .font(.system(size: 8.5, weight: isToday ? .bold : .semibold)) + .foregroundStyle(isToday ? Color.white : Color.primary) + .frame(width: 12, height: 12) + .background(isToday ? primary : Color.clear) + .clipShape(Circle()) + // Up to 3 colored dots + HStack(spacing: 1) { + ForEach(evs.prefix(3).indices, id: \.self) { i in + Circle() + .fill(Color(widgetHex: evs[i].colorHex)) + .frame(width: 3, height: 3) + } + } + .frame(height: 3) + if evs.count > 3 { + Text("+\(evs.count - 3)") + .font(.system(size: 6)) + .foregroundStyle(accent) + } + Spacer(minLength: 0) + } + .padding(.top, 1) + } +} diff --git a/CalendarrWidgets/UpNextWidgetView.swift b/CalendarrWidgets/UpNextWidgetView.swift new file mode 100644 index 0000000..a0bed0c --- /dev/null +++ b/CalendarrWidgets/UpNextWidgetView.swift @@ -0,0 +1,173 @@ +import SwiftUI +import WidgetKit + +struct UpNextWidgetView: View { + let entry: CalendarrEntry + + private var snapshot: WidgetSnapshot? { entry.snapshot } + private var lang: String { snapshot?.language ?? "system" } + + private var cal: Calendar { + var c = Calendar(identifier: .gregorian) + c.locale = WidgetL10n.locale(lang) + c.firstWeekday = 2 + return c + } + + private var todayEvents: [WidgetEvent] { + guard let s = snapshot else { return [] } + return WidgetHelpers.events(for: entry.date, in: s) + } + + /// Mini-month grid: 6 rows × 7 cols starting from the first weekday of the + /// month, padded with neighbouring days where necessary. + private var monthGrid: [Date] { + let firstOfMonth = cal.date(from: cal.dateComponents([.year, .month], from: entry.date)) ?? entry.date + let weekday = cal.component(.weekday, from: firstOfMonth) + let offset = ((weekday - cal.firstWeekday) + 7) % 7 + let gridStart = cal.date(byAdding: .day, value: -offset, to: firstOfMonth) ?? firstOfMonth + return (0..<42).compactMap { cal.date(byAdding: .day, value: $0, to: gridStart) } + } + + private var weekdayHeaders: [String] { + let f = DateFormatter(); f.locale = WidgetL10n.locale(lang) + let symbols = f.veryShortWeekdaySymbols ?? cal.veryShortWeekdaySymbols + let start = cal.firstWeekday - 1 + return (0..<7).map { symbols[(start + $0) % 7] } + } + + private var weekdayFmt: DateFormatter { + let f = DateFormatter() + f.locale = WidgetL10n.locale(lang) + f.dateFormat = "EEE" + return f + } + + private var monthNameFmt: DateFormatter { + let f = DateFormatter() + f.locale = WidgetL10n.locale(lang) + f.dateFormat = "LLL" + return f + } + + private var timeFmt: DateFormatter { + let f = DateFormatter() + f.locale = WidgetL10n.locale(lang) + f.dateFormat = "HH:mm" + return f + } + + var body: some View { + if let s = snapshot { + let primary = Color(widgetHex: s.primaryColorHex) + let accent = Color(widgetHex: s.accentColorHex) + HStack(spacing: 8) { + leftPanel(snapshot: s, primary: primary, accent: accent) + .frame(maxWidth: .infinity, alignment: .topLeading) + miniMonth(snapshot: s, primary: primary, accent: accent) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } else { + Text(WidgetL10n.t("widget.no_data", lang)) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func leftPanel(snapshot: WidgetSnapshot, primary: Color, accent: Color) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text("\(cal.component(.day, from: entry.date))") + .font(.system(size: 17, weight: .bold)) + .foregroundStyle(Color.white) + .frame(width: 26, height: 26) + .background(primary) + .clipShape(RoundedRectangle(cornerRadius: 5)) + VStack(alignment: .leading, spacing: 0) { + Text(weekdayFmt.string(from: entry.date).uppercased() + ".") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(accent) + Text(monthNameFmt.string(from: entry.date)) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + } + } + if todayEvents.isEmpty { + Text(WidgetL10n.t("widget.no_events", lang)) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } else { + ForEach(todayEvents.prefix(3)) { ev in + HStack(alignment: .top, spacing: 4) { + Circle() + .fill(Color(widgetHex: ev.colorHex)) + .frame(width: 5, height: 5) + .padding(.top, 4) + VStack(alignment: .leading, spacing: 0) { + Text(ev.title) + .font(.system(size: 10, weight: .semibold)) + .lineLimit(1) + Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start)) + .font(.system(size: 9)) + .foregroundStyle(.secondary) + } + } + } + } + Spacer(minLength: 0) + } + } + + private func miniMonth(snapshot: WidgetSnapshot, primary: Color, accent: Color) -> some View { + VStack(spacing: 1) { + HStack(spacing: 0) { + ForEach(weekdayHeaders, id: \.self) { h in + Text(h) + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + } + } + GeometryReader { geo in + let cellW = geo.size.width / 7 + let cellH = geo.size.height / 6 + VStack(spacing: 0) { + ForEach(0..<6, id: \.self) { row in + HStack(spacing: 0) { + ForEach(0..<7, id: \.self) { col in + miniDay(monthGrid[row * 7 + col], + snapshot: snapshot, + primary: primary, + accent: accent) + .frame(width: cellW, height: cellH) + } + } + } + } + } + } + } + + private func miniDay(_ day: Date, snapshot: WidgetSnapshot, primary: Color, accent: Color) -> some View { + let isToday = cal.isDateInToday(day) + let inMonth = cal.isDate(day, equalTo: entry.date, toGranularity: .month) + let hasEvents = !WidgetHelpers.events(for: day, in: snapshot).isEmpty + return ZStack { + if isToday { + RoundedRectangle(cornerRadius: 3) + .fill(primary) + } else if hasEvents && inMonth { + RoundedRectangle(cornerRadius: 3) + .fill(accent.opacity(0.20)) + } + Text("\(cal.component(.day, from: day))") + .font(.system(size: 9, weight: isToday ? .bold : .medium)) + .foregroundStyle( + isToday ? Color.white : + inMonth ? Color.primary : Color.secondary.opacity(0.4) + ) + } + .padding(0.5) + } +} diff --git a/CalendarrWidgets/UpcomingWidgetView.swift b/CalendarrWidgets/UpcomingWidgetView.swift new file mode 100644 index 0000000..a48756c --- /dev/null +++ b/CalendarrWidgets/UpcomingWidgetView.swift @@ -0,0 +1,130 @@ +import SwiftUI +import WidgetKit + +private let rowHeight: CGFloat = 16 +private let dayHeaderHeight: CGFloat = 14 +private let maxEventsPerDay: Int = 3 +private let maxTotalRows: Int = 15 + +struct UpcomingWidgetView: View { + let entry: CalendarrEntry + + private var snapshot: WidgetSnapshot? { entry.snapshot } + private var lang: String { snapshot?.language ?? "system" } + + private var groupedWithLimits: [(Date, [WidgetEvent], Int)] { + guard let s = snapshot else { return [] } + let cal = Calendar.current + let now = entry.date + let events = WidgetHelpers.upcoming(from: now, daysAhead: 5, in: s) + var buckets: [Date: [WidgetEvent]] = [:] + for ev in events { + let key = cal.startOfDay(for: ev.start) + buckets[key, default: []].append(ev) + } + + var result: [(Date, [WidgetEvent], Int)] = [] + var totalRows = 0 + + for date in buckets.keys.sorted() { + let allEventsForDay = buckets[date] ?? [] + let eventsToShow = Array(allEventsForDay.prefix(maxEventsPerDay)) + let hiddenCount = allEventsForDay.count - eventsToShow.count + + // Account for day header + event rows + potential "more" row + let rowsForThisDay = 1 + eventsToShow.count + (hiddenCount > 0 ? 1 : 0) + + if totalRows + rowsForThisDay <= maxTotalRows { + result.append((date, eventsToShow, hiddenCount)) + totalRows += rowsForThisDay + } else { + break + } + } + + return result + } + + private var timeFmt: DateFormatter { + let f = DateFormatter() + f.locale = WidgetL10n.locale(lang) + f.dateFormat = "HH:mm" + return f + } + + private var dayFmt: DateFormatter { + let f = DateFormatter() + f.locale = WidgetL10n.locale(lang) + f.dateFormat = "EEE d. MMM" + return f + } + + var body: some View { + let primary = Color(widgetHex: snapshot?.primaryColorHex ?? "#4285f4") + let accent = Color(widgetHex: snapshot?.accentColorHex ?? "#ea4335") + VStack(alignment: .leading, spacing: 2) { + Text(WidgetL10n.t("widget.upcoming", lang)) + .font(.caption.weight(.bold)) + .foregroundStyle(primary) + .padding(.bottom, 2) + if snapshot == nil { + Text(WidgetL10n.t("widget.no_data", lang)) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if groupedWithLimits.isEmpty { + Text(WidgetL10n.t("widget.no_events", lang)) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + VStack(alignment: .leading, spacing: 2) { + ForEach(groupedWithLimits, id: \.0) { day, evs, hiddenCount in + dayHeader(d: day, accent: accent) + ForEach(evs) { ev in + eventRow(ev) + } + if hiddenCount > 0 { + moreRow(count: hiddenCount, accent: accent) + } + } + } + Spacer(minLength: 0) + } + } + } + + private func dayHeader(d: Date, accent: Color) -> some View { + let cal = Calendar.current + let isToday = cal.isDateInToday(d) + return Text(dayFmt.string(from: d)) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(isToday ? accent : .secondary) + .frame(height: dayHeaderHeight, alignment: .bottomLeading) + .padding(.top, 1) + } + + private func eventRow(_ ev: WidgetEvent) -> some View { + HStack(spacing: 6) { + RoundedRectangle(cornerRadius: 1.5) + .fill(Color(widgetHex: ev.colorHex)) + .frame(width: 2.5) + Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start)) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .frame(width: 38, alignment: .leading) + Text(ev.title) + .font(.system(size: 10, weight: .medium)) + .lineLimit(1) + Spacer(minLength: 0) + } + .frame(height: rowHeight) + } + + private func moreRow(count: Int, accent: Color) -> some View { + Text(String(format: WidgetL10n.t("widget.more", lang), count)) + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(accent) + .frame(height: rowHeight) + } +} diff --git a/CalendarrWidgets/WidgetSupport.swift b/CalendarrWidgets/WidgetSupport.swift new file mode 100644 index 0000000..56c1aa4 --- /dev/null +++ b/CalendarrWidgets/WidgetSupport.swift @@ -0,0 +1,90 @@ +import SwiftUI + +// Local copy of the Color(hex:) initializer, since the widget extension +// is a separate target and cannot import the main app's Color extension. +extension Color { + init(widgetHex hex: String) { + let cleaned = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: cleaned).scanHexInt64(&int) + let r, g, b: UInt64 + switch cleaned.count { + case 6: + (r, g, b) = ((int >> 16) & 0xFF, (int >> 8) & 0xFF, int & 0xFF) + default: + (r, g, b) = (0, 0, 0) + } + self.init(red: Double(r) / 255, green: Double(g) / 255, blue: Double(b) / 255) + } +} + +enum WidgetL10n { + static func t(_ key: String, _ stored: String) -> String { + let lang: String + if stored == "de" || stored == "en" { lang = stored } + else { + let pref = Locale.preferredLanguages.first ?? "en" + lang = pref.lowercased().hasPrefix("de") ? "de" : "en" + } + return strings[lang]?[key] ?? strings["en"]?[key] ?? key + } + + static func locale(_ stored: String) -> Locale { + let lang: String + if stored == "de" || stored == "en" { lang = stored } + else { + let pref = Locale.preferredLanguages.first ?? "en" + lang = pref.lowercased().hasPrefix("de") ? "de" : "en" + } + return Locale(identifier: lang) + } + + private static let strings: [String: [String: String]] = [ + "de": [ + "widget.today": "Heute", + "widget.tomorrow": "Morgen", + "widget.no_events": "Keine Termine", + "widget.allday": "Ganztägig", + "widget.more": "+%d weitere", + "widget.upcoming": "Nächste 5 Tage", + "widget.no_data": "Keine Daten – App einmal öffnen", + "widget.display.today_title": "Heute", + "widget.display.today_desc": "Heutige Termine auf einen Blick.", + "widget.display.days_title": "Heute & Morgen", + "widget.display.days_desc": "Termine der nächsten zwei Tage.", + "widget.display.upcoming_title": "Nächste 5 Tage", + "widget.display.upcoming_desc": "Termine der nächsten 5 Tage.", + "widget.display.thisweek_title": "Diese Woche", + "widget.display.thisweek_desc": "Wochenraster mit Terminen.", + "widget.display.twoweeks_title": "Zwei Wochen", + "widget.display.twoweeks_desc": "Zwei-Wochen-Raster mit Terminen.", + "widget.display.threedays_title": "Drei Tage", + "widget.display.threedays_desc": "Drei-Tages-Ansicht mit Terminen.", + "widget.display.upnext_title": "Up Next + Kalender", + "widget.display.upnext_desc": "Nächste Termine mit Monatsübersicht." + ], + "en": [ + "widget.today": "Today", + "widget.tomorrow": "Tomorrow", + "widget.no_events": "No events", + "widget.allday": "All-day", + "widget.more": "+%d more", + "widget.upcoming": "Next 5 days", + "widget.no_data": "No data – open the app once", + "widget.display.today_title": "Today", + "widget.display.today_desc": "Today's events at a glance.", + "widget.display.days_title": "Today & tomorrow", + "widget.display.days_desc": "Events for the next two days.", + "widget.display.upcoming_title": "Next 5 days", + "widget.display.upcoming_desc": "Events for the next 5 days.", + "widget.display.thisweek_title": "This Week", + "widget.display.thisweek_desc": "Week grid with events.", + "widget.display.twoweeks_title": "Two Weeks", + "widget.display.twoweeks_desc": "Two-week grid with events.", + "widget.display.threedays_title": "Three Days", + "widget.display.threedays_desc": "Three-day view with events.", + "widget.display.upnext_title": "Up Next + Calendar", + "widget.display.upnext_desc": "Next events with month overview." + ] + ] +} diff --git a/Shared/WidgetData.swift b/Shared/WidgetData.swift new file mode 100644 index 0000000..7fba69b --- /dev/null +++ b/Shared/WidgetData.swift @@ -0,0 +1,135 @@ +import Foundation +#if canImport(WidgetKit) +import WidgetKit +#endif + +/// App-Group identifier shared between the main app and the widget extension. +/// IMPORTANT: This must match the App Group capability in BOTH targets +/// and the App Group ID registered in the Apple Developer Portal. +let widgetAppGroupID = "group.com.scarriffleservices.calendarr" + +/// Lightweight event representation that lives inside the widget cache. +/// We strip everything the widget doesn't need (notes, calendar IDs, URLs). +struct WidgetEvent: Codable, Hashable, Identifiable { + let id: String + let title: String + let start: Date + let end: Date + let isAllDay: Bool + let colorHex: String + let location: String +} + +/// Snapshot blob the app writes to the App-Group container and the widget reads. +struct WidgetSnapshot: Codable { + let writtenAt: Date + let events: [WidgetEvent] + /// Mirrors the user's chosen visual settings so the widget looks the same + /// as the app even when its own AppStorage in the extension is empty. + let todayColorHex: String + let textColorHex: String + let backgroundColorHex: String + let lineColorHex: String + let primaryColorHex: String + let accentColorHex: String + let language: String + + init(writtenAt: Date, + events: [WidgetEvent], + todayColorHex: String, + textColorHex: String, + backgroundColorHex: String, + lineColorHex: String, + primaryColorHex: String, + accentColorHex: String, + language: String) { + self.writtenAt = writtenAt + self.events = events + self.todayColorHex = todayColorHex + self.textColorHex = textColorHex + self.backgroundColorHex = backgroundColorHex + self.lineColorHex = lineColorHex + self.primaryColorHex = primaryColorHex + self.accentColorHex = accentColorHex + self.language = language + } + + /// Custom decoder so older caches without the new colour fields still load. + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + writtenAt = try c.decode(Date.self, forKey: .writtenAt) + events = try c.decode([WidgetEvent].self, forKey: .events) + todayColorHex = try c.decode(String.self, forKey: .todayColorHex) + textColorHex = try c.decode(String.self, forKey: .textColorHex) + backgroundColorHex = try c.decode(String.self, forKey: .backgroundColorHex) + lineColorHex = try c.decode(String.self, forKey: .lineColorHex) + language = try c.decode(String.self, forKey: .language) + primaryColorHex = try c.decodeIfPresent(String.self, forKey: .primaryColorHex) ?? "#4285f4" + accentColorHex = try c.decodeIfPresent(String.self, forKey: .accentColorHex) ?? "#ea4335" + } + + private enum CodingKeys: String, CodingKey { + case writtenAt, events, todayColorHex, textColorHex, backgroundColorHex + case lineColorHex, primaryColorHex, accentColorHex, language + } +} + +enum WidgetStore { + private static let cacheFilename = "widget-cache.json" + + private static var containerURL: URL? { + FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: widgetAppGroupID) + } + + private static var cacheURL: URL? { + containerURL?.appendingPathComponent(cacheFilename) + } + + /// Called by the app whenever the event cache changes. + static func write(_ snapshot: WidgetSnapshot) { + guard let url = cacheURL else { return } + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + if let data = try? encoder.encode(snapshot) { + try? data.write(to: url, options: .atomic) + } + } + + /// Called by the widget timeline provider to load the latest snapshot. + static func read() -> WidgetSnapshot? { + guard let url = cacheURL, let data = try? Data(contentsOf: url) else { return nil } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try? decoder.decode(WidgetSnapshot.self, from: data) + } + + /// Rewrite the existing snapshot with the latest colour / language values + /// from UserDefaults. Used when the user tweaks an appearance setting and + /// we want the widgets to refresh immediately, without needing a new event + /// sync. No-op if there's no cached snapshot yet. + static func republishAppearanceOnly() { + guard let existing = read() else { return } + let defaults = UserDefaults.standard + let updated = WidgetSnapshot( + writtenAt: Date(), + events: existing.events, + todayColorHex: defaults.string(forKey: "todayColor") ?? existing.todayColorHex, + textColorHex: defaults.string(forKey: "textColor") ?? existing.textColorHex, + backgroundColorHex: defaults.string(forKey: "backgroundColor") ?? existing.backgroundColorHex, + lineColorHex: defaults.string(forKey: "lineColor") ?? existing.lineColorHex, + primaryColorHex: defaults.string(forKey: "primaryColor") ?? existing.primaryColorHex, + accentColorHex: defaults.string(forKey: "accentColor") ?? existing.accentColorHex, + language: defaults.string(forKey: "appLanguage") ?? existing.language + ) + write(updated) + WidgetTimelineNotifier.reload() + } +} + +enum WidgetTimelineNotifier { + static func reload() { + #if canImport(WidgetKit) + WidgetCenter.shared.reloadAllTimelines() + #endif + } +} From 1395aaa0c094835076f1327255eefd7e673f71b0 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 25 May 2026 15:48:55 +0200 Subject: [PATCH 05/90] fix anzeige Termine --- Calendarr iOS/Models/CalendarStore.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index 431168c..8a8a320 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -171,13 +171,17 @@ class CalendarStore { return cs <= start && ce >= end } - /// Fast in-memory refresh of `events` for the current visible range. - /// Call this after navigation without hitting the network. + /// Republish the full cached event set, applying only visibility filters + /// (hidden + banished). We deliberately do NOT slice by the current view's + /// date window: the user's chosen cache range is already loaded, and + /// scrolling within it must not make events vanish. Per-day / per-range + /// rendering is the responsibility of `events(on:)` / `events(in:)`. + /// `start` / `end` are kept in the signature for call-site clarity. func refreshFromCache(start: Date, end: Date) { + _ = (start, end) events = allCachedEvents.filter { ev in let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId) - return ev.startDate < end && ev.endDate > start - && !hiddenCalendarKeys.contains(key) + return !hiddenCalendarKeys.contains(key) && !banishedCalendarKeys.contains(key) } } From 07a9e9eb7f5f992047c5f94dc1c21adb5bdef150 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 25 May 2026 18:16:55 +0200 Subject: [PATCH 06/90] anzeige korrigiert Kopfzeilen Monat --- Calendarr iOS/Views/Calendar/MonthView.swift | 23 +++++++------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/Calendarr iOS/Views/Calendar/MonthView.swift b/Calendarr iOS/Views/Calendar/MonthView.swift index f4f64c8..c4af936 100644 --- a/Calendarr iOS/Views/Calendar/MonthView.swift +++ b/Calendarr iOS/Views/Calendar/MonthView.swift @@ -108,23 +108,16 @@ struct MonthView: View { cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date))! } - /// Determine the visible month from the currently-scrolled week. - /// Instead of switching as soon as a few days of the next month appear, - /// we count the month affiliation of the visible week rows and keep the - /// month that occupies the majority of the viewport. + /// 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 visibleWeeks = (0..<6).compactMap { cal.date(byAdding: .weekOfYear, value: $0, to: w) } - let monthCounts = visibleWeeks.reduce(into: [Date: Int]()) { acc, weekStart in - guard let midWeek = cal.date(byAdding: .day, value: 3, to: weekStart) else { return } - let month = cal.date(from: cal.dateComponents([.year, .month], from: midWeek)) ?? midWeek - acc[month, default: 0] += 1 - } - - let selectedMonth = monthCounts.max { a, b in a.value < b.value }?.key - if let m = selectedMonth, visibleMonth != m { - visibleMonth = m + let month = cal.date(from: cal.dateComponents([.year, .month], from: w)) ?? w + if visibleMonth != month { + visibleMonth = month } } } From 4125bfc7289280c0a09b532d3e5e513c85277255 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 27 May 2026 20:44:14 +0200 Subject: [PATCH 07/90] Settings sync, calendar visibility sync, event refresh & week-view fixes - Add two-way settings sync (SettingsSync) with toggle, app-start/foreground/ 10-min pull and debounced push; server wins; view/week-start/dim-past always sync. Wire previously-ignored settings (hour height, contrasts, week start, default view, dim past) into the actual UI. - Make AppSettings decoding resilient (decodeIfPresent) so getSettings no longer fails on iOS-only fields the server omits; keep text/bg/line colors local-only; month divider/label colors now sync. - Auto-refresh after create/edit (cache-busting) and optimistic removal on delete; switch delete confirm to a centered alert. Add HA event deletion. - Calendar visibility: fix inverted hide/show toggle; normalize calendar keys so local filtering works for all sources; sync banish with server sidebar_hidden (CalDAV/Google/HA), refetch on un-banish. - Manual "sync with server" button in the menu. - Upcoming widget shows next 5 days (renamed). - Week/Day view: route multi-day timed events to the all-day strip so they no longer render as a full-height block. Co-Authored-By: Claude Opus 4.7 --- Calendarr iOS.xcodeproj/project.pbxproj | 8 +- Calendarr iOS/Models/AppSettings.swift | 39 ++++ Calendarr iOS/Models/CalendarStore.swift | 57 +++++- Calendarr iOS/Models/Localization.swift | 10 + Calendarr iOS/Services/CalendarrAPI.swift | 27 +++ Calendarr iOS/Services/SettingsSync.swift | 152 ++++++++++++++++ Calendarr iOS/Views/AccountsView.swift | 39 +++- .../Views/Calendar/CalendarHostView.swift | 73 +++++++- Calendarr iOS/Views/Calendar/DayView.swift | 25 ++- .../Views/Calendar/EventDetailSheet.swift | 22 ++- Calendarr iOS/Views/Calendar/MonthView.swift | 18 +- .../Views/Calendar/TimeGridView.swift | 50 ++++- Calendarr iOS/Views/Calendar/WeekView.swift | 32 ++-- Calendarr iOS/Views/CalendarFilterSheet.swift | 25 ++- Calendarr iOS/Views/MenuSheet.swift | 24 +++ Calendarr iOS/Views/SettingsView.swift | 171 +++++++----------- 16 files changed, 616 insertions(+), 156 deletions(-) create mode 100644 Calendarr iOS/Services/SettingsSync.swift diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index 778f47f..85ec7a3 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -491,7 +491,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PP34X97WS3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -509,7 +509,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -533,7 +533,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PP34X97WS3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -551,7 +551,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/Calendarr iOS/Models/AppSettings.swift b/Calendarr iOS/Models/AppSettings.swift index 86f0f37..d7a6962 100644 --- a/Calendarr iOS/Models/AppSettings.swift +++ b/Calendarr iOS/Models/AppSettings.swift @@ -34,6 +34,33 @@ struct AppSettings: Codable { case backgroundColor = "background_color" case lineColor = "line_color" } + + init() {} + + /// Resilient decoding: the server only stores a subset of these fields + /// (e.g. it has no `text_color`/`background_color`/`line_color`, which are + /// iOS-only). Using `decodeIfPresent` with the property defaults means a + /// missing key no longer aborts the whole decode — otherwise the entire + /// settings sync silently breaks. + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let d = AppSettings() + defaultView = try c.decodeIfPresent(String.self, forKey: .defaultView) ?? d.defaultView + weekStartDay = try c.decodeIfPresent(String.self, forKey: .weekStartDay) ?? d.weekStartDay + primaryColor = try c.decodeIfPresent(String.self, forKey: .primaryColor) ?? d.primaryColor + accentColor = try c.decodeIfPresent(String.self, forKey: .accentColor) ?? d.accentColor + todayColor = try c.decodeIfPresent(String.self, forKey: .todayColor) ?? d.todayColor + dimPastEvents = try c.decodeIfPresent(Bool.self, forKey: .dimPastEvents) ?? d.dimPastEvents + textContrast = try c.decodeIfPresent(Int.self, forKey: .textContrast) ?? d.textContrast + lineContrast = try c.decodeIfPresent(Int.self, forKey: .lineContrast) ?? d.lineContrast + hourHeight = try c.decodeIfPresent(Int.self, forKey: .hourHeight) ?? d.hourHeight + language = try c.decodeIfPresent(String.self, forKey: .language) ?? d.language + monthDividerColor = try c.decodeIfPresent(String.self, forKey: .monthDividerColor) ?? d.monthDividerColor + monthLabelColor = try c.decodeIfPresent(String.self, forKey: .monthLabelColor) ?? d.monthLabelColor + textColor = try c.decodeIfPresent(String.self, forKey: .textColor) ?? d.textColor + backgroundColor = try c.decodeIfPresent(String.self, forKey: .backgroundColor) ?? d.backgroundColor + lineColor = try c.decodeIfPresent(String.self, forKey: .lineColor) ?? d.lineColor + } } struct CalDAVAccount: Codable, Identifiable { @@ -124,10 +151,22 @@ struct HACalendar: Codable, Identifiable { var entityId: String var color: String? var enabled: Bool + var sidebarHidden: Bool enum CodingKeys: String, CodingKey { case id, name, color, enabled case entityId = "entity_id" + case sidebarHidden = "sidebar_hidden" + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(Int.self, forKey: .id) + name = try c.decode(String.self, forKey: .name) + entityId = try c.decodeIfPresent(String.self, forKey: .entityId) ?? "" + color = try c.decodeIfPresent(String.self, forKey: .color) + enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? true + sidebarHidden = try c.decodeIfPresent(Bool.self, forKey: .sidebarHidden) ?? false } } diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index 8a8a320..e567842 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -6,6 +6,11 @@ extension Notification.Name { /// 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 { @@ -107,7 +112,16 @@ class CalendarStore { } static func calendarKey(source: String, calendarId: String) -> String { - "\(source):\(calendarId)" + // The events API returns `calendar_id` inconsistently: a raw numeric for + // CalDAV, but "-" for local / ical / google / homeassistant + // (e.g. "local-3", "google-5"). The filter UI keys off the numeric DB id, + // so strip any leading "-" prefix to make event keys and filter + // keys comparable — otherwise local hiding/banishing silently does nothing + // for those sources. + var id = calendarId + let prefix = "\(source)-" + if id.hasPrefix(prefix) { id = String(id.dropFirst(prefix.count)) } + return "\(source):\(id)" } // MARK: – Banished-calendar persistence @@ -149,6 +163,18 @@ class CalendarStore { publishWidgetSnapshot() } + /// Replace the whole banished set (used when reconciling with the server's + /// `sidebar_hidden` flags). Persists, notifies, refreshes. + func setBanishedCalendars(_ keys: Set) { + 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() { @@ -158,6 +184,17 @@ class CalendarStore { publishWidgetSnapshot() } + /// Split a `"source:calendarId"` key back into its parts. + static func parseCalendarKey(_ key: String) -> (source: String, id: Int)? { + guard let colon = key.firstIndex(of: ":") else { return nil } + let source = String(key[.. = ["caldav", "google", "homeassistant"] + var userCalendar: Calendar { var cal = Calendar.current cal.firstWeekday = weekStartsOnMonday ? 2 : 1 @@ -186,11 +223,23 @@ class CalendarStore { } } + /// Optimistically drop a just-deleted event from the cache so it disappears + /// from the UI immediately, without waiting for a server round-trip (HA + /// deletes can lag several seconds, and an immediate refetch could even + /// re-add it before the source propagated the deletion). + func removeCachedEvent(id: String) { + allCachedEvents.removeAll { $0.id == id } + events.removeAll { $0.id == id } + publishWidgetSnapshot() + } + // MARK: – Network loading - /// Load events for a specific range – skips network if already cached. - func loadEvents(api: CalendarrAPI, start: Date, end: Date) async { - if isCached(start: start, end: end) { + /// Load events for a specific range. Skips the network if already cached, + /// unless `force` is set (used after create/edit to pull fresh server data + /// for the visible range, bypassing the cache). + func loadEvents(api: CalendarrAPI, start: Date, end: Date, force: Bool = false) async { + if !force, isCached(start: start, end: end) { refreshFromCache(start: start, end: end) return } diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index 515a06b..7f2b548 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -64,6 +64,8 @@ private let strings: [String: [String: String]] = [ "menu.server": "Server", "menu.logout": "Abmelden", "menu.admin": "Admin", + "menu.sync": "Mit Server synchronisieren", + "menu.sync.section": "Synchronisierung", // Settings – chrome "settings.title": "Darstellung", @@ -76,6 +78,9 @@ private let strings: [String: [String: String]] = [ "settings.liquidglass": "Liquid Glass", "settings.liquidglass.desc": "Verwendet die neue iOS\u{202F}26 Glasoptik mit transparenter Navigationsleiste", "settings.liquidglass.footer": "Änderung wirkt sofort – kein Neustart nötig.", + "settings.sync": "Einstellungen synchronisieren", + "settings.sync.desc": "Darstellung mit dem Server abgleichen", + "settings.sync.footer": "Wenn aktiv, werden Farben, Kontraste und Stundenhöhe mit dem Server abgeglichen (der Server hat Vorrang). Ansicht, erster Wochentag und das Ausgrauen vergangener Termine werden immer synchronisiert – auch wenn der Schalter aus ist.", "settings.cache.header": "Vorladen", "settings.cache.title": "Vorladen", @@ -320,6 +325,8 @@ private let strings: [String: [String: String]] = [ "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…", @@ -330,6 +337,9 @@ private let strings: [String: [String: String]] = [ "settings.liquidglass": "Liquid Glass", "settings.liquidglass.desc": "Uses the new iOS\u{202F}26 glass look with a translucent navigation bar", "settings.liquidglass.footer": "Takes effect immediately – no restart required.", + "settings.sync": "Sync settings", + "settings.sync.desc": "Keep appearance in sync with the server", + "settings.sync.footer": "When on, colors, contrasts and hour height sync with the server (the server wins). View, first weekday and dimming past events always sync – even when the switch is off.", "settings.cache.header": "Preloading", "settings.cache.title": "Preloading", diff --git a/Calendarr iOS/Services/CalendarrAPI.swift b/Calendarr iOS/Services/CalendarrAPI.swift index b123f9e..a1f53c3 100644 --- a/Calendarr iOS/Services/CalendarrAPI.swift +++ b/Calendarr iOS/Services/CalendarrAPI.swift @@ -365,4 +365,31 @@ class CalendarrAPI { ] _ = try await request("/api/homeassistant/events", method: "POST", body: body) } + + /// Delete a Home Assistant calendar event. + /// `calendarId` is the numeric HA-calendar DB id; `uid` is the HA event uid. + func deleteHAEvent(calendarId: Int, uid: String) async throws { + // uid is a path segment and may contain "/" or other reserved chars. + let allowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/")) + let encUid = uid.addingPercentEncoding(withAllowedCharacters: allowed) ?? uid + _ = try await request("/api/homeassistant/events/\(calendarId)/\(encUid)", method: "DELETE") + } + + // MARK: – Calendar visibility (sidebar_hidden) + + /// Toggle a calendar's server-side visibility. Mirrors the web: hiding sets + /// `enabled=false, sidebar_hidden=true` (server then omits its events); + /// showing sets `enabled=true, sidebar_hidden=false`. Only CalDAV / Google / + /// Home Assistant have this flag; `local` / `ical` are a no-op. + func setCalendarSidebarHidden(source: String, calendarId: Int, hidden: Bool) async throws { + let path: String + switch source { + case "caldav": path = "/api/caldav/calendars/\(calendarId)" + case "google": path = "/api/google/calendars/\(calendarId)" + case "homeassistant": path = "/api/homeassistant/calendars/\(calendarId)" + default: return + } + _ = try await request(path, method: "PUT", + body: ["enabled": !hidden, "sidebar_hidden": hidden]) + } } diff --git a/Calendarr iOS/Services/SettingsSync.swift b/Calendarr iOS/Services/SettingsSync.swift new file mode 100644 index 0000000..6bc1101 --- /dev/null +++ b/Calendarr iOS/Services/SettingsSync.swift @@ -0,0 +1,152 @@ +import Foundation + +extension Notification.Name { + /// Posted after a successful pull applied new settings to UserDefaults, so + /// views holding live state (CalendarHostView → store, widgets) can react. + static let settingsDidChange = Notification.Name("settingsDidChange") +} + +/// Two-way synchronisation of appearance/behaviour settings between the app and +/// the Calendarr server. The server is treated as the source of truth on pull; +/// local edits are pushed immediately so the server then holds the newest value. +/// +/// Two groups: +/// - **optional** (colors, contrasts, hour height) only sync when the user has +/// enabled the `settingsSync` toggle. +/// - **always** (default view, week start, dim past events) sync regardless of +/// the toggle, because they describe how the user expects the calendar to be +/// computed/presented everywhere. +enum SettingsSync { + + // MARK: – UserDefaults keys + + enum Key { + // optional group + static let primaryColor = "primaryColor" + static let accentColor = "accentColor" + static let todayColor = "todayColor" + static let textColor = "textColor" + static let backgroundColor = "backgroundColor" + static let lineColor = "lineColor" + static let monthDividerColor = "monthDividerColor" + static let monthLabelColor = "monthLabelColor" + static let textContrast = "textContrast" + static let lineContrast = "lineContrast" + static let hourHeight = "hourHeight" + // always group + static let defaultView = "defaultView" + static let weekStartDay = "weekStartDay" + static let dimPastEvents = "dimPastEvents" + // master switch + static let enabled = "settingsSync" + } + + static var isEnabled: Bool { UserDefaults.standard.bool(forKey: Key.enabled) } + + // MARK: – Defaults (mirror the historical hard-coded values) + + private static func int(_ key: String, _ fallback: Int) -> Int { + let v = UserDefaults.standard.object(forKey: key) as? Int + return v ?? fallback + } + private static func str(_ key: String, _ fallback: String) -> String { + UserDefaults.standard.string(forKey: key) ?? fallback + } + + // MARK: – Build AppSettings from local UserDefaults + + static func currentSettings() -> AppSettings { + var s = AppSettings() + s.primaryColor = str(Key.primaryColor, "#4285f4") + s.accentColor = str(Key.accentColor, "#ea4335") + s.todayColor = str(Key.todayColor, "#4285f4") + s.textColor = str(Key.textColor, "#FFFFFF") + s.backgroundColor = str(Key.backgroundColor, "#000000") + s.lineColor = str(Key.lineColor, "#3A3A3C") + s.monthDividerColor = str(Key.monthDividerColor, "#7090c0") + s.monthLabelColor = str(Key.monthLabelColor, "#7090c0") + s.textContrast = int(Key.textContrast, 3) + s.lineContrast = int(Key.lineContrast, 3) + s.hourHeight = int(Key.hourHeight, 60) + s.defaultView = str(Key.defaultView, "month") + s.weekStartDay = str(Key.weekStartDay, "monday") + s.dimPastEvents = UserDefaults.standard.bool(forKey: Key.dimPastEvents) + return s + } + + // MARK: – Apply a server snapshot to local UserDefaults + + /// Always writes the "always" trio. Writes the optional group only when + /// `includeOptional` is true. + static func apply(_ s: AppSettings, includeOptional: Bool) { + let d = UserDefaults.standard + // always group + d.set(s.defaultView, forKey: Key.defaultView) + d.set(s.weekStartDay, forKey: Key.weekStartDay) + d.set(s.dimPastEvents, forKey: Key.dimPastEvents) + guard includeOptional else { return } + // NOTE: textColor / backgroundColor / lineColor are intentionally NOT + // synced – the server has no columns for them (iOS-only). Writing the + // resilient-decoded defaults here would wipe the user's local choices. + d.set(s.primaryColor, forKey: Key.primaryColor) + d.set(s.accentColor, forKey: Key.accentColor) + d.set(s.todayColor, forKey: Key.todayColor) + d.set(s.monthDividerColor, forKey: Key.monthDividerColor) + d.set(s.monthLabelColor, forKey: Key.monthLabelColor) + d.set(s.textContrast, forKey: Key.textContrast) + d.set(s.lineContrast, forKey: Key.lineContrast) + d.set(s.hourHeight, forKey: Key.hourHeight) + } + + // MARK: – Pull + + /// Fetch the server's settings and apply them locally (server wins). + static func pull(api: CalendarrAPI) async { + guard let server = try? await api.getSettings() else { return } + apply(server, includeOptional: isEnabled) + await MainActor.run { + NotificationCenter.default.post(name: .settingsDidChange, object: nil) + } + } + + // MARK: – Push (debounced) + + private static var pushTask: Task? + + /// Schedule a debounced push. Repeated calls (e.g. while dragging a colour + /// slider) collapse into a single network write ~1.2 s after the last edit. + static func push(api: CalendarrAPI) { + pushTask?.cancel() + pushTask = Task { + try? await Task.sleep(for: .milliseconds(1200)) + if Task.isCancelled { return } + await performPush(api: api) + } + } + + /// Read-modify-write: start from the server's current settings so that, + /// when the optional group is NOT being synced, the server's colours stay + /// intact. Overwrite the trio always, the optional group only if enabled. + private static func performPush(api: CalendarrAPI) async { + guard var merged = try? await api.getSettings() else { return } + let local = currentSettings() + // always group + merged.defaultView = local.defaultView + merged.weekStartDay = local.weekStartDay + merged.dimPastEvents = local.dimPastEvents + if isEnabled { + merged.primaryColor = local.primaryColor + merged.accentColor = local.accentColor + merged.todayColor = local.todayColor + merged.textColor = local.textColor + merged.backgroundColor = local.backgroundColor + merged.lineColor = local.lineColor + merged.monthDividerColor = local.monthDividerColor + merged.monthLabelColor = local.monthLabelColor + merged.textContrast = local.textContrast + merged.lineContrast = local.lineContrast + merged.hourHeight = local.hourHeight + } + try? await api.updateSettings(merged) + } +} diff --git a/Calendarr iOS/Views/AccountsView.swift b/Calendarr iOS/Views/AccountsView.swift index 2817923..ed9033e 100644 --- a/Calendarr iOS/Views/AccountsView.swift +++ b/Calendarr iOS/Views/AccountsView.swift @@ -195,9 +195,7 @@ struct AccountsView: View { .foregroundStyle(.secondary) Spacer() Button(L10n.t("accounts.banished_unhide", appLang)) { - banishedKeys.remove(key) - CalendarStore.saveBanishedKeys(banishedKeys) - NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil) + unbanish(key) } .font(.callout) .foregroundStyle(Color.accentColor) @@ -208,6 +206,25 @@ struct AccountsView: View { } } + /// 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 { @@ -282,6 +299,22 @@ struct AccountsView: View { async let g = (try? await api.getGoogleAccounts()) ?? [] async let h = (try? await api.getHomeAssistantAccounts()) ?? [] (caldavAccounts, localCalendars, icalSubs, googleAccounts, haAccounts) = await (c, l, i, g, h) + + // Reconcile banished list with the server's sidebar_hidden (server wins + // for CalDAV/Google/HA; local/ical keep their local state). + var b = banishedKeys + func applyServerHidden(_ source: String, _ id: Int, _ hidden: Bool) { + let key = CalendarStore.calendarKey(source: source, calendarId: "\(id)") + if hidden { b.insert(key) } else { b.remove(key) } + } + for acc in caldavAccounts { for cal in acc.calendars ?? [] { applyServerHidden("caldav", cal.id, cal.sidebarHidden) } } + for acc in googleAccounts { for cal in acc.calendars ?? [] { applyServerHidden("google", cal.id, cal.sidebarHidden) } } + for acc in haAccounts { for cal in acc.calendars ?? [] { applyServerHidden("homeassistant", cal.id, cal.sidebarHidden) } } + if b != banishedKeys { + banishedKeys = b + CalendarStore.saveBanishedKeys(b) + NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil) + } isLoading = false } diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index aaa9f5c..15ec78f 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -8,6 +8,10 @@ struct CalendarHostView: View { @AppStorage("cacheMonths") private var cacheMonths = 3 @AppStorage("appLanguage") private var appLang = "system" @AppStorage("backgroundColor") private var bgHex = "#000000" + @AppStorage("weekStartDay") private var weekStartDay = "monday" + @AppStorage("defaultView") private var defaultView = "month" + + @Environment(\.scenePhase) private var scenePhase @State private var store = CalendarStore() @State private var showEditor = false @@ -16,6 +20,7 @@ struct CalendarHostView: View { @State private var selectedEvent: CalEvent? = nil @State private var visibleMonth: Date = .now @State private var showFilter = false + @State private var didApplyDefaultView = false private var titleString: String { if store.viewType == .month { @@ -68,9 +73,16 @@ struct CalendarHostView: View { .onChange(of: store.viewType) { _, _ in Task { await onNavigate() } } .onChange(of: cacheMonths) { _, _ in Task { await recache() } } .onChange(of: visibleMonth) { _, new in Task { await ensureLoaded(around: new) } } + .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await SettingsSync.pull(api: api) } } } .onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in store.syncBanishedFromDefaults() } + .onReceive(NotificationCenter.default.publisher(for: .settingsDidChange)) { _ in + applyServerDrivenSettings(initial: false) + } + .onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in + Task { await forceReload() } + } } // MARK: – Liquid Glass variant @@ -123,9 +135,16 @@ struct CalendarHostView: View { .onChange(of: store.viewType) { _, _ in Task { await onNavigate() } } .onChange(of: cacheMonths) { _, _ in Task { await recache() } } .onChange(of: visibleMonth) { _, new in Task { await ensureLoaded(around: new) } } + .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await SettingsSync.pull(api: api) } } } .onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in store.syncBanishedFromDefaults() } + .onReceive(NotificationCenter.default.publisher(for: .settingsDidChange)) { _ in + applyServerDrivenSettings(initial: false) + } + .onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in + Task { await forceReload() } + } } // MARK: – Top bar (flat mode) @@ -322,12 +341,19 @@ struct CalendarHostView: View { CalendarSheets(store: store, showEditor: $showEditor, editorDate: $editorDate, editingEvent: $editingEvent, selectedEvent: $selectedEvent, showFilter: $showFilter, - api: api, reload: { await onNavigate() }) + api: api, + reload: { await onNavigate() }, + reloadForce: { await reloadVisible(force: true) }) } // MARK: – Loading logic private func startup() async { + // 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) // 1. Load current view immediately (visible) let (s, e) = store.rangeForCurrentView() @@ -336,6 +362,25 @@ struct CalendarHostView: View { Task(priority: .background) { await store.prefetchBackground(api: api, months: cacheMonths) } + // 3. Periodic settings pull (tied to this .task's lifetime). + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(600)) + if Task.isCancelled { break } + await SettingsSync.pull(api: api) + } + } + + /// Apply the server-driven "always sync" settings to the live store. + /// `weekStartsOnMonday` is applied every time; the default view is applied + /// only once at startup so it never overrides the user's manual switches. + private func applyServerDrivenSettings(initial: Bool) { + store.weekStartsOnMonday = (weekStartDay != "sunday") + if initial, !didApplyDefaultView { + didApplyDefaultView = true + if let vt = CalViewType(rawValue: defaultView) { + store.viewType = vt + } + } } /// Called on every navigation – instant if within cache, fetches otherwise. @@ -344,12 +389,31 @@ struct CalendarHostView: View { await store.loadEvents(api: api, start: s, end: e) } + /// Re-fetch the visible range. With `force` it bypasses the cache so a just + /// created/edited event shows up immediately (the server is authoritative). + private func reloadVisible(force: Bool) async { + let (s, e) = store.rangeForCurrentView() + await store.loadEvents(api: api, start: s, end: e, force: force) + } + /// Called when cacheMonths setting changes – clear cache and re-prefetch. private func recache() async { store.invalidateCache() await startup() } + /// Manual sync from the menu: drop the event cache and re-fetch from the + /// server (the periodic loop in `startup()` is untouched, so we don't spawn + /// a second one). Settings were already pulled by the menu action. + private func forceReload() async { + store.invalidateCache() + let (s, e) = store.rangeForCurrentView() + await store.loadEvents(api: api, start: s, end: e) + Task(priority: .background) { + await store.prefetchBackground(api: api, months: cacheMonths) + } + } + /// Called when the user scrolls into a new month – refreshes the visible range /// immediately from cache, then fetches on demand if needed. private func ensureLoaded(around month: Date) async { @@ -380,19 +444,24 @@ private struct CalendarSheets: ViewModifier { @Binding var showFilter: Bool let api: CalendarrAPI let reload: () async -> Void + let reloadForce: () async -> Void func body(content: Content) -> some View { content .sheet(isPresented: $showEditor) { EventEditorSheet(api: api, store: store, initialDate: editorDate, editingEvent: editingEvent) { - editingEvent = nil; await reload() + // Create/edit changed server state → bust the cache so the + // new/updated event appears without a manual sync. + editingEvent = nil; await reloadForce() } } .sheet(item: $selectedEvent) { ev in EventDetailSheet(event: ev, api: api, store: store) { updated in selectedEvent = nil if let u = updated { editingEvent = u; showEditor = true } + // Delete already removed the event from the cache optimistically; + // a light cache refresh is enough here. await reload() } } diff --git a/Calendarr iOS/Views/Calendar/DayView.swift b/Calendarr iOS/Views/Calendar/DayView.swift index 7e9c25e..d72664a 100644 --- a/Calendarr iOS/Views/Calendar/DayView.swift +++ b/Calendarr iOS/Views/Calendar/DayView.swift @@ -5,14 +5,20 @@ struct DayView: View { 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 = "#3A3A3C" + @AppStorage("appLanguage") private var appLang = "system" + @AppStorage("todayColor") private var todayHex = "#4285f4" + @AppStorage("textColor") private var textHex = "#FFFFFF" + @AppStorage("lineColor") private var lineHex = "#3A3A3C" + @AppStorage("textContrast") private var textContrast = 3 + @AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout private var cal: Calendar { store.userCalendar } - private var allDayEvents: [CalEvent] { store.events(on: store.currentDate).filter(\.isAllDay) } - private var timedEvents: [CalEvent] { store.events(on: store.currentDate).filter { !$0.isAllDay } } + private var allDayEvents: [CalEvent] { + store.events(on: store.currentDate).filter { $0.isAllDay || eventSpansMultipleDays($0) } + } + private var timedEvents: [CalEvent] { + store.events(on: store.currentDate).filter { !$0.isAllDay && !eventSpansMultipleDays($0) } + } var body: some View { VStack(spacing: 0) { @@ -97,7 +103,7 @@ struct DayView: View { Color.clear.frame(height: hourHeight) Text(String(format: "%02d:00", h)) .font(.system(size: 10)) - .foregroundStyle(Color(hex: textHex).opacity(0.6)) + .foregroundStyle(Color(hex: textHex).opacity(secondaryTextOpacity(textContrast))) .offset(y: -6) } } @@ -131,7 +137,8 @@ private struct DayHourSlot: View { let language: String let onCreateEvent: (Date) -> Void - @AppStorage("lineColor") private var lineHex = "#3A3A3C" + @AppStorage("lineColor") private var lineHex = "#3A3A3C" + @AppStorage("lineContrast") private var lineContrast = 3 private var date: Date { Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day @@ -139,7 +146,7 @@ private struct DayHourSlot: View { var body: some View { VStack(spacing: 0) { - Rectangle().fill(Color(hex: lineHex).opacity(0.4)).frame(height: 0.5) + Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(height: 0.5) Color.clear.frame(height: hourHeight - 0.5) } .contentShape(Rectangle()) diff --git a/Calendarr iOS/Views/Calendar/EventDetailSheet.swift b/Calendarr iOS/Views/Calendar/EventDetailSheet.swift index b057b1f..f73cef7 100644 --- a/Calendarr iOS/Views/Calendar/EventDetailSheet.swift +++ b/Calendarr iOS/Views/Calendar/EventDetailSheet.swift @@ -40,6 +40,12 @@ struct EventDetailSheet: View { event.source == "local" || event.source == "caldav" } + /// Home Assistant events can't be edited in-app (no editor support), but + /// the server does support deleting them. + private var canDelete: Bool { + canEdit || event.source == "homeassistant" + } + var body: some View { NavigationStack { List { @@ -86,7 +92,7 @@ struct EventDetailSheet: View { } } - if canEdit { + if canDelete { Section { Button(role: .destructive) { showDeleteConfirm = true @@ -115,7 +121,7 @@ struct EventDetailSheet: View { } } } - .confirmationDialog("Termin löschen?", isPresented: $showDeleteConfirm, titleVisibility: .visible) { + .alert("Termin löschen?", isPresented: $showDeleteConfirm) { Button("Löschen", role: .destructive) { Task { await deleteEvent() } } @@ -129,12 +135,20 @@ struct EventDetailSheet: View { private func deleteEvent() async { isDeleting = true do { - if event.source == "local" { + switch event.source { + case "local": try await api.deleteLocalEvent(uid: event.id) - } else { + case "homeassistant": + // calendarId looks like "homeassistant-42" → numeric DB id 42 + let calId = Int(event.calendarId.replacingOccurrences(of: "homeassistant-", with: "")) ?? 0 + try await api.deleteHAEvent(calendarId: calId, uid: event.id) + default: let calId = Int(event.calendarId) try await api.deleteCalDAVEvent(uid: event.id, url: event.url, calendarId: calId) } + // 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) } catch { isDeleting = false diff --git a/Calendarr iOS/Views/Calendar/MonthView.swift b/Calendarr iOS/Views/Calendar/MonthView.swift index c4af936..aeb24b2 100644 --- a/Calendarr iOS/Views/Calendar/MonthView.swift +++ b/Calendarr iOS/Views/Calendar/MonthView.swift @@ -26,6 +26,7 @@ struct MonthView: View { @AppStorage("monthLabelColor") private var labelHex = "#7090c0" @AppStorage("textColor") private var textHex = "#FFFFFF" @AppStorage("lineColor") private var lineHex = "#3A3A3C" + @AppStorage("textContrast") private var textContrast = 3 @State private var scrolledWeek: Date? = nil @State private var didInitialScroll = false @@ -98,7 +99,7 @@ struct MonthView: View { ForEach(weekdayHeaders, id: \.self) { d in Text(d) .font(.caption2.weight(.semibold)) - .foregroundStyle(Color(hex: textHex).opacity(0.7)) + .foregroundStyle(Color(hex: textHex).opacity(secondaryTextOpacity(textContrast))) .frame(maxWidth: .infinity, minHeight: weekdayHeaderHeight) } } @@ -291,6 +292,9 @@ private struct DayCell: View { 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 } @@ -330,14 +334,14 @@ private struct DayCell: View { if extraCount > 0 { Text("+\(extraCount)") .font(.system(size: 9, weight: .medium)) - .foregroundStyle(textColor.opacity(0.6)) + .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(0.6)) + .foregroundStyle(textColor.opacity(secondaryTextOpacity(textContrast))) .padding(.trailing, 4) } } @@ -345,11 +349,11 @@ private struct DayCell: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .overlay(alignment: .trailing) { - Rectangle().fill(lineColor.opacity(0.4)).frame(width: 0.5) + Rectangle().fill(lineColor.opacity(gridLineOpacity(lineContrast))).frame(width: 0.5) } .overlay(alignment: .top) { Rectangle() - .fill(edge == .topHighlight ? dividerColor : lineColor.opacity(0.3)) + .fill(edge == .topHighlight ? dividerColor : lineColor.opacity(gridLineOpacity(lineContrast))) .frame(height: edge == .topHighlight ? 1.5 : 0.5) } .overlay(alignment: .bottom) { @@ -377,6 +381,9 @@ private struct DayCell: View { 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) { @@ -390,5 +397,6 @@ private struct EventBar: View { .frame(maxWidth: .infinity, alignment: .leading) .background(Color(hex: event.effectiveColor)) .clipShape(RoundedRectangle(cornerRadius: 3)) + .opacity(dimPast && isPast ? 0.5 : 1.0) } } diff --git a/Calendarr iOS/Views/Calendar/TimeGridView.swift b/Calendarr iOS/Views/Calendar/TimeGridView.swift index f183fba..7cbafc3 100644 --- a/Calendarr iOS/Views/Calendar/TimeGridView.swift +++ b/Calendarr iOS/Views/Calendar/TimeGridView.swift @@ -1,10 +1,54 @@ import SwiftUI // Shared constants used by WeekView, DayView, EventEditorSheet -let hourHeight: CGFloat = 60 let timeColumnWidth: CGFloat = 44 let hours = Array(0..<24) +/// Live hour-row height, driven by the synced `hourHeight` setting. +/// Falls back to 60 when unset (fresh install / value 0). Views that lay out +/// against this also observe `@AppStorage("hourHeight")` so their body +/// re-renders when it changes. +var hourHeight: CGFloat { + let v = UserDefaults.standard.integer(forKey: "hourHeight") + return v > 0 ? CGFloat(v) : 60 +} + +/// Opacity for secondary text (weekday headers, time labels, "+N"/"KW"), +/// mapped from the 1–4 `textContrast` level. Level 3 ≈ the previous hard-coded +/// look so existing installs are visually unchanged. +func secondaryTextOpacity(_ level: Int) -> Double { + switch level { + case 1: return 0.4 + case 2: return 0.55 + case 4: return 1.0 + default: return 0.75 + } +} + +/// Opacity for grid lines / separators, mapped from the 1–4 `lineContrast` +/// level. Level 3 ≈ the previous hard-coded ~0.4 look. +func gridLineOpacity(_ level: Int) -> Double { + switch level { + case 1: return 0.15 + case 2: return 0.3 + case 4: return 0.8 + default: return 0.5 + } +} + +/// A timed (non-all-day) event that crosses a day boundary. Such events must +/// NOT be placed in the hourly grid — their height would be `duration × +/// hourHeight`, i.e. taller than the whole day, rendering as a giant block +/// (and, sharing one id across days, only drawing on the first day). They are +/// shown in the all-day strip instead, like all-day events. +func eventSpansMultipleDays(_ ev: CalEvent) -> Bool { + guard !ev.isAllDay, ev.endDate > ev.startDate else { return false } + let cal = Calendar.current + // End is exclusive: an event ending exactly at midnight is still single-day. + let lastInstant = ev.endDate.addingTimeInterval(-1) + return !cal.isDate(ev.startDate, inSameDayAs: lastInstant) +} + // Position helpers func eventTop(_ ev: CalEvent) -> CGFloat { let cal = Calendar.current @@ -21,6 +65,9 @@ func eventHeight(_ ev: CalEvent) -> CGFloat { // Shared event block used in WeekView and DayView struct EventBlock: View { let event: CalEvent + @AppStorage("dimPastEvents") private var dimPast = false + + private var isPast: Bool { event.endDate < .now } var body: some View { RoundedRectangle(cornerRadius: 4) @@ -41,5 +88,6 @@ struct EventBlock: View { .padding(4) } .padding(.horizontal, 1) + .opacity(dimPast && isPast ? 0.5 : 1.0) } } diff --git a/Calendarr iOS/Views/Calendar/WeekView.swift b/Calendarr iOS/Views/Calendar/WeekView.swift index fb0d0d1..55128f9 100644 --- a/Calendarr iOS/Views/Calendar/WeekView.swift +++ b/Calendarr iOS/Views/Calendar/WeekView.swift @@ -7,10 +7,13 @@ struct WeekView: View { let onShowMonth: (Date) -> Void let onShowDay: (Date) -> Void - @AppStorage("appLanguage") private var appLang = "system" - @AppStorage("todayColor") private var todayHex = "#4285f4" - @AppStorage("textColor") private var textHex = "#FFFFFF" - @AppStorage("lineColor") private var lineHex = "#3A3A3C" + @AppStorage("appLanguage") private var appLang = "system" + @AppStorage("todayColor") private var todayHex = "#4285f4" + @AppStorage("textColor") private var textHex = "#FFFFFF" + @AppStorage("lineColor") private var lineHex = "#3A3A3C" + @AppStorage("textContrast") private var textContrast = 3 + @AppStorage("lineContrast") private var lineContrast = 3 + @AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout private var cal: Calendar { store.userCalendar } @@ -21,14 +24,16 @@ struct WeekView: View { private var timedEvents: [(Int, CalEvent)] { weekDays.enumerated().flatMap { idx, day in - store.events(on: day).filter { !$0.isAllDay }.map { (idx, $0) } + store.events(on: day) + .filter { !$0.isAllDay && !eventSpansMultipleDays($0) } + .map { (idx, $0) } } } private var allDayEvents: [CalEvent] { let s = weekDays.first ?? .now let e = cal.date(byAdding: .day, value: 1, to: weekDays.last ?? .now)! - return store.events(in: s, end: e).filter(\.isAllDay) + return store.events(in: s, end: e).filter { $0.isAllDay || eventSpansMultipleDays($0) } } private var todayIndex: Int? { @@ -56,10 +61,10 @@ struct WeekView: View { ForEach(weekDays, id: \.self) { day in Text(headerFmt.string(from: day).uppercased()) .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(cal.isDateInToday(day) ? Color.accentColor : Color(hex: textHex).opacity(0.7)) + .foregroundStyle(cal.isDateInToday(day) ? Color.accentColor : Color(hex: textHex).opacity(secondaryTextOpacity(textContrast))) .frame(maxWidth: .infinity, minHeight: 36) .overlay(alignment: .trailing) { - Rectangle().fill(Color(hex: lineHex)).frame(width: 0.5) + Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(width: 0.5) } } } @@ -94,7 +99,7 @@ struct WeekView: View { .padding(.horizontal, 1) .frame(maxWidth: .infinity) .overlay(alignment: .trailing) { - Rectangle().fill(Color(.separator)).frame(width: 0.5) + Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(width: 0.5) } } } @@ -127,7 +132,7 @@ struct WeekView: View { } .frame(width: colW) .overlay(alignment: .trailing) { - Rectangle().fill(Color(hex: lineHex)).frame(width: 0.5) + Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(width: 0.5) } } } @@ -171,7 +176,7 @@ struct WeekView: View { Color.clear.frame(height: hourHeight) Text(String(format: "%02d:00", h)) .font(.system(size: 10)) - .foregroundStyle(Color(hex: textHex).opacity(0.6)) + .foregroundStyle(Color(hex: textHex).opacity(secondaryTextOpacity(textContrast))) .offset(y: -6) } } @@ -208,7 +213,8 @@ struct HourSlot: View { let onShowMonth: (Date) -> Void let onShowDay: (Date) -> Void - @AppStorage("lineColor") private var lineHex = "#3A3A3C" + @AppStorage("lineColor") private var lineHex = "#3A3A3C" + @AppStorage("lineContrast") private var lineContrast = 3 private var date: Date { Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day @@ -216,7 +222,7 @@ struct HourSlot: View { var body: some View { VStack(spacing: 0) { - Rectangle().fill(Color(hex: lineHex).opacity(0.4)).frame(height: 0.5) + Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(height: 0.5) Color.clear.frame(height: hourHeight - 0.5) } .contentShape(Rectangle()) diff --git a/Calendarr iOS/Views/CalendarFilterSheet.swift b/Calendarr iOS/Views/CalendarFilterSheet.swift index e932c1b..57e9fc1 100644 --- a/Calendarr iOS/Views/CalendarFilterSheet.swift +++ b/Calendarr iOS/Views/CalendarFilterSheet.swift @@ -136,7 +136,9 @@ struct CalendarFilterSheet: View { let isVisible = !hidden.contains(key) Button { if isVisible { hidden.insert(key) } else { hidden.remove(key) } - store.setCalendarHidden(key, hidden: !isVisible) + // New hidden state == was-visible (flip). Previous code passed the + // inverse, which persisted the opposite of what the UI showed. + store.setCalendarHidden(key, hidden: isVisible) } label: { HStack(spacing: 12) { Circle() @@ -158,6 +160,7 @@ struct CalendarFilterSheet: View { hidden.remove(key) banished.insert(key) store.setCalendarBanished(key, banished: true) + pushBanishToServer(key: key, hidden: true) } label: { Label(L10n.t("filter.banish", appLang), systemImage: "archivebox") } @@ -175,6 +178,19 @@ struct CalendarFilterSheet: View { async let h = (try? await api.getHomeAssistantAccounts()) ?? [] (caldavAccounts, localCalendars, icalSubs, googleAccounts, haAccounts) = await (c, l, i, g, h) + // Reconcile banished state with the server's sidebar_hidden flags + // (server wins for CalDAV/Google/HA; local/ical keep their local state). + var b = store.banishedCalendarKeys + func applyServerHidden(_ source: String, _ id: Int, _ hidden: Bool) { + let key = CalendarStore.calendarKey(source: source, calendarId: "\(id)") + if hidden { b.insert(key) } else { b.remove(key) } + } + for acc in caldavAccounts { for cal in acc.calendars ?? [] { applyServerHidden("caldav", cal.id, cal.sidebarHidden) } } + for acc in googleAccounts { for cal in acc.calendars ?? [] { applyServerHidden("google", cal.id, cal.sidebarHidden) } } + for acc in haAccounts { for cal in acc.calendars ?? [] { applyServerHidden("homeassistant", cal.id, cal.sidebarHidden) } } + store.setBanishedCalendars(b) + banished = b + var keys = Set() for cal in localCalendars { keys.insert(CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)")) @@ -200,4 +216,11 @@ struct CalendarFilterSheet: View { allKeys = keys isLoading = false } + + /// For server-backed sources, persist the banish on the server too. + private func pushBanishToServer(key: String, hidden: Bool) { + guard let parsed = CalendarStore.parseCalendarKey(key), + CalendarStore.serverManagedSources.contains(parsed.source) else { return } + Task { try? await api.setCalendarSidebarHidden(source: parsed.source, calendarId: parsed.id, hidden: hidden) } + } } diff --git a/Calendarr iOS/Views/MenuSheet.swift b/Calendarr iOS/Views/MenuSheet.swift index 1bb6a7f..032bd5f 100644 --- a/Calendarr iOS/Views/MenuSheet.swift +++ b/Calendarr iOS/Views/MenuSheet.swift @@ -5,6 +5,7 @@ struct MenuSheet: View { @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 { @@ -68,6 +69,19 @@ struct MenuSheet: View { } } + 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() @@ -88,4 +102,14 @@ struct MenuSheet: View { } } } + + /// 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() + } } diff --git a/Calendarr iOS/Views/SettingsView.swift b/Calendarr iOS/Views/SettingsView.swift index f5eb66c..01edd50 100644 --- a/Calendarr iOS/Views/SettingsView.swift +++ b/Calendarr iOS/Views/SettingsView.swift @@ -2,12 +2,8 @@ import SwiftUI struct SettingsView: View { let api: CalendarrAPI - @State private var settings = AppSettings() - @State private var isLoading = true - @State private var isSaving = false - @State private var toast = "" - @State private var showToast = false @AppStorage("liquidGlass") private var liquidGlass = false + @AppStorage("settingsSync") private var settingsSync = false @AppStorage("cacheMonths") private var cacheMonths = 3 @AppStorage("appLanguage") private var appLang = "system" @AppStorage("monthDividerColor") private var dividerHex = "#7090c0" @@ -18,65 +14,52 @@ struct SettingsView: View { @AppStorage("lineColor") private var lineHex = "#3A3A3C" @AppStorage("primaryColor") private var primaryHex = "#4285f4" @AppStorage("accentColor") private var accentHex = "#ea4335" + // Previously server-only; now AppStorage-backed so they persist and the + // calendar views actually apply them. + @AppStorage("textContrast") private var textContrast = 3 + @AppStorage("lineContrast") private var lineContrast = 3 + @AppStorage("hourHeight") private var hourHeight = 60 + @AppStorage("defaultView") private var defaultView = "month" + @AppStorage("weekStartDay") private var weekStartDay = "monday" + @AppStorage("dimPastEvents") private var dimPastEvents = false var body: some View { NavigationStack { - Group { - if isLoading { - ProgressView(L10n.t("settings.loading", appLang)) - } else { - Form { - liquidGlassSection - cacheSection - spracheSection - farbenSection - schriftSection - linienSection - ansichtSection - stundenSection - } - } + Form { + liquidGlassSection + cacheSection + spracheSection + farbenSection + schriftSection + linienSection + ansichtSection + stundenSection } .navigationTitle(L10n.t("settings.title", appLang)) .navigationBarTitleDisplayMode(.large) - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - Task { await save() } - } label: { - if isSaving { - ProgressView() - } else { - Text(L10n.t("settings.save", appLang)).bold() - } - } - .disabled(isSaving) - } - } - .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) } - .task { await load() } - // Live-update widgets the moment any appearance value changes, so the - // user sees the new colours without having to wait for the next event - // sync or save the settings. - .onChange(of: primaryHex) { _, _ in WidgetStore.republishAppearanceOnly() } - .onChange(of: accentHex) { _, _ in WidgetStore.republishAppearanceOnly() } - .onChange(of: todayHex) { _, _ in WidgetStore.republishAppearanceOnly() } - .onChange(of: textHex) { _, _ in WidgetStore.republishAppearanceOnly() } - .onChange(of: bgHex) { _, _ in WidgetStore.republishAppearanceOnly() } - .onChange(of: lineHex) { _, _ in WidgetStore.republishAppearanceOnly() } + // Reflect the latest server values when opening the screen. + .task { await SettingsSync.pull(api: api) } + // Appearance changes update widgets live; synced values are also pushed + // to the server (debounced). `push` itself decides what actually gets + // sent based on the sync toggle, so every change can simply call it. + .onChange(of: primaryHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) } + .onChange(of: accentHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) } + .onChange(of: todayHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) } + .onChange(of: textHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) } + .onChange(of: bgHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) } + .onChange(of: lineHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) } + .onChange(of: dividerHex) { _, _ in SettingsSync.push(api: api) } + .onChange(of: labelHex) { _, _ in SettingsSync.push(api: api) } + .onChange(of: textContrast) { _, _ in SettingsSync.push(api: api) } + .onChange(of: lineContrast) { _, _ in SettingsSync.push(api: api) } + .onChange(of: hourHeight) { _, _ in SettingsSync.push(api: api) } + .onChange(of: defaultView) { _, _ in SettingsSync.push(api: api) } + .onChange(of: weekStartDay) { _, _ in SettingsSync.push(api: api) } + .onChange(of: dimPastEvents) { _, _ in SettingsSync.push(api: api) } .onChange(of: appLang) { _, _ in WidgetStore.republishAppearanceOnly() } + // Enabling sync adopts the server's appearance (server wins). + .onChange(of: settingsSync) { _, on in if on { Task { await SettingsSync.pull(api: api) } } } } // MARK: – Liquid Glass @@ -97,10 +80,25 @@ struct SettingsView: View { } } .tint(Color.accentColor) + + Toggle(isOn: $settingsSync) { + Label { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.t("settings.sync", appLang)) + Text(L10n.t("settings.sync.desc", appLang)) + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "arrow.triangle.2.circlepath") + .foregroundStyle(.teal) + } + } + .tint(Color.accentColor) } header: { Text(L10n.t("settings.appdesign", appLang)) } footer: { - Text(L10n.t("settings.liquidglass.footer", appLang)) + Text(L10n.t("settings.sync.footer", appLang)) .font(.caption) } } @@ -177,7 +175,7 @@ struct SettingsView: View { .font(.caption) .foregroundStyle(.secondary) ContrastSelector( - value: $settings.textContrast, + value: $textContrast, options: [ (1, L10n.t("settings.contrast.dark", appLang)), (2, L10n.t("settings.contrast.medium", appLang)), @@ -201,7 +199,7 @@ struct SettingsView: View { .font(.caption) .foregroundStyle(.secondary) ContrastSelector( - value: $settings.lineContrast, + value: $lineContrast, options: [ (1, L10n.t("settings.linecontrast.barely", appLang)), (2, L10n.t("settings.linecontrast.subtle", appLang)), @@ -218,18 +216,18 @@ struct SettingsView: View { var ansichtSection: some View { Section(L10n.t("settings.calview", appLang)) { - Picker(L10n.t("settings.defaultview", appLang), selection: $settings.defaultView) { + Picker(L10n.t("settings.defaultview", appLang), selection: $defaultView) { Text(L10n.t("view.month", appLang)).tag("month") Text(L10n.t("view.week", appLang)).tag("week") Text(L10n.t("view.day", appLang)).tag("day") Text(L10n.t("view.quarter", appLang)).tag("quarter") Text(L10n.t("view.agenda", appLang)).tag("agenda") } - Picker(L10n.t("settings.firstweekday", appLang), selection: $settings.weekStartDay) { + Picker(L10n.t("settings.firstweekday", appLang), selection: $weekStartDay) { Text(L10n.t("settings.monday", appLang)).tag("monday") Text(L10n.t("settings.sunday", appLang)).tag("sunday") } - Toggle(L10n.t("settings.dimpast", appLang), isOn: $settings.dimPastEvents) + Toggle(L10n.t("settings.dimpast", appLang), isOn: $dimPastEvents) .tint(Color.accentColor) } } @@ -245,7 +243,7 @@ struct SettingsView: View { .font(.caption) .foregroundStyle(.secondary) ContrastSelector( - value: $settings.hourHeight, + value: $hourHeight, options: [ (28, L10n.t("settings.hourheight.compact", appLang)), (44, L10n.t("settings.hourheight.normal", appLang)), @@ -258,53 +256,6 @@ struct SettingsView: View { } } - // MARK: – Actions - - private func load() async { - isLoading = true - defer { isLoading = false } - if let s = try? await api.getSettings() { - settings = s - // Mirror server-side color settings so calendar views (which read AppStorage) see them. - dividerHex = s.monthDividerColor - labelHex = s.monthLabelColor - todayHex = s.todayColor - textHex = s.textColor - bgHex = s.backgroundColor - lineHex = s.lineColor - primaryHex = s.primaryColor - accentHex = s.accentColor - } - } - - private func save() async { - isSaving = true - defer { isSaving = false } - // Push local AppStorage colors back into the settings struct before saving. - settings.monthDividerColor = dividerHex - settings.monthLabelColor = labelHex - settings.todayColor = todayHex - settings.textColor = textHex - settings.backgroundColor = bgHex - settings.lineColor = lineHex - settings.primaryColor = primaryHex - settings.accentColor = accentHex - do { - try await api.updateSettings(settings) - showNotice(L10n.t("settings.saved", appLang)) - } catch { - showNotice(error.localizedDescription) - } - } - - private func showNotice(_ msg: String) { - toast = msg - withAnimation { showToast = true } - Task { - try? await Task.sleep(for: .seconds(2)) - withAnimation { showToast = false } - } - } } // MARK: – Reusable Components From e71fd7512f4b7f2c56c05ce85370b43ae08db553 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 28 May 2026 21:43:18 +0200 Subject: [PATCH 08/90] =?UTF-8?q?Widget=20=C3=A4nderungen,=20sync=20=C3=A4?= =?UTF-8?q?nderungen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Views/Calendar/EventDetailSheet.swift | 35 +++- .../Views/Calendar/EventEditorSheet.swift | 25 ++- CalendarrWidgets/CalendarDayWidgetView.swift | 151 ++++++++++++++++ CalendarrWidgets/CalendarrWidgets.swift | 36 +++- CalendarrWidgets/LockScreenWidgetViews.swift | 99 ++++++++++ CalendarrWidgets/NowNextWidgetView.swift | 162 +++++++++++++++++ CalendarrWidgets/ThisWeekWidgetView.swift | 41 +++-- CalendarrWidgets/TwoMonthWidgetView.swift | 170 ++++++++++++++++++ CalendarrWidgets/WidgetSupport.swift | 64 ++++--- 9 files changed, 726 insertions(+), 57 deletions(-) create mode 100644 CalendarrWidgets/CalendarDayWidgetView.swift create mode 100644 CalendarrWidgets/LockScreenWidgetViews.swift create mode 100644 CalendarrWidgets/NowNextWidgetView.swift create mode 100644 CalendarrWidgets/TwoMonthWidgetView.swift diff --git a/Calendarr iOS/Views/Calendar/EventDetailSheet.swift b/Calendarr iOS/Views/Calendar/EventDetailSheet.swift index f73cef7..a1e7aa1 100644 --- a/Calendarr iOS/Views/Calendar/EventDetailSheet.swift +++ b/Calendarr iOS/Views/Calendar/EventDetailSheet.swift @@ -4,11 +4,16 @@ struct EventDetailSheet: View { let event: CalEvent let api: CalendarrAPI let store: CalendarStore - let onDone: (CalEvent?) async -> Void + /// Called when the sheet should close. + /// - `editEvent`: non-nil when the user wants to edit this event + /// - `forceReload`: true when server data changed (create/copy) and the + /// caller must bypass the cache to fetch fresh events + let onDone: (_ editEvent: CalEvent?, _ forceReload: Bool) async -> Void @Environment(\.dismiss) var dismiss @State private var showDeleteConfirm = false @State private var isDeleting = false + @State private var showCopySheet = false private let timeFmt: DateFormatter = { let f = DateFormatter() @@ -92,6 +97,16 @@ struct EventDetailSheet: View { } } + if !store.writableCalendars.isEmpty { + Section { + Button { + showCopySheet = true + } label: { + Label("Termin kopieren", systemImage: "doc.on.doc") + } + } + } + if canDelete { Section { Button(role: .destructive) { @@ -110,13 +125,13 @@ struct EventDetailSheet: View { .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Schliessen") { - Task { await onDone(nil) } + Task { await onDone(nil, false) } } } if canEdit { ToolbarItem(placement: .primaryAction) { Button("Bearbeiten") { - Task { await onDone(event) } + Task { await onDone(event, false) } } } } @@ -129,6 +144,18 @@ struct EventDetailSheet: View { } message: { Text("\"\(event.title)\" wird dauerhaft gelöscht.") } + .sheet(isPresented: $showCopySheet) { + EventEditorSheet( + api: api, + store: store, + initialDate: event.startDate, + editingEvent: nil, + copyFrom: event + ) { + // Copy created a new server-side event → force reload so it appears + await onDone(nil, true) + } + } } } @@ -149,7 +176,7 @@ struct EventDetailSheet: View { // 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) + await onDone(nil, false) } catch { isDeleting = false } diff --git a/Calendarr iOS/Views/Calendar/EventEditorSheet.swift b/Calendarr iOS/Views/Calendar/EventEditorSheet.swift index 1cf0f51..a967b45 100644 --- a/Calendarr iOS/Views/Calendar/EventEditorSheet.swift +++ b/Calendarr iOS/Views/Calendar/EventEditorSheet.swift @@ -5,6 +5,7 @@ struct EventEditorSheet: View { let store: CalendarStore let initialDate: Date let editingEvent: CalEvent? + let copyFrom: CalEvent? let onSaved: () async -> Void @Environment(\.dismiss) var dismiss @@ -21,6 +22,7 @@ struct EventEditorSheet: View { @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 } @@ -96,9 +98,11 @@ struct EventEditorSheet: View { } } } - .navigationTitle(isEditing - ? L10n.t("event.edit_title", appLang) - : L10n.t("event.new_title", appLang)) + .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) { @@ -116,6 +120,12 @@ struct EventEditorSheet: View { } } .onAppear { setup() } + .onChange(of: startDate) { oldStart, newStart in + guard newStart >= endDate else { return } + let duration = endDate.timeIntervalSince(oldStart) + let minDuration: TimeInterval = isAllDay ? 86400 : 3600 + endDate = newStart.addingTimeInterval(max(duration, minDuration)) + } } private func setup() { @@ -128,6 +138,15 @@ struct EventEditorSheet: View { notes = ev.notes color = ev.color ?? "" selectedCalendarId = ev.calendarId + } else if let ev = copyFrom { + title = ev.title + isAllDay = ev.isAllDay + startDate = ev.startDate + endDate = ev.endDate + location = ev.location + notes = ev.notes + color = ev.color ?? "" + selectedCalendarId = store.writableCalendars.first?.id ?? "" } else { let cal = Calendar.current startDate = cal.date(bySettingHour: cal.component(.hour, from: initialDate), diff --git a/CalendarrWidgets/CalendarDayWidgetView.swift b/CalendarrWidgets/CalendarDayWidgetView.swift new file mode 100644 index 0000000..91521dc --- /dev/null +++ b/CalendarrWidgets/CalendarDayWidgetView.swift @@ -0,0 +1,151 @@ +import SwiftUI +import WidgetKit + +struct CalendarDayWidgetView: View { + let entry: CalendarrEntry + + private var snapshot: WidgetSnapshot? { entry.snapshot } + private var lang: String { snapshot?.language ?? "system" } + + private var cal: Calendar { + var c = Calendar(identifier: .gregorian) + c.locale = WidgetL10n.locale(lang) + c.firstWeekday = 2 + return c + } + + private var weekDays: [Date] { + let start = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: entry.date)) ?? entry.date + return (0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: start) } + } + + private var upcomingEvents: [WidgetEvent] { + guard let s = snapshot else { return [] } + return WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s) + } + + private var monthFmt: DateFormatter { + let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "LLLL"; return f + } + private var weekdayFmt: DateFormatter { + let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "EEEE"; return f + } + private var timeFmt: DateFormatter { + let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "HH:mm"; return f + } + + var body: some View { + if let s = snapshot { + let primary = Color(widgetHex: s.primaryColorHex) + let accent = Color(widgetHex: s.accentColorHex) + VStack(alignment: .leading, spacing: 0) { + header(primary: primary) + weekStrip(snapshot: s, primary: primary, accent: accent) + .padding(.vertical, 5) + Rectangle() + .fill(Color(widgetHex: s.lineColorHex).opacity(0.4)) + .frame(height: 0.5) + .padding(.bottom, 6) + eventList(accent: accent) + } + } else { + Text(WidgetL10n.t("widget.no_data", lang)) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + // MARK: – Header + + private func header(primary: Color) -> some View { + HStack(alignment: .top, spacing: 6) { + Text("\(cal.component(.day, from: entry.date))") + .font(.system(size: 36, weight: .bold)) + .foregroundStyle(primary) + .frame(width: 44, alignment: .leading) + .minimumScaleFactor(0.7) + VStack(alignment: .leading, spacing: 1) { + Text(monthFmt.string(from: entry.date).uppercased()) + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(primary) + Text("\(WidgetL10n.t("widget.today", lang)), \(weekdayFmt.string(from: entry.date))") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + Spacer(minLength: 0) + } + .padding(.bottom, 2) + } + + // MARK: – Week strip + + private func weekStrip(snapshot: WidgetSnapshot, primary: Color, accent: Color) -> some View { + HStack(spacing: 0) { + ForEach(weekDays, id: \.self) { day in + let isToday = cal.isDateInToday(day) + let hasEvs = !WidgetHelpers.events(for: day, in: snapshot).isEmpty + VStack(spacing: 2) { + Text(shortDay(day)) + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(isToday ? accent : .secondary) + ZStack { + if isToday { + Circle().fill(primary) + } else if hasEvs { + Circle().fill(accent.opacity(0.18)) + } + Text("\(cal.component(.day, from: day))") + .font(.system(size: 11, weight: isToday ? .bold : .medium)) + .foregroundStyle(isToday ? .white : .primary) + } + .frame(width: 22, height: 22) + } + .frame(maxWidth: .infinity) + } + } + } + + private func shortDay(_ date: Date) -> String { + let f = DateFormatter() + f.locale = WidgetL10n.locale(lang) + f.dateFormat = "EEE" + return String(f.string(from: date).prefix(2)).uppercased() + } + + // MARK: – Event list + + @ViewBuilder + private func eventList(accent: Color) -> some View { + if upcomingEvents.isEmpty { + Text(WidgetL10n.t("widget.no_events", lang)) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + } else { + VStack(alignment: .leading, spacing: 5) { + ForEach(upcomingEvents.prefix(3)) { ev in + HStack(alignment: .center, spacing: 6) { + RoundedRectangle(cornerRadius: 1.5) + .fill(Color(widgetHex: ev.colorHex)) + .frame(width: 3, height: 26) + VStack(alignment: .leading, spacing: 1) { + Text(ev.title) + .font(.system(size: 11, weight: .semibold)) + .lineLimit(1) + Text(ev.isAllDay + ? WidgetL10n.t("widget.allday", lang) + : "\(timeFmt.string(from: ev.start)) – \(timeFmt.string(from: ev.end))") + .font(.system(size: 9)) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + } + Spacer(minLength: 0) + } + } + } +} diff --git a/CalendarrWidgets/CalendarrWidgets.swift b/CalendarrWidgets/CalendarrWidgets.swift index 9511c9e..67e59bb 100644 --- a/CalendarrWidgets/CalendarrWidgets.swift +++ b/CalendarrWidgets/CalendarrWidgets.swift @@ -11,10 +11,12 @@ struct CalendarrWidgetBundle: WidgetBundle { TwoWeeksWidget() UpcomingWidget() UpNextWidget() + CalendarDayWidget() + LockScreenWidget() } } -// Shared chrome modifier — keeps every widget on the same theme. +// Shared chrome modifier — keeps every home-screen widget on the same theme. private struct CalendarrWidgetChrome: ViewModifier { let snapshot: WidgetSnapshot? @@ -139,3 +141,35 @@ struct UpNextWidget: Widget { .supportedFamilies([.systemMedium]) } } + +// MARK: – Calendar Day: date + week strip + events (medium) + +struct CalendarDayWidget: Widget { + let kind: String = "CalendarDayWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + CalendarDayWidgetView(entry: entry).calendarrChrome(entry.snapshot) + } + .configurationDisplayName(WidgetL10n.t("widget.display.calday_title", "system")) + .description(WidgetL10n.t("widget.display.calday_desc", "system")) + .supportedFamilies([.systemMedium]) + } +} + +// MARK: – Lock Screen (circular, rectangular, inline) + +struct LockScreenWidget: Widget { + let kind: String = "LockScreenWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + LockScreenWidgetView(entry: entry) + .containerBackground(for: .widget) { Color.clear } + .environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system")) + } + .configurationDisplayName(WidgetL10n.t("widget.display.lockscreen_title", "system")) + .description(WidgetL10n.t("widget.display.lockscreen_desc", "system")) + .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline]) + } +} diff --git a/CalendarrWidgets/LockScreenWidgetViews.swift b/CalendarrWidgets/LockScreenWidgetViews.swift new file mode 100644 index 0000000..63b3771 --- /dev/null +++ b/CalendarrWidgets/LockScreenWidgetViews.swift @@ -0,0 +1,99 @@ +import SwiftUI +import WidgetKit + +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") + } +} diff --git a/CalendarrWidgets/NowNextWidgetView.swift b/CalendarrWidgets/NowNextWidgetView.swift new file mode 100644 index 0000000..c8f724e --- /dev/null +++ b/CalendarrWidgets/NowNextWidgetView.swift @@ -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.accentColor.opacity(0.5) + + 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) + } +} diff --git a/CalendarrWidgets/ThisWeekWidgetView.swift b/CalendarrWidgets/ThisWeekWidgetView.swift index e547ade..0bdf1bd 100644 --- a/CalendarrWidgets/ThisWeekWidgetView.swift +++ b/CalendarrWidgets/ThisWeekWidgetView.swift @@ -10,7 +10,7 @@ struct ThisWeekWidgetView: View { private var cal: Calendar { var c = Calendar(identifier: .gregorian) c.locale = WidgetL10n.locale(lang) - c.firstWeekday = 2 // Monday + c.firstWeekday = 2 return c } @@ -40,7 +40,7 @@ struct ThisWeekWidgetView: View { if let s = snapshot { let primary = Color(widgetHex: s.primaryColorHex) let accent = Color(widgetHex: s.accentColorHex) - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 3) { HStack(alignment: .firstTextBaseline, spacing: 4) { Text(monthHeader.split(separator: " ").first.map(String.init) ?? "") .font(.system(size: 10, weight: .bold)) @@ -48,22 +48,21 @@ struct ThisWeekWidgetView: View { Text(monthHeader.split(separator: " ").dropFirst().joined(separator: " ")) .font(.system(size: 10, weight: .semibold)) } - GeometryReader { geo in - let colW = geo.size.width / 7 - HStack(spacing: 0) { - ForEach(Array(weekDays.enumerated()), id: \.offset) { idx, day in - dayColumn(day, snapshot: s, primary: primary, accent: accent) - .frame(width: colW) - .overlay(alignment: .trailing) { - if idx < 6 { - Rectangle() - .fill(Color(widgetHex: s.lineColorHex).opacity(0.35)) - .frame(width: 0.5) - } + // 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)) @@ -80,22 +79,22 @@ struct ThisWeekWidgetView: 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(spacing: 1) { + 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: 15, height: 15) + .frame(width: 16, height: 16) .background(isToday ? primary : Color.clear) .clipShape(Circle()) - ForEach(evs.prefix(2)) { ev in + ForEach(evs.prefix(3)) { ev in eventPill(ev) } - if evs.count > 2 { - Text("+\(evs.count - 2)") - .font(.system(size: 7)) + if evs.count > 3 { + Text("+\(evs.count - 3)") + .font(.system(size: 6.5)) .foregroundStyle(accent) } Spacer(minLength: 0) diff --git a/CalendarrWidgets/TwoMonthWidgetView.swift b/CalendarrWidgets/TwoMonthWidgetView.swift new file mode 100644 index 0000000..2838ca3 --- /dev/null +++ b/CalendarrWidgets/TwoMonthWidgetView.swift @@ -0,0 +1,170 @@ +import SwiftUI +import WidgetKit + +struct TwoMonthWidgetView: View { + let entry: CalendarrEntry + @Environment(\.widgetFamily) private var family + + private var snapshot: WidgetSnapshot? { entry.snapshot } + private var lang: String { snapshot?.language ?? "system" } + + private var cal: Calendar { + var c = Calendar(identifier: .gregorian) + c.locale = WidgetL10n.locale(lang) + c.firstWeekday = 2 + return c + } + + private var thisMonth: Date { + cal.date(from: cal.dateComponents([.year, .month], from: entry.date)) ?? entry.date + } + + private var nextMonth: Date { + cal.date(byAdding: .month, value: 1, to: thisMonth) ?? thisMonth + } + + // Weekday header labels (M T W T F S S) + private var weekdayHeaders: [String] { + let f = DateFormatter(); f.locale = WidgetL10n.locale(lang) + let symbols = f.veryShortWeekdaySymbols ?? cal.veryShortWeekdaySymbols + let start = cal.firstWeekday - 1 + return (0..<7).map { String(symbols[(start + $0) % 7]).uppercased() } + } + + // Number of date rows to show (5 for medium, 6 for large) + private var rowCount: Int { family == .systemLarge ? 6 : 5 } + + var body: some View { + if let s = snapshot { + let primary = Color(widgetHex: s.primaryColorHex) + let accent = Color(widgetHex: s.accentColorHex) + let line = Color(widgetHex: s.lineColorHex) + HStack(alignment: .top, spacing: 0) { + monthColumn(monthDate: thisMonth, snapshot: s, + primary: primary, accent: accent, line: line) + .frame(maxWidth: .infinity) + line.opacity(0.35).frame(width: 0.5) + .padding(.horizontal, 3) + monthColumn(monthDate: nextMonth, snapshot: s, + primary: primary, accent: accent, line: line) + .frame(maxWidth: .infinity) + } + } else { + Text(WidgetL10n.t("widget.no_data", lang)) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + // MARK: – One month column + + private func monthColumn(monthDate: Date, + snapshot: WidgetSnapshot, + primary: Color, + accent: Color, + line: Color) -> some View { + let monthFmt = DateFormatter() + monthFmt.locale = WidgetL10n.locale(lang) + monthFmt.dateFormat = "LLLL" + let name = monthFmt.string(from: monthDate).uppercased() + let start = gridStart(for: monthDate) + let wn = WidgetL10n.t("widget.cw", lang) + + return VStack(alignment: .leading, spacing: 1) { + // Month name + Text(name) + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(primary) + + // Column headers: KW + 7 weekdays + HStack(spacing: 0) { + Text(wn) + .font(.system(size: 6, weight: .bold)) + .foregroundStyle(.secondary) + .frame(width: 14, alignment: .center) + ForEach(weekdayHeaders, id: \.self) { h in + Text(h) + .font(.system(size: 6.5, weight: .bold)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + } + } + + // Date rows + ForEach(0.. 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 + } +} diff --git a/CalendarrWidgets/WidgetSupport.swift b/CalendarrWidgets/WidgetSupport.swift index 56c1aa4..f080405 100644 --- a/CalendarrWidgets/WidgetSupport.swift +++ b/CalendarrWidgets/WidgetSupport.swift @@ -48,20 +48,24 @@ enum WidgetL10n { "widget.more": "+%d weitere", "widget.upcoming": "Nächste 5 Tage", "widget.no_data": "Keine Daten – App einmal öffnen", - "widget.display.today_title": "Heute", - "widget.display.today_desc": "Heutige Termine auf einen Blick.", - "widget.display.days_title": "Heute & Morgen", - "widget.display.days_desc": "Termine der nächsten zwei Tage.", - "widget.display.upcoming_title": "Nächste 5 Tage", - "widget.display.upcoming_desc": "Termine der nächsten 5 Tage.", - "widget.display.thisweek_title": "Diese Woche", - "widget.display.thisweek_desc": "Wochenraster mit Terminen.", - "widget.display.twoweeks_title": "Zwei Wochen", - "widget.display.twoweeks_desc": "Zwei-Wochen-Raster mit Terminen.", - "widget.display.threedays_title": "Drei Tage", - "widget.display.threedays_desc": "Drei-Tages-Ansicht mit Terminen.", - "widget.display.upnext_title": "Up Next + Kalender", - "widget.display.upnext_desc": "Nächste Termine mit Monatsübersicht." + "widget.display.today_title": "Heute", + "widget.display.today_desc": "Heutige Termine auf einen Blick.", + "widget.display.days_title": "Heute & Morgen", + "widget.display.days_desc": "Termine der nächsten zwei Tage.", + "widget.display.upcoming_title": "Nächste 5 Tage", + "widget.display.upcoming_desc": "Termine der nächsten 5 Tage.", + "widget.display.thisweek_title": "Diese Woche", + "widget.display.thisweek_desc": "Wochenraster mit Terminen.", + "widget.display.twoweeks_title": "Zwei Wochen", + "widget.display.twoweeks_desc": "Zwei-Wochen-Raster mit Terminen.", + "widget.display.threedays_title": "Drei Tage", + "widget.display.threedays_desc": "Drei-Tages-Ansicht mit Terminen.", + "widget.display.upnext_title": "Up Next + Kalender", + "widget.display.upnext_desc": "Nächste Termine mit Monatsübersicht.", + "widget.display.calday_title": "Tag & Termine", + "widget.display.calday_desc": "Datum, Wochenübersicht und nächste Termine.", + "widget.display.lockscreen_title": "Sperrbildschirm", + "widget.display.lockscreen_desc": "Datum und nächster Termin auf dem Sperrbildschirm." ], "en": [ "widget.today": "Today", @@ -71,20 +75,24 @@ enum WidgetL10n { "widget.more": "+%d more", "widget.upcoming": "Next 5 days", "widget.no_data": "No data – open the app once", - "widget.display.today_title": "Today", - "widget.display.today_desc": "Today's events at a glance.", - "widget.display.days_title": "Today & tomorrow", - "widget.display.days_desc": "Events for the next two days.", - "widget.display.upcoming_title": "Next 5 days", - "widget.display.upcoming_desc": "Events for the next 5 days.", - "widget.display.thisweek_title": "This Week", - "widget.display.thisweek_desc": "Week grid with events.", - "widget.display.twoweeks_title": "Two Weeks", - "widget.display.twoweeks_desc": "Two-week grid with events.", - "widget.display.threedays_title": "Three Days", - "widget.display.threedays_desc": "Three-day view with events.", - "widget.display.upnext_title": "Up Next + Calendar", - "widget.display.upnext_desc": "Next events with month overview." + "widget.display.today_title": "Today", + "widget.display.today_desc": "Today's events at a glance.", + "widget.display.days_title": "Today & tomorrow", + "widget.display.days_desc": "Events for the next two days.", + "widget.display.upcoming_title": "Next 5 days", + "widget.display.upcoming_desc": "Events for the next 5 days.", + "widget.display.thisweek_title": "This Week", + "widget.display.thisweek_desc": "Week grid with events.", + "widget.display.twoweeks_title": "Two Weeks", + "widget.display.twoweeks_desc": "Two-week grid with events.", + "widget.display.threedays_title": "Three Days", + "widget.display.threedays_desc": "Three-day view with events.", + "widget.display.upnext_title": "Up Next + Calendar", + "widget.display.upnext_desc": "Next events with month overview.", + "widget.display.calday_title": "Day & Events", + "widget.display.calday_desc": "Date, week overview and upcoming events.", + "widget.display.lockscreen_title": "Lock Screen", + "widget.display.lockscreen_desc": "Date and next event on the lock screen." ] ] } From b1e0cf1fdcfedd1f8061dda66aa64ce76968518c Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 31 May 2026 19:22:12 +0200 Subject: [PATCH 09/90] =?UTF-8?q?WIP:=20Widget-,=20Sync-=20&=20Event-Edito?= =?UTF-8?q?r-=C3=84nderungen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwischenstand vor den Sharing/Gruppen/Import-Export-Features (gesichert, damit die neuen Features sauber darauf aufbauen). --- Calendarr iOS.xcodeproj/project.pbxproj | 6 +- Calendarr iOS/Models/CalendarStore.swift | 4 +- Calendarr iOS/Models/Localization.swift | 4 + .../Views/Calendar/CalendarHostView.swift | 101 +++++----- .../Views/Calendar/EventDetailSheet.swift | 8 +- .../Views/Calendar/EventEditorSheet.swift | 32 ++- Calendarr iOS/Views/Calendar/MonthView.swift | 4 +- CalendarrWidgets/CalendarrWidgets.swift | 70 ++++++- CalendarrWidgets/LockScreenWidgetViews.swift | 189 ++++++++++++++++++ CalendarrWidgets/NowNextWidgetView.swift | 2 +- CalendarrWidgets/WidgetSupport.swift | 30 ++- 11 files changed, 375 insertions(+), 75 deletions(-) diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index 85ec7a3..c74c66e 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -498,6 +498,7 @@ INFOPLIST_KEY_CFBundleDisplayName = Calendarr; INFOPLIST_KEY_CFBundleName = Calendarr; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -509,7 +510,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -540,6 +541,7 @@ INFOPLIST_KEY_CFBundleDisplayName = Calendarr; INFOPLIST_KEY_CFBundleName = Calendarr; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -551,7 +553,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index e567842..46b29fb 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -277,11 +277,13 @@ class CalendarStore { } /// Trigger a full cache reload (e.g. when cache-range setting changes). + /// Intentionally keeps `events` intact so the UI stays populated while + /// the network fetch runs; `refreshFromCache` will swap in fresh data + /// atomically once it arrives. func invalidateCache() { cachedStart = nil cachedEnd = nil allCachedEvents = [] - events = [] } private func mergeIntoCache(_ newEvents: [CalEvent], rangeStart: Date, rangeEnd: Date) { diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index 7f2b548..0e896ef 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -213,6 +213,8 @@ private let strings: [String: [String: String]] = [ "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", @@ -472,6 +474,8 @@ private let strings: [String: [String: String]] = [ "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", diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index 15ec78f..19aaf48 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -1,5 +1,16 @@ 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 @@ -14,9 +25,7 @@ struct CalendarHostView: View { @Environment(\.scenePhase) private var scenePhase @State private var store = CalendarStore() - @State private var showEditor = false - @State private var editorDate: Date = .now - @State private var editingEvent: CalEvent? = nil + @State private var editorContext: CalEditorContext? = nil @State private var selectedEvent: CalEvent? = nil @State private var visibleMonth: Date = .now @State private var showFilter = false @@ -40,6 +49,19 @@ struct CalendarHostView: View { } } + // MARK: – Loading indicator + + @ViewBuilder + private var loadingIndicator: some View { + if store.isLoading || store.isCachingBackground { + ProgressView() + .padding(14) + .background(.regularMaterial, in: Circle()) + .shadow(color: .black.opacity(0.12), radius: 6, y: 2) + .transition(.opacity.combined(with: .scale(scale: 0.85))) + } + } + // MARK: – Flat variant private var flatVariant: some View { @@ -51,22 +73,11 @@ struct CalendarHostView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(hex: bgHex)) .overlay(alignment: .top) { - if store.isLoading { - ProgressView().padding(.top, 10).transition(.opacity) - } + loadingIndicator.padding(.top, 12) } + .animation(.easeInOut(duration: 0.2), value: store.isLoading || store.isCachingBackground) } .overlay(alignment: .bottomTrailing) { solidFAB } - // Subtle background cache indicator (top-leading) - .overlay(alignment: .topLeading) { - if store.isCachingBackground { - Image(systemName: "arrow.triangle.2.circlepath") - .font(.caption2) - .foregroundStyle(.secondary) - .padding(6) - .transition(.opacity) - } - } .modifier(calendarSheets) .task { await startup() } .onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } } @@ -93,10 +104,9 @@ struct CalendarHostView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(hex: bgHex)) .overlay(alignment: .top) { - if store.isLoading { - ProgressView().padding(.top, 10).transition(.opacity) - } + loadingIndicator.padding(.top, 12) } + .animation(.easeInOut(duration: 0.2), value: store.isLoading || store.isCachingBackground) .overlay(alignment: .top) { if let err = store.lastError { errorBannerView(err).padding(.top, 8) } } @@ -247,13 +257,9 @@ struct CalendarHostView: View { case .month: // Month view uses vertical scroll – no horizontal swipe. MonthView(store: store, - onDayTap: { editorDate = $0 }, + onDayTap: { store.currentDate = $0 }, onEventTap: { selectedEvent = $0 }, - onCreateEvent: { day in - editingEvent = nil - editorDate = day - showEditor = true - }, + onCreateEvent: { day in editorContext = .create(day) }, onShowWeek: { day in store.currentDate = day store.viewType = .week @@ -266,11 +272,7 @@ struct CalendarHostView: View { case .week: WeekView(store: store, onEventTap: { selectedEvent = $0 }, - onCreateEvent: { date in - editingEvent = nil - editorDate = date - showEditor = true - }, + onCreateEvent: { date in editorContext = .create(date) }, onShowMonth: { date in store.currentDate = date store.viewType = .month @@ -283,11 +285,7 @@ struct CalendarHostView: View { case .day: DayView(store: store, onEventTap: { selectedEvent = $0 }, - onCreateEvent: { date in - editingEvent = nil - editorDate = date - showEditor = true - }) + onCreateEvent: { date in editorContext = .create(date) }) .simultaneousGesture(swipe) case .quarter: QuarterView(store: store, onEventTap: { selectedEvent = $0 }) @@ -302,7 +300,7 @@ struct CalendarHostView: View { /// Standard solid FAB (flat mode) private var solidFAB: some View { Button { - editingEvent = nil; editorDate = .now; showEditor = true + editorContext = .create(.now) } label: { Image(systemName: "plus") .font(.system(size: 22, weight: .semibold)) @@ -320,7 +318,7 @@ struct CalendarHostView: View { private var glassFAB: some View { if #available(iOS 26, *) { Button { - editingEvent = nil; editorDate = .now; showEditor = true + editorContext = .create(.now) } label: { Image(systemName: "plus") .font(.system(size: 22, weight: .semibold)) @@ -338,8 +336,7 @@ struct CalendarHostView: View { // MARK: – Sheets modifier private var calendarSheets: CalendarSheets { - CalendarSheets(store: store, showEditor: $showEditor, - editorDate: $editorDate, editingEvent: $editingEvent, + CalendarSheets(store: store, editorContext: $editorContext, selectedEvent: $selectedEvent, showFilter: $showFilter, api: api, reload: { await onNavigate() }, @@ -437,9 +434,7 @@ struct CalendarHostView: View { private struct CalendarSheets: ViewModifier { let store: CalendarStore - @Binding var showEditor: Bool - @Binding var editorDate: Date - @Binding var editingEvent: CalEvent? + @Binding var editorContext: CalEditorContext? @Binding var selectedEvent: CalEvent? @Binding var showFilter: Bool let api: CalendarrAPI @@ -448,21 +443,23 @@ private struct CalendarSheets: ViewModifier { func body(content: Content) -> some View { content - .sheet(isPresented: $showEditor) { + // Use sheet(item:) so the editing event is captured atomically – + // avoiding the race where sheet(isPresented:) evaluates its content + // before the editingEvent state update propagates. + .sheet(item: $editorContext) { ctx in + let editingEv: CalEvent? = { if case .edit(let ev) = ctx { return ev }; return nil }() + let date: Date = { if case .create(let d) = ctx { return d }; return .now }() EventEditorSheet(api: api, store: store, - initialDate: editorDate, editingEvent: editingEvent) { - // Create/edit changed server state → bust the cache so the - // new/updated event appears without a manual sync. - editingEvent = nil; await reloadForce() + initialDate: date, editingEvent: editingEv) { + editorContext = nil + await reloadForce() } } .sheet(item: $selectedEvent) { ev in - EventDetailSheet(event: ev, api: api, store: store) { updated in + EventDetailSheet(event: ev, api: api, store: store) { updated, needsForce in selectedEvent = nil - if let u = updated { editingEvent = u; showEditor = true } - // Delete already removed the event from the cache optimistically; - // a light cache refresh is enough here. - await reload() + if let u = updated { editorContext = .edit(u) } + if needsForce { await reloadForce() } else { await reload() } } } .sheet(isPresented: $showFilter) { diff --git a/Calendarr iOS/Views/Calendar/EventDetailSheet.swift b/Calendarr iOS/Views/Calendar/EventDetailSheet.swift index a1e7aa1..eaad07c 100644 --- a/Calendarr iOS/Views/Calendar/EventDetailSheet.swift +++ b/Calendarr iOS/Views/Calendar/EventDetailSheet.swift @@ -42,14 +42,10 @@ struct EventDetailSheet: View { } private var canEdit: Bool { - event.source == "local" || event.source == "caldav" + event.source == "local" || event.source == "caldav" || event.source == "homeassistant" } - /// Home Assistant events can't be edited in-app (no editor support), but - /// the server does support deleting them. - private var canDelete: Bool { - canEdit || event.source == "homeassistant" - } + private var canDelete: Bool { canEdit } var body: some View { NavigationStack { diff --git a/Calendarr iOS/Views/Calendar/EventEditorSheet.swift b/Calendarr iOS/Views/Calendar/EventEditorSheet.swift index a967b45..d411325 100644 --- a/Calendarr iOS/Views/Calendar/EventEditorSheet.swift +++ b/Calendarr iOS/Views/Calendar/EventEditorSheet.swift @@ -5,7 +5,7 @@ struct EventEditorSheet: View { let store: CalendarStore let initialDate: Date let editingEvent: CalEvent? - let copyFrom: CalEvent? + var copyFrom: CalEvent? = nil let onSaved: () async -> Void @Environment(\.dismiss) var dismiss @@ -133,16 +133,27 @@ struct EventEditorSheet: View { title = ev.title isAllDay = ev.isAllDay startDate = ev.startDate - endDate = ev.endDate + // All-day end dates are stored as exclusive (day after last); subtract 1 for the picker. + endDate = ev.isAllDay + ? Calendar.current.date(byAdding: .day, value: -1, to: ev.endDate) ?? ev.endDate + : ev.endDate location = ev.location notes = ev.notes color = ev.color ?? "" - selectedCalendarId = ev.calendarId + // 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.endDate + 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 ?? "" @@ -168,10 +179,19 @@ struct EventEditorSheet: View { do { if let ev = editingEvent { - if ev.source == "local" { + switch ev.source { + case "local": try await api.updateLocalEvent(uid: ev.id, title: title, start: start, end: end, isAllDay: isAllDay, location: location, description: notes, color: colorVal) - } else { + 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, diff --git a/Calendarr iOS/Views/Calendar/MonthView.swift b/Calendarr iOS/Views/Calendar/MonthView.swift index aeb24b2..1a1f77b 100644 --- a/Calendarr iOS/Views/Calendar/MonthView.swift +++ b/Calendarr iOS/Views/Calendar/MonthView.swift @@ -53,8 +53,7 @@ struct MonthView: View { headerRow Divider() ScrollView { - LazyVStack(spacing: 0) { - ForEach(weekStarts, id: \.self) { ws in + LazyVStack(spacing: 0) { ForEach(weekStarts, id: \.self) { ws in WeekRow(weekStart: ws, store: store, dividerColor: Color(hex: dividerHex), @@ -72,6 +71,7 @@ struct MonthView: View { } .scrollTargetLayout() } + .scrollIndicators(.hidden) .scrollPosition(id: $scrolledWeek, anchor: .top) .onAppear { if !didInitialScroll { diff --git a/CalendarrWidgets/CalendarrWidgets.swift b/CalendarrWidgets/CalendarrWidgets.swift index 67e59bb..8cb6cb9 100644 --- a/CalendarrWidgets/CalendarrWidgets.swift +++ b/CalendarrWidgets/CalendarrWidgets.swift @@ -12,7 +12,11 @@ struct CalendarrWidgetBundle: WidgetBundle { UpcomingWidget() UpNextWidget() CalendarDayWidget() + TwoMonthWidget() + NowNextEventsWidget() LockScreenWidget() + LockScreenCountWidget() + LockScreenCountdownWidget() } } @@ -157,7 +161,37 @@ struct CalendarDayWidget: Widget { } } -// MARK: – Lock Screen (circular, rectangular, inline) +// MARK: – Two Month calendar grid (medium + large) + +struct TwoMonthWidget: Widget { + let kind: String = "TwoMonthWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + TwoMonthWidgetView(entry: entry).calendarrChrome(entry.snapshot) + } + .configurationDisplayName(WidgetL10n.t("widget.display.twomonth_title", "system")) + .description(WidgetL10n.t("widget.display.twomonth_desc", "system")) + .supportedFamilies([.systemMedium, .systemLarge]) + } +} + +// MARK: – Now & Next events (medium) + +struct NowNextEventsWidget: Widget { + let kind: String = "NowNextEventsWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + NowNextWidgetView(entry: entry).calendarrChrome(entry.snapshot) + } + .configurationDisplayName(WidgetL10n.t("widget.display.nownext_title", "system")) + .description(WidgetL10n.t("widget.display.nownext_desc", "system")) + .supportedFamilies([.systemMedium]) + } +} + +// MARK: – Lock Screen: date (circular, rectangular, inline) struct LockScreenWidget: Widget { let kind: String = "LockScreenWidget" @@ -173,3 +207,37 @@ struct LockScreenWidget: Widget { .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline]) } } + +// MARK: – Lock Screen: today event count (circular, rectangular, inline) + +struct LockScreenCountWidget: Widget { + let kind: String = "LockScreenCountWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + LockScreenCountWidgetView(entry: entry) + .containerBackground(for: .widget) { Color.clear } + .environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system")) + } + .configurationDisplayName(WidgetL10n.t("widget.display.lockscreen_count_title", "system")) + .description(WidgetL10n.t("widget.display.lockscreen_count_desc", "system")) + .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline]) + } +} + +// MARK: – Lock Screen: countdown to next event (circular, rectangular, inline) + +struct LockScreenCountdownWidget: Widget { + let kind: String = "LockScreenCountdownWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + LockScreenCountdownWidgetView(entry: entry) + .containerBackground(for: .widget) { Color.clear } + .environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system")) + } + .configurationDisplayName(WidgetL10n.t("widget.display.lockscreen_countdown_title", "system")) + .description(WidgetL10n.t("widget.display.lockscreen_countdown_desc", "system")) + .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline]) + } +} diff --git a/CalendarrWidgets/LockScreenWidgetViews.swift b/CalendarrWidgets/LockScreenWidgetViews.swift index 63b3771..dc03981 100644 --- a/CalendarrWidgets/LockScreenWidgetViews.swift +++ b/CalendarrWidgets/LockScreenWidgetViews.swift @@ -1,6 +1,8 @@ import SwiftUI import WidgetKit +// MARK: – Date widget (existing) + struct LockScreenWidgetView: View { let entry: CalendarrEntry @Environment(\.widgetFamily) private var family @@ -97,3 +99,190 @@ struct LockScreenWidgetView: View { 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") + } +} diff --git a/CalendarrWidgets/NowNextWidgetView.swift b/CalendarrWidgets/NowNextWidgetView.swift index c8f724e..6a06400 100644 --- a/CalendarrWidgets/NowNextWidgetView.swift +++ b/CalendarrWidgets/NowNextWidgetView.swift @@ -62,7 +62,7 @@ struct NowNextWidgetView: View { private func featuredCard(snapshot: WidgetSnapshot) -> some View { let ev = featuredEvent - let baseColor = ev.map { Color(widgetHex: $0.colorHex) } ?? Color.accentColor.opacity(0.5) + let baseColor = ev.map { Color(widgetHex: $0.colorHex) } ?? Color(widgetHex: snapshot.primaryColorHex) return ZStack(alignment: .leading) { LinearGradient( diff --git a/CalendarrWidgets/WidgetSupport.swift b/CalendarrWidgets/WidgetSupport.swift index f080405..8df82c0 100644 --- a/CalendarrWidgets/WidgetSupport.swift +++ b/CalendarrWidgets/WidgetSupport.swift @@ -64,8 +64,19 @@ enum WidgetL10n { "widget.display.upnext_desc": "Nächste Termine mit Monatsübersicht.", "widget.display.calday_title": "Tag & Termine", "widget.display.calday_desc": "Datum, Wochenübersicht und nächste Termine.", - "widget.display.lockscreen_title": "Sperrbildschirm", - "widget.display.lockscreen_desc": "Datum und nächster Termin auf dem Sperrbildschirm." + "widget.display.lockscreen_title": "Datum", + "widget.display.lockscreen_desc": "Aktuelles Datum und nächster Termin.", + "widget.display.twomonth_title": "Zwei Monate", + "widget.display.twomonth_desc": "Aktueller und nächster Monat auf einen Blick.", + "widget.display.nownext_title": "Jetzt & Nächstes", + "widget.display.nownext_desc": "Aktueller Termin und nächste Ereignisse.", + "widget.cw": "KW", + "widget.running": "Läuft", + "widget.events_count": "Termine", + "widget.display.lockscreen_count_title": "Termine heute", + "widget.display.lockscreen_count_desc": "Anzahl und Liste heutiger Termine.", + "widget.display.lockscreen_countdown_title": "Countdown", + "widget.display.lockscreen_countdown_desc": "Zeit bis zum nächsten Termin." ], "en": [ "widget.today": "Today", @@ -91,8 +102,19 @@ enum WidgetL10n { "widget.display.upnext_desc": "Next events with month overview.", "widget.display.calday_title": "Day & Events", "widget.display.calday_desc": "Date, week overview and upcoming events.", - "widget.display.lockscreen_title": "Lock Screen", - "widget.display.lockscreen_desc": "Date and next event on the lock screen." + "widget.display.lockscreen_title": "Date", + "widget.display.lockscreen_desc": "Current date and next event.", + "widget.display.twomonth_title": "Two Months", + "widget.display.twomonth_desc": "Current and next month at a glance.", + "widget.display.nownext_title": "Now & Next", + "widget.display.nownext_desc": "Current event and upcoming events.", + "widget.cw": "W", + "widget.running": "Running", + "widget.events_count": "Events", + "widget.display.lockscreen_count_title": "Today's Events", + "widget.display.lockscreen_count_desc": "Count and list of today's events.", + "widget.display.lockscreen_countdown_title": "Countdown", + "widget.display.lockscreen_countdown_desc": "Time until your next event." ] ] } From e7e4998fb9b14cd5f42aafb0664eb10f9780e0b0 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 31 May 2026 19:32:31 +0200 Subject: [PATCH 10/90] feat: iOS Datenebene + Ersteller-Anzeige + Privat-Flag - Modelle: CalEvent (creator, isPrivate, owner, isGroupEvent, displayColor), LocalCalendar (owned/sharedBy/permission/group), AppSettings (privateEventVisibility, groupVisibleCalendarId), UserProfile (displayName); neue Modelle CalGroup/GroupMember/DirectoryUser/CalendarShare. - API: Profil-Update (Name/Login), Sharing-CRUD, Gruppen-CRUD + combined, Mitglieder-Farbe, iCal Import (multipart) & Export, private-Flag bei Events. - Event-Detail zeigt "Erstellt von" (wenn != ich) + Privat-Hinweis; Editor hat Privat-Toggle (nur lokale Kalender). Login speichert userId. Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/AppSettings.swift | 77 ++++++++++ Calendarr iOS/Models/CalEvent.swift | 33 +++- Calendarr iOS/Models/Localization.swift | 2 + Calendarr iOS/Services/CalendarrAPI.swift | 145 +++++++++++++++++- .../Views/Calendar/EventDetailSheet.swift | 17 ++ .../Views/Calendar/EventEditorSheet.swift | 16 +- 6 files changed, 282 insertions(+), 8 deletions(-) diff --git a/Calendarr iOS/Models/AppSettings.swift b/Calendarr iOS/Models/AppSettings.swift index d7a6962..bbf5248 100644 --- a/Calendarr iOS/Models/AppSettings.swift +++ b/Calendarr iOS/Models/AppSettings.swift @@ -16,6 +16,8 @@ struct AppSettings: Codable { var textColor: String = "#FFFFFF" var backgroundColor: String = "#000000" var lineColor: String = "#3A3A3C" + var privateEventVisibility: String = "busy" // 'hidden' | 'busy' + var groupVisibleCalendarId: Int? = nil enum CodingKeys: String, CodingKey { case defaultView = "default_view" @@ -33,6 +35,8 @@ struct AppSettings: Codable { case textColor = "text_color" case backgroundColor = "background_color" case lineColor = "line_color" + case privateEventVisibility = "private_event_visibility" + case groupVisibleCalendarId = "group_visible_calendar_id" } init() {} @@ -60,6 +64,8 @@ struct AppSettings: Codable { 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) } } @@ -95,6 +101,27 @@ struct LocalCalendar: Codable, Identifiable { var name: String var color: String var enabled: Bool + var owned: Bool = true + var sharedBy: String? = nil + var permission: String? = nil + var group: Bool = false + + enum CodingKeys: String, CodingKey { + case id, name, color, enabled, owned, permission, group + case sharedBy = "shared_by" + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(Int.self, forKey: .id) + name = try c.decodeIfPresent(String.self, forKey: .name) ?? "" + color = try c.decodeIfPresent(String.self, forKey: .color) ?? "#34a853" + enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? true + owned = try c.decodeIfPresent(Bool.self, forKey: .owned) ?? true + sharedBy = try c.decodeIfPresent(String.self, forKey: .sharedBy) + permission = try c.decodeIfPresent(String.self, forKey: .permission) + group = try c.decodeIfPresent(Bool.self, forKey: .group) ?? false + } } struct ICalSubscription: Codable, Identifiable { @@ -173,6 +200,7 @@ struct HACalendar: Codable, Identifiable { struct UserProfile: Codable { let id: Int let username: String + var displayName: String? var email: String? let isAdmin: Bool let hasAvatar: Bool @@ -180,12 +208,61 @@ struct UserProfile: Codable { enum CodingKeys: String, CodingKey { case id, username, email + case displayName = "display_name" case isAdmin = "is_admin" case hasAvatar = "has_avatar" case totpEnabled = "totp_enabled" } } +// MARK: - Sharing & groups + +struct DirectoryUser: Codable, Identifiable { + let id: Int + let displayName: String + enum CodingKeys: String, CodingKey { case id; case displayName = "display_name" } +} + +struct CalendarShare: Codable, Identifiable { + let userId: Int + let displayName: String? + var permission: String + var id: Int { userId } + enum CodingKeys: String, CodingKey { + case userId = "user_id" + case displayName = "display_name" + case permission + } +} + +struct GroupMember: Codable, Identifiable { + let id: Int + let displayName: String? + var role: String + var color: String? + enum CodingKeys: String, CodingKey { + case id, role, color + case displayName = "display_name" + } +} + +struct CalGroup: Codable, Identifiable { + let id: Int + var name: String + var icon: String? + var role: String? + var memberCount: Int? + var groupCalendarId: Int? + var groupCalendarColor: String? + var members: [GroupMember]? + enum CodingKeys: String, CodingKey { + case id, name, icon, role, members + case memberCount = "member_count" + case groupCalendarId = "group_calendar_id" + case groupCalendarColor = "group_calendar_color" + } +} + extension Color { init(hex: String) { let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) diff --git a/Calendarr iOS/Models/CalEvent.swift b/Calendarr iOS/Models/CalEvent.swift index 195e522..efdbb40 100644 --- a/Calendarr iOS/Models/CalEvent.swift +++ b/Calendarr iOS/Models/CalEvent.swift @@ -1,6 +1,23 @@ import Foundation import SwiftUI +/// Creator (or owner, in the group combined view) of an event. +/// `id` is nil for imported events. +struct EventPerson: Hashable { + let id: Int? + let displayName: String + + static func from(_ json: Any?) -> EventPerson? { + guard let obj = json as? [String: Any], + let name = obj["display_name"] as? String, !name.isEmpty else { return nil } + let id: Int? + if let n = obj["id"] as? Int { id = n } + else if let s = obj["id"] as? String { id = Int(s) } + else { id = nil } + return EventPerson(id: id, displayName: name) + } +} + struct CalEvent: Identifiable, Hashable { let id: String let url: String @@ -15,8 +32,15 @@ struct CalEvent: Identifiable, Hashable { var calendarName: String var calendarColor: String var source: String + var creator: EventPerson? = nil + var isPrivate: Bool = false + // Only set in the group combined view: + var owner: EventPerson? = nil + var isGroupEvent: Bool = false + var displayColor: String? = nil - var effectiveColor: String { color ?? calendarColor } + // Group view supplies a server-resolved colour; otherwise per-event then calendar colour. + var effectiveColor: String { displayColor ?? color ?? calendarColor } static func from(json: [String: Any]) -> CalEvent? { guard @@ -49,7 +73,12 @@ struct CalEvent: Identifiable, Hashable { calendarId: json["calendar_id"].map { "\($0)" } ?? "", calendarName: json["calendar_name"] as? String ?? "", calendarColor: json["calendarColor"] as? String ?? "#4285f4", - source: json["source"] as? String ?? "local" + source: json["source"] as? String ?? "local", + creator: EventPerson.from(json["creator"]), + isPrivate: json["private"] as? Bool ?? false, + owner: EventPerson.from(json["owner"]), + isGroupEvent: json["is_group_event"] as? Bool ?? false, + displayColor: (json["display_color"] as? String).flatMap { $0.isEmpty ? nil : $0 } ) } } diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index 0e896ef..c2524cb 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -201,6 +201,7 @@ private let strings: [String: [String: String]] = [ // Event editor "event.title_placeholder": "Titel", "event.allday": "Ganztägig", + "event.private": "Privat", "event.start": "Start", "event.end": "Ende", "event.location": "Ort", @@ -462,6 +463,7 @@ private let strings: [String: [String: String]] = [ // Event editor "event.title_placeholder": "Title", "event.allday": "All-day", + "event.private": "Private", "event.start": "Start", "event.end": "End", "event.location": "Location", diff --git a/Calendarr iOS/Services/CalendarrAPI.swift b/Calendarr iOS/Services/CalendarrAPI.swift index a1f53c3..bb6a9ab 100644 --- a/Calendarr iOS/Services/CalendarrAPI.swift +++ b/Calendarr iOS/Services/CalendarrAPI.swift @@ -72,6 +72,9 @@ class CalendarrAPI { throw APIError.decodingError } let admin = user["is_admin"] as? Bool ?? false + // Persist id + display name for creator/owner comparisons and display. + UserDefaults.standard.set(user["id"] as? Int ?? 0, forKey: "userId") + UserDefaults.standard.set(user["display_name"] as? String ?? uname, forKey: "displayName") return (token, uname, admin) } @@ -225,7 +228,8 @@ class CalendarrAPI { } func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date, - isAllDay: Bool, location: String, description: String, color: String?) async throws -> CalEvent { + isAllDay: Bool, location: String, description: String, color: String?, + isPrivate: Bool = false) async throws -> CalEvent { var body: [String: Any] = [ "calendar_id": calendarId, "title": title, @@ -233,7 +237,8 @@ class CalendarrAPI { "end": formatISO(end, allDay: isAllDay), "allDay": isAllDay, "location": location, - "description": description + "description": description, + "private": isPrivate ] if let c = color, !c.isEmpty { body["color"] = c } let data = try await request("/api/local/events", method: "POST", body: body) @@ -243,14 +248,16 @@ class CalendarrAPI { } func updateLocalEvent(uid: String, title: String, start: Date, end: Date, - isAllDay: Bool, location: String, description: String, color: String?) async throws { + isAllDay: Bool, location: String, description: String, color: String?, + isPrivate: Bool = false) async throws { var body: [String: Any] = [ "title": title, "start": formatISO(start, allDay: isAllDay), "end": formatISO(end, allDay: isAllDay), "allDay": isAllDay, "location": location, - "description": description + "description": description, + "private": isPrivate ] if let c = color { body["color"] = c } _ = try await request("/api/local/events/\(uid)", method: "PUT", body: body) @@ -392,4 +399,134 @@ class CalendarrAPI { _ = try await request(path, method: "PUT", body: ["enabled": !hidden, "sidebar_hidden": hidden]) } + + // MARK: – Profile (display name / login name / email) + + /// Update profile fields. A login-name change returns a fresh token (the old + /// one becomes invalid) — the caller must store the returned token. + func updateProfile(displayName: String?, username: String?, email: String?) async throws -> String? { + var body: [String: Any] = [:] + if let d = displayName { body["display_name"] = d } + if let u = username { body["username"] = u } + if let e = email { body["email"] = e } else { body["email"] = NSNull() } + let data = try await request("/api/profile/", method: "PUT", body: body) + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + return json?["access_token"] as? String + } + + // MARK: – 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") + } } diff --git a/Calendarr iOS/Views/Calendar/EventDetailSheet.swift b/Calendarr iOS/Views/Calendar/EventDetailSheet.swift index eaad07c..2bf8d39 100644 --- a/Calendarr iOS/Views/Calendar/EventDetailSheet.swift +++ b/Calendarr iOS/Views/Calendar/EventDetailSheet.swift @@ -47,6 +47,11 @@ struct EventDetailSheet: View { 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 { @@ -91,6 +96,18 @@ struct EventDetailSheet: View { Text(event.source.capitalized) .foregroundStyle(.secondary) } + if let creator = event.creator, creator.id != currentUserId { + HStack { + Label("Erstellt von", systemImage: "person") + Spacer() + Text(creator.displayName) + .foregroundStyle(.secondary) + } + } + if event.isPrivate { + Label("Privat", systemImage: "lock") + .foregroundStyle(.secondary) + } } if !store.writableCalendars.isEmpty { diff --git a/Calendarr iOS/Views/Calendar/EventEditorSheet.swift b/Calendarr iOS/Views/Calendar/EventEditorSheet.swift index d411325..1f9b6c8 100644 --- a/Calendarr iOS/Views/Calendar/EventEditorSheet.swift +++ b/Calendarr iOS/Views/Calendar/EventEditorSheet.swift @@ -18,6 +18,7 @@ struct EventEditorSheet: View { @State private var notes = "" @State private var selectedCalendarId: String = "" @State private var color = "" + @State private var isPrivate = false @State private var isSaving = false @State private var error = "" @@ -75,6 +76,13 @@ struct EventEditorSheet: View { } } + if selectedCal?.source == "local" { + Section { + Toggle(L10n.t("event.private", appLang), isOn: $isPrivate) + .tint(Color.accentColor) + } + } + Section(L10n.t("event.color_section", appLang)) { HStack { Text(L10n.t("event.color", appLang)) @@ -140,6 +148,7 @@ struct EventEditorSheet: View { location = ev.location notes = ev.notes color = ev.color ?? "" + isPrivate = ev.isPrivate // HA events use "homeassistant-42" in CalEvent but "ha-42" in WritableCalendar if ev.source == "homeassistant" { let num = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "") @@ -157,6 +166,7 @@ struct EventEditorSheet: View { location = ev.location notes = ev.notes color = ev.color ?? "" + isPrivate = ev.isPrivate selectedCalendarId = store.writableCalendars.first?.id ?? "" } else { let cal = Calendar.current @@ -182,7 +192,8 @@ struct EventEditorSheet: View { 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) + isAllDay: isAllDay, location: location, description: notes, color: colorVal, + isPrivate: isPrivate) case "homeassistant": // No update API exists – delete the old event and recreate with new data. let rawId = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "") @@ -202,7 +213,8 @@ struct EventEditorSheet: View { case "local": _ = try await api.createLocalEvent(calendarId: cal.numericId, title: title, start: start, end: end, isAllDay: isAllDay, - location: location, description: notes, color: colorVal) + location: location, description: notes, color: colorVal, + isPrivate: isPrivate) case "google": try await api.createGoogleEvent(calendarDbId: cal.numericId, title: title, start: start, end: end, isAllDay: isAllDay, From 023f90be3b7523edf3c52ca0bbf752233d5ea5ce Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 31 May 2026 19:35:22 +0200 Subject: [PATCH 11/90] =?UTF-8?q?feat:=20iOS=20Einstellungen=20=E2=80=93?= =?UTF-8?q?=20Profil,=20Privatsph=C3=A4re,=20geteilter=20Kalender?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue Settings-Sektionen: Anzeigename + E-Mail ändern (Login-Name read-only), Private-Termine-Sichtbarkeit (busy/hidden) und Auswahl des für Gruppen sichtbaren Kalenders. Gezielte API-PUTs, damit nicht die ganze AppSettings überschrieben wird. Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/Localization.swift | 26 ++++++ Calendarr iOS/Services/CalendarrAPI.swift | 11 +++ Calendarr iOS/Views/SettingsView.swift | 108 ++++++++++++++++++++++ 3 files changed, 145 insertions(+) diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index c2524cb..166befd 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -127,6 +127,19 @@ private let strings: [String: [String: String]] = [ "settings.monday": "Montag", "settings.sunday": "Sonntag", "settings.dimpast": "Vergangene Termine ausgrauen", + "settings.nav.profile": "Profil", + "settings.saved": "Gespeichert", + "settings.privacy": "Privatsphäre", + "settings.private_visibility": "Private Termine für Gruppen", + "settings.private_visibility.desc": "Wie private Termine für andere Gruppenmitglieder erscheinen", + "settings.private.busy": "Als „Beschäftigt“", + "settings.private.hidden": "Ausblenden", + "settings.calendars": "Geteilter Kalender", + "settings.group_visible": "Für Gruppen sichtbar", + "settings.group_visible.desc": "Wähle, welcher deiner Kalender für Gruppenmitglieder sichtbar ist", + "group.visible.none": "Keiner", + "profile.display_name": "Anzeigename", + "profile.login_name": "Login-Name", "settings.hourheight": "Stundenhöhe", "settings.hourheight.desc": "Platz pro Stunde in der Wochen- & Tagesansicht", @@ -389,6 +402,19 @@ private let strings: [String: [String: String]] = [ "settings.monday": "Monday", "settings.sunday": "Sunday", "settings.dimpast": "Dim past events", + "settings.nav.profile": "Profile", + "settings.saved": "Saved", + "settings.privacy": "Privacy", + "settings.private_visibility": "Private events for groups", + "settings.private_visibility.desc": "How your private events appear to other group members", + "settings.private.busy": "Show as \"Busy\"", + "settings.private.hidden": "Hide", + "settings.calendars": "Shared calendar", + "settings.group_visible": "Visible to groups", + "settings.group_visible.desc": "Choose which of your calendars group members can see", + "group.visible.none": "None", + "profile.display_name": "Display name", + "profile.login_name": "Login name", "settings.hourheight": "Hour height", "settings.hourheight.desc": "Space per hour in week & day view", diff --git a/Calendarr iOS/Services/CalendarrAPI.swift b/Calendarr iOS/Services/CalendarrAPI.swift index bb6a9ab..a50230c 100644 --- a/Calendarr iOS/Services/CalendarrAPI.swift +++ b/Calendarr iOS/Services/CalendarrAPI.swift @@ -414,6 +414,17 @@ class CalendarrAPI { 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] { diff --git a/Calendarr iOS/Views/SettingsView.swift b/Calendarr iOS/Views/SettingsView.swift index 01edd50..0c9e6c6 100644 --- a/Calendarr iOS/Views/SettingsView.swift +++ b/Calendarr iOS/Views/SettingsView.swift @@ -23,9 +23,21 @@ struct SettingsView: View { @AppStorage("weekStartDay") private var weekStartDay = "monday" @AppStorage("dimPastEvents") private var dimPastEvents = false + // Profile chapter (server-backed; loaded on appear). + @State private var displayName = "" + @State private var loginName = "" + @State private var email = "" + @State private var privateVisibility = "busy" + @State private var groupVisibleId = 0 // 0 = none + @State private var ownLocalCals: [LocalCalendar] = [] + @State private var profileMsg = "" + var body: some View { NavigationStack { Form { + profilSection + privatsphaereSection + geteilterKalenderSection liquidGlassSection cacheSection spracheSection @@ -40,6 +52,7 @@ struct SettingsView: View { } // Reflect the latest server values when opening the screen. .task { await SettingsSync.pull(api: api) } + .task { await loadProfile() } // Appearance changes update widgets live; synced values are also pushed // to the server (debounced). `push` itself decides what actually gets // sent based on the sync toggle, so every change can simply call it. @@ -62,6 +75,101 @@ struct SettingsView: View { .onChange(of: settingsSync) { _, on in if on { Task { await SettingsSync.pull(api: api) } } } } + // MARK: – Profil + + var profilSection: some View { + Section(L10n.t("settings.nav.profile", appLang)) { + HStack { + Text(L10n.t("profile.display_name", appLang)) + Spacer() + TextField(L10n.t("profile.display_name", appLang), text: $displayName) + .multilineTextAlignment(.trailing) + } + HStack { + Text(L10n.t("profile.login_name", appLang)) + Spacer() + Text(loginName).foregroundStyle(.secondary) + } + HStack { + Text("E-Mail") + Spacer() + TextField("E-Mail", text: $email) + .multilineTextAlignment(.trailing) + .keyboardType(.emailAddress) + .autocapitalization(.none) + } + Button(L10n.t("event.save", appLang)) { Task { await saveProfile() } } + if !profileMsg.isEmpty { + Text(profileMsg).font(.caption).foregroundStyle(.secondary) + } + } + } + + // MARK: – Privatsphäre + + var privatsphaereSection: some View { + Section { + Picker(L10n.t("settings.private_visibility", appLang), selection: $privateVisibility) { + Text(L10n.t("settings.private.busy", appLang)).tag("busy") + Text(L10n.t("settings.private.hidden", appLang)).tag("hidden") + } + .onChange(of: privateVisibility) { _, v in + Task { try? await api.updatePrivateVisibility(v) } + } + } header: { + Text(L10n.t("settings.privacy", appLang)) + } footer: { + Text(L10n.t("settings.private_visibility.desc", appLang)).font(.caption) + } + } + + // MARK: – Geteilter Kalender + + var geteilterKalenderSection: some View { + Section { + Picker(L10n.t("settings.group_visible", appLang), selection: $groupVisibleId) { + Text(L10n.t("group.visible.none", appLang)).tag(0) + ForEach(ownLocalCals) { cal in + Text(cal.name).tag(cal.id) + } + } + .onChange(of: groupVisibleId) { _, id in + Task { try? await api.updateGroupVisibleCalendar(id == 0 ? nil : id) } + } + } header: { + Text(L10n.t("settings.calendars", appLang)) + } footer: { + Text(L10n.t("settings.group_visible.desc", appLang)).font(.caption) + } + } + + private func loadProfile() async { + if let p = try? await api.getProfile() { + displayName = p.displayName ?? p.username + loginName = p.username + email = p.email ?? "" + } + if let s = try? await api.getSettings() { + privateVisibility = s.privateEventVisibility + groupVisibleId = s.groupVisibleCalendarId ?? 0 + } + if let cals = try? await api.getLocalCalendars() { + ownLocalCals = cals.filter { $0.owned && !$0.group } + } + } + + private func saveProfile() async { + do { + _ = try await api.updateProfile(displayName: displayName.isEmpty ? nil : displayName, + username: nil, + email: email.isEmpty ? "" : email) + UserDefaults.standard.set(displayName, forKey: "displayName") + profileMsg = L10n.t("settings.saved", appLang) + } catch { + profileMsg = error.localizedDescription + } + } + // MARK: – Liquid Glass var liquidGlassSection: some View { From da2e39911c8d81d3bc1c822ba97bcc1bcdfaa0c7 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 31 May 2026 19:40:41 +0200 Subject: [PATCH 12/90] =?UTF-8?q?feat:=20iOS=20Sharing=20+=20iCal=20Import?= =?UTF-8?q?/Export=20f=C3=BCr=20lokale=20Kalender?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kalenderverwaltung: pro lokalem Kalender ein Menü mit Teilen (SharingView: Benutzer aus Verzeichnis, read/read_write, entfernbar), Importieren (.ics File Picker) und Exportieren (Share Sheet). Geteilte Kalender mit "geteilt von"-Badge; Gruppenkalender markiert. Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/Localization.swift | 28 ++++ Calendarr iOS/Views/AccountsView.swift | 177 ++++++++++++++++++++++++ 2 files changed, 205 insertions(+) diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index 166befd..4dfed8f 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -140,6 +140,20 @@ private let strings: [String: [String: String]] = [ "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", "settings.hourheight": "Stundenhöhe", "settings.hourheight.desc": "Platz pro Stunde in der Wochen- & Tagesansicht", @@ -415,6 +429,20 @@ private let strings: [String: [String: String]] = [ "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", "settings.hourheight": "Hour height", "settings.hourheight.desc": "Space per hour in week & day view", diff --git a/Calendarr iOS/Views/AccountsView.swift b/Calendarr iOS/Views/AccountsView.swift index ed9033e..3679ba8 100644 --- a/Calendarr iOS/Views/AccountsView.swift +++ b/Calendarr iOS/Views/AccountsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import UniformTypeIdentifiers struct AccountsView: View { let api: CalendarrAPI @@ -16,6 +17,13 @@ struct AccountsView: View { @State private var errorAlert: String? @State private var banishedKeys: Set = CalendarStore.loadBanishedKeys() + // Sharing / import / export + @State private var shareCalId: Int? + @State private var showImporter = false + @State private var importTargetCalId: Int? + @State private var exportDoc: ExportedICS? + @State private var infoMessage: String? + @AppStorage("appLanguage") private var appLang = "system" var body: some View { @@ -65,10 +73,55 @@ struct AccountsView: View { }, 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 { @@ -114,6 +167,31 @@ struct AccountsView: View { .fill(Color(hex: cal.color)) .frame(width: 12, height: 12) 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 @@ -598,3 +676,102 @@ struct AddHASheet: View { isLoading = false } } + +// MARK: – Sharing / Import-Export helpers + +struct IdentifiableInt: Identifiable { let id: Int } +struct ExportedICS: Identifiable { let id = UUID(); let url: URL } + +/// 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() + } +} From 9fac13f99c571f6e8ddcc8cccf03a16737ff468d Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 31 May 2026 20:08:53 +0200 Subject: [PATCH 13/90] =?UTF-8?q?feat:=20iOS=20Gruppen=20=E2=80=93=20Liste?= =?UTF-8?q?,=20Erstellen/Verwalten,=20kombinierte=20Ansicht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Menü-Eintrag "Gruppen" -> GroupsView (Liste, Erstellen mit Icon-Auswahl + Mitglieder, Verwalten: umbenennen/Icon/Mitglieder/Mitglieder-Farben/löschen). - GroupCombinedView: monatsweise Agenda der überlagerten Mitglieder-Kalender + Gruppenkalender; Termine mit Besitzer-Vorname bzw. 👥 + Ersteller, server-definierte Farben (display_color). Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/Localization.swift | 20 ++ Calendarr iOS/Views/GroupsView.swift | 365 ++++++++++++++++++++++++ Calendarr iOS/Views/MenuSheet.swift | 6 + 3 files changed, 391 insertions(+) create mode 100644 Calendarr iOS/Views/GroupsView.swift diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index 4dfed8f..ecd47d8 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -154,6 +154,16 @@ private let strings: [String: [String: String]] = [ "ics.import_result": "%d importiert, %d übersprungen", "common.info": "Info", "common.done": "Fertig", + "groups.title": "Gruppen", + "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.member_colors": "Farben der Mitglieder", + "group.delete": "Gruppe löschen", "settings.hourheight": "Stundenhöhe", "settings.hourheight.desc": "Platz pro Stunde in der Wochen- & Tagesansicht", @@ -443,6 +453,16 @@ private let strings: [String: [String: String]] = [ "ics.import_result": "%d imported, %d skipped", "common.info": "Info", "common.done": "Done", + "groups.title": "Groups", + "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.member_colors": "Member colours", + "group.delete": "Delete group", "settings.hourheight": "Hour height", "settings.hourheight.desc": "Space per hour in week & day view", diff --git a/Calendarr iOS/Views/GroupsView.swift b/Calendarr iOS/Views/GroupsView.swift new file mode 100644 index 0000000..7fd018e --- /dev/null +++ b/Calendarr iOS/Views/GroupsView.swift @@ -0,0 +1,365 @@ +import SwiftUI + +// 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 { + Text(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 = "👥" + @State private var directory: [DirectoryUser] = [] + @State private var selected: Set = [] + @State private var error = "" + + private let icons = ["👥", "👨‍👩‍👧", "🏠", "❤️", "🧑‍🤝‍🧑", "⚽", "🎓", "💼", "🎉", "🐶", "✈️", "🎵", "🍕", "📚", "🌳", "⭐"] + 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 + Text(ic).font(.title2) + .frame(width: 44, height: 44) + .background(ic == icon ? Color.accentColor.opacity(0.25) : Color(.systemGray6)) + .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 = "👥" + @State private var directory: [DirectoryUser] = [] + @State private var memberIds: Set = [] + @State private var showDeleteConfirm = false + @State private var error = "" + + private let icons = ["👥", "👨‍👩‍👧", "🏠", "❤️", "🧑‍🤝‍🧑", "⚽", "🎓", "💼", "🎉", "🐶", "✈️", "🎵", "🍕", "📚", "🌳", "⭐"] + 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 + Text(ic).font(.title2) + .frame(width: 44, height: 44) + .background(ic == icon ? Color.accentColor.opacity(0.25) : Color(.systemGray6)) + .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 = g.icon ?? "👥" + 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.icon ?? "👥") \(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() } + } + + // Prefix others' events with their first name; group events with 👥 + creator. + private func displayTitle(_ ev: CalEvent) -> String { + let me = UserDefaults.standard.integer(forKey: "userId") + if ev.isGroupEvent { + if let c = ev.creator, c.id != me { return "👥 \(firstName(c.displayName)): \(ev.title)" } + return "👥 \(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 + } +} diff --git a/Calendarr iOS/Views/MenuSheet.swift b/Calendarr iOS/Views/MenuSheet.swift index 032bd5f..16b6881 100644 --- a/Calendarr iOS/Views/MenuSheet.swift +++ b/Calendarr iOS/Views/MenuSheet.swift @@ -62,6 +62,12 @@ struct MenuSheet: View { 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: { From c9803d80a3be3965027845dc421ffd0bf05d8eeb Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 31 May 2026 20:35:04 +0200 Subject: [PATCH 14/90] fix: doppelter L10n-Key "settings.saved" entfernt (Runtime-Crash beim Start) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ein doppelter Dictionary-Key liess die App beim Start abstürzen ("Dictionary literal contains duplicate keys"). Meine Dublette entfernt. Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/Localization.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index ecd47d8..71f98e5 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -128,7 +128,6 @@ private let strings: [String: [String: String]] = [ "settings.sunday": "Sonntag", "settings.dimpast": "Vergangene Termine ausgrauen", "settings.nav.profile": "Profil", - "settings.saved": "Gespeichert", "settings.privacy": "Privatsphäre", "settings.private_visibility": "Private Termine für Gruppen", "settings.private_visibility.desc": "Wie private Termine für andere Gruppenmitglieder erscheinen", @@ -427,7 +426,6 @@ private let strings: [String: [String: String]] = [ "settings.sunday": "Sunday", "settings.dimpast": "Dim past events", "settings.nav.profile": "Profile", - "settings.saved": "Saved", "settings.privacy": "Privacy", "settings.private_visibility": "Private events for groups", "settings.private_visibility.desc": "How your private events appear to other group members", From 6dc8724a9a2a581a1b76cd46f675a82a71f01648 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 31 May 2026 20:55:28 +0200 Subject: [PATCH 15/90] feat: iOS Gruppenansicht direkt im Kalender (Umschalter + Banner) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gruppen sind nicht mehr nur im Menü versteckt: im Top-Bar gibt es einen Gruppen-Umschalter (Persönlich / ). Beim Wählen einer Gruppe zeigt der echte Monats-/Wochen-/Tagesansicht die kombinierte Überlagerung (GET /groups/{id}/combined) mit server-definierten Farben und Besitzer-Präfix; ein Banner "Gruppenansicht: " mit "Verlassen". CalendarStore.activeGroup steuert den Modus. Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/CalendarStore.swift | 32 +++++++++++++ Calendarr iOS/Models/Localization.swift | 6 +++ .../Views/Calendar/CalendarHostView.swift | 48 +++++++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index 46b29fb..8832d73 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -56,6 +56,9 @@ class CalendarStore { var lastError: String? = nil var weekStartsOnMonday = true var writableCalendars: [WritableCalendar] = [] + // When set, the calendar shows the group's combined overlay instead of the + // user's own events. nil = personal view. + var activeGroup: CalGroup? = nil /// Set of `"source:calendarId"` keys the user has chosen to hide from the /// calendar views. Persisted in UserDefaults as a JSON array. Events whose @@ -239,6 +242,20 @@ class CalendarStore { /// 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 { + // Group overlay mode: always fetch the combined view for the range + // (no personal cache), decorating each event with its owner. + if let g = activeGroup { + isLoading = true + lastError = nil + defer { isLoading = false } + do { + let fetched = try await api.fetchGroupCombined(groupId: g.id, start: start, end: end) + events = fetched.map { decorateGroupEvent($0) } + } catch { + lastError = error.localizedDescription + } + return + } if !force, isCached(start: start, end: end) { refreshFromCache(start: start, end: end) return @@ -255,6 +272,21 @@ class CalendarStore { } } + /// 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 { + var e = ev + let me = UserDefaults.standard.integer(forKey: "userId") + 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 = "👥 \(first(c.displayName)): \(ev.title)" } + else { e.title = "👥 \(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 diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index 71f98e5..1be1469 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -154,6 +154,9 @@ private let strings: [String: [String: String]] = [ "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", @@ -452,6 +455,9 @@ private let strings: [String: [String: String]] = [ "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", diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index 19aaf48..0ac3616 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -30,6 +30,7 @@ struct CalendarHostView: View { @State private var visibleMonth: Date = .now @State private var showFilter = false @State private var didApplyDefaultView = false + @State private var groups: [CalGroup] = [] private var titleString: String { if store.viewType == .month { @@ -67,6 +68,7 @@ struct CalendarHostView: View { private var flatVariant: some View { VStack(spacing: 0) { topBar + groupBanner Divider() errorBanner calendarContent @@ -127,6 +129,7 @@ struct CalendarHostView: View { } ToolbarItem(placement: .navigationBarTrailing) { HStack(spacing: 8) { + groupMenu viewPickerMenu Button { showFilter = true } label: { Image(systemName: "line.3.horizontal.decrease.circle") @@ -137,6 +140,7 @@ struct CalendarHostView: View { } } } + .safeAreaInset(edge: .top) { groupBanner } } .overlay(alignment: .bottomTrailing) { glassFAB } .modifier(calendarSheets) @@ -182,6 +186,7 @@ struct CalendarHostView: View { .lineLimit(1) .minimumScaleFactor(0.7) Spacer(minLength: 8) + groupMenu viewPickerMenu filterButton Button { showMenu = true } label: { @@ -205,6 +210,48 @@ struct CalendarHostView: View { .accessibilityLabel(L10n.t("filter.button", appLang)) } + // Group switcher: "Persönlich" + each group. Selecting a group flips the + // calendar into the combined overlay (like the web's group view). + private var groupMenu: some View { + Menu { + Button { switchGroup(nil) } label: { + Label(L10n.t("groups.personal", appLang), + systemImage: store.activeGroup == nil ? "checkmark" : "person") + } + ForEach(groups) { g in + Button { switchGroup(g) } label: { + Label("\(g.icon ?? "👥") \(g.name)", + systemImage: store.activeGroup?.id == g.id ? "checkmark" : "person.2") + } + } + } label: { + Image(systemName: store.activeGroup == nil ? "person.2" : "person.2.fill") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(store.activeGroup == nil ? .primary : Color.accentColor) + .frame(width: 40, height: 40) + } + .accessibilityLabel(L10n.t("groups.title", appLang)) + } + + @ViewBuilder private var groupBanner: some View { + if let g = store.activeGroup { + HStack(spacing: 8) { + Text("\(L10n.t("groups.view_label", appLang)): \(g.icon ?? "👥") \(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 + Task { await onNavigate() } + } + private var viewPickerMenu: some View { Menu { ForEach(CalViewType.allCases, id: \.self) { vt in @@ -352,6 +399,7 @@ struct CalendarHostView: View { applyServerDrivenSettings(initial: true) await store.loadWritableCalendars(api: api) + groups = (try? await api.getGroups()) ?? [] // 1. Load current view immediately (visible) let (s, e) = store.rangeForCurrentView() await store.loadEvents(api: api, start: s, end: e) From 815f2cf01a07408ece4823daf4b87d0be610f2a7 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 31 May 2026 21:05:31 +0200 Subject: [PATCH 16/90] =?UTF-8?q?feat:=20iOS=20Kalenderfarben=20=C3=A4nder?= =?UTF-8?q?bar=20+=20Top-Bar=20entzerrt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Kalenderverwaltung: tappbarer ColorPicker pro Kalender (lokal/iCal direkt; CalDAV/Google/HA klappen ihre Unterkalender mit je eigenem Farbwähler auf). Neue API: updateLocalCalendarColor, updateICalColor, setCalendarColor (caldav/google/homeassistant) -> PUT …/{id} {color}. Geteilte Kalender read-only (nur Besitzer). - Top-Bar: Gruppen-Umschalter nur bei vorhandenen Gruppen, "Heute" nicht mehr quetschbar (fixedSize), kompaktere Icons -> "Heute" wird nicht mehr zu "H…". Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Services/CalendarrAPI.swift | 22 ++++ Calendarr iOS/Views/AccountsView.swift | 101 +++++++++++++----- .../Views/Calendar/CalendarHostView.swift | 22 ++-- 3 files changed, 109 insertions(+), 36 deletions(-) diff --git a/Calendarr iOS/Services/CalendarrAPI.swift b/Calendarr iOS/Services/CalendarrAPI.swift index a50230c..f87631b 100644 --- a/Calendarr iOS/Services/CalendarrAPI.swift +++ b/Calendarr iOS/Services/CalendarrAPI.swift @@ -400,6 +400,28 @@ class CalendarrAPI { body: ["enabled": !hidden, "sidebar_hidden": hidden]) } + // MARK: – Calendar colour + + func updateLocalCalendarColor(id: Int, color: String) async throws { + _ = try await request("/api/local/calendars/\(id)", method: "PUT", body: ["color": color]) + } + + func updateICalColor(id: Int, color: String) async throws { + _ = try await request("/api/ical/subscriptions/\(id)", method: "PUT", body: ["color": color]) + } + + /// Set a per-calendar colour for server-managed sources (caldav/google/homeassistant). + func setCalendarColor(source: String, calendarId: Int, color: String) async throws { + let path: String + switch source { + case "caldav": path = "/api/caldav/calendars/\(calendarId)" + case "google": path = "/api/google/calendars/\(calendarId)" + case "homeassistant": path = "/api/homeassistant/calendars/\(calendarId)" + default: return + } + _ = try await request(path, method: "PUT", body: ["color": color]) + } + // MARK: – Profile (display name / login name / email) /// Update profile fields. A login-name change returns a fresh token (the old diff --git a/Calendarr iOS/Views/AccountsView.swift b/Calendarr iOS/Views/AccountsView.swift index 3679ba8..06beb11 100644 --- a/Calendarr iOS/Views/AccountsView.swift +++ b/Calendarr iOS/Views/AccountsView.swift @@ -131,16 +131,22 @@ struct AccountsView: View { .foregroundStyle(.secondary) } else { ForEach(caldavAccounts) { acc in - 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) + 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) } } } @@ -163,9 +169,9 @@ struct AccountsView: View { } else { ForEach(localCalendars) { cal in HStack { - Circle() - .fill(Color(hex: cal.color)) - .frame(width: 12, height: 12) + CalendarColorDot(hex: cal.color, editable: cal.owned) { hex in + try? await api.updateLocalCalendarColor(id: cal.id, color: hex) + } Text(cal.name) if cal.group { Image(systemName: "person.2.fill").font(.caption2).foregroundStyle(.secondary) @@ -213,9 +219,9 @@ struct AccountsView: View { } else { ForEach(icalSubs) { sub in HStack { - Circle() - .fill(Color(hex: sub.color)) - .frame(width: 12, height: 12) + CalendarColorDot(hex: sub.color) { hex in + try? await api.updateICalColor(id: sub.id, color: hex) + } VStack(alignment: .leading, spacing: 2) { Text(sub.name).font(.body) Text(String(format: L10n.t("accounts.ical.every", appLang), sub.refreshMinutes)) @@ -242,10 +248,20 @@ struct AccountsView: View { .foregroundStyle(.secondary) } else { ForEach(googleAccounts) { acc in - HStack { - Image(systemName: "g.circle.fill") - .foregroundStyle(.red) - Text(acc.email) + 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 @@ -347,12 +363,20 @@ struct AccountsView: View { .foregroundStyle(.secondary) } else { ForEach(haAccounts) { acc in - VStack(alignment: .leading, spacing: 2) { - Text(acc.name).font(.body) - Text(acc.url) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) + 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 @@ -682,6 +706,31 @@ struct AddHASheet: View { 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] diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index 0ac3616..709ad5b 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -129,7 +129,7 @@ struct CalendarHostView: View { } ToolbarItem(placement: .navigationBarTrailing) { HStack(spacing: 8) { - groupMenu + if !groups.isEmpty { groupMenu } viewPickerMenu Button { showFilter = true } label: { Image(systemName: "line.3.horizontal.decrease.circle") @@ -178,23 +178,25 @@ struct CalendarHostView: View { } Button(L10n.t("nav.today", appLang)) { store.moveToToday() } .font(.callout).padding(.horizontal, 6) + .lineLimit(1).fixedSize() } - .padding(.leading, 8) - Spacer(minLength: 8) + .padding(.leading, 6) + Spacer(minLength: 6) Text(titleString) .font(.headline) .lineLimit(1) .minimumScaleFactor(0.7) - Spacer(minLength: 8) - groupMenu + .layoutPriority(1) + Spacer(minLength: 6) + if !groups.isEmpty { groupMenu } viewPickerMenu filterButton Button { showMenu = true } label: { Image(systemName: "line.3.horizontal") .font(.system(size: 18, weight: .medium)) - .frame(width: 40, height: 40) + .frame(width: 36, height: 36) } - .padding(.trailing, 4) + .padding(.trailing, 2) } .frame(height: 48) .background(.bar) @@ -205,7 +207,7 @@ struct CalendarHostView: View { Image(systemName: "line.3.horizontal.decrease.circle") .font(.system(size: 17, weight: .medium)) .foregroundStyle(store.hiddenCalendarKeys.isEmpty ? .primary : Color.accentColor) - .frame(width: 40, height: 40) + .frame(width: 36, height: 36) } .accessibilityLabel(L10n.t("filter.button", appLang)) } @@ -228,7 +230,7 @@ struct CalendarHostView: View { Image(systemName: store.activeGroup == nil ? "person.2" : "person.2.fill") .font(.system(size: 17, weight: .medium)) .foregroundStyle(store.activeGroup == nil ? .primary : Color.accentColor) - .frame(width: 40, height: 40) + .frame(width: 36, height: 36) } .accessibilityLabel(L10n.t("groups.title", appLang)) } @@ -263,7 +265,7 @@ struct CalendarHostView: View { Image(systemName: store.viewType.systemImage) .font(.system(size: 17, weight: .medium)) .foregroundStyle(.primary) - .frame(width: 40, height: 40) + .frame(width: 36, height: 36) } .accessibilityLabel(L10n.t("view.change", appLang)) } From c6f9981a547384a08a6cf707495f4f24c0d2f625 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 31 May 2026 21:14:48 +0200 Subject: [PATCH 17/90] =?UTF-8?q?fix:=20Gruppenansicht=20=E2=80=93=20Termi?= =?UTF-8?q?ne=20=C3=BCber=20Cache/Prefetch=20laden=20(kein=20Range-Gap)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Gruppen-Modus ersetzte events nur mit dem schmalen aktuellen Fetch, statt denselben Cache/Prefetch-Pfad wie die Normalansicht zu nutzen -> Termine erschienen erst nach Scrollen. Jetzt fetchForMode (personal/group) läuft durch loadEvents + prefetchBackground + refreshFromCache; Moduswechsel lädt breit neu. In der Gruppenansicht greift der "ausgeblendet"-Filter nicht. Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/CalendarStore.swift | 35 ++++++++++--------- .../Views/Calendar/CalendarHostView.swift | 4 ++- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index 8832d73..725cfd4 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -219,6 +219,12 @@ class CalendarStore { /// `start` / `end` are kept in the signature for call-site clarity. func refreshFromCache(start: Date, end: Date) { _ = (start, end) + // In group overlay mode show everything (the per-calendar hide/banish + // toggles are for the personal view only). + if activeGroup != nil { + events = allCachedEvents + return + } events = allCachedEvents.filter { ev in let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId) return !hiddenCalendarKeys.contains(key) @@ -242,20 +248,6 @@ class CalendarStore { /// 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 { - // Group overlay mode: always fetch the combined view for the range - // (no personal cache), decorating each event with its owner. - if let g = activeGroup { - isLoading = true - lastError = nil - defer { isLoading = false } - do { - let fetched = try await api.fetchGroupCombined(groupId: g.id, start: start, end: end) - events = fetched.map { decorateGroupEvent($0) } - } catch { - lastError = error.localizedDescription - } - return - } if !force, isCached(start: start, end: end) { refreshFromCache(start: start, end: end) return @@ -264,7 +256,7 @@ class CalendarStore { lastError = nil defer { isLoading = false } do { - let fetched = try await api.fetchEvents(start: start, end: end) + let fetched = try await fetchForMode(api: api, start: start, end: end) mergeIntoCache(fetched, rangeStart: start, rangeEnd: end) refreshFromCache(start: start, end: end) } catch { @@ -272,6 +264,17 @@ class CalendarStore { } } + /// Fetch events for the current mode (personal vs. group overlay). Group + /// events go through the same cache/prefetch/refresh path as personal ones, + /// so the whole visible grid is covered (no "only the middle weeks" gaps). + private func fetchForMode(api: CalendarrAPI, start: Date, end: Date) async throws -> [CalEvent] { + if let g = activeGroup { + let combined = try await api.fetchGroupCombined(groupId: g.id, start: start, end: end) + return combined.map { decorateGroupEvent($0) } + } + return try await api.fetchEvents(start: start, end: end) + } + /// Prefix a combined-view event with its owner (others) or 👥 + creator /// (group calendar). Colour comes from the server's display_color. private func decorateGroupEvent(_ ev: CalEvent) -> CalEvent { @@ -298,7 +301,7 @@ class CalendarStore { isCachingBackground = true defer { isCachingBackground = false } do { - let fetched = try await api.fetchEvents(start: start, end: end) + let fetched = try await fetchForMode(api: api, start: start, end: end) mergeIntoCache(fetched, rangeStart: start, rangeEnd: end) // Refresh visible range from newly expanded cache let (vs, ve) = rangeForCurrentView() diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index 709ad5b..78c4164 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -251,7 +251,9 @@ struct CalendarHostView: View { private func switchGroup(_ g: CalGroup?) { store.activeGroup = g - Task { await onNavigate() } + // 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() } } private var viewPickerMenu: some View { From a62b200dfa3745a2b3a5f7cdc6fa5d639d6205bf Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 31 May 2026 21:33:12 +0200 Subject: [PATCH 18/90] chore: Marketing-Version auf 2.0 (Sharing/Gruppen/Import-Export-Release) Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index c74c66e..f08e5b9 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -510,7 +510,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -553,7 +553,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; From 852e46fcf8aa4d535251910141160d923cb3d116 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 1 Jun 2026 08:50:32 +0200 Subject: [PATCH 19/90] fix: month title disappears in Liquid Glass mode on month change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The glass variant rendered the month title in a `.principal` ToolbarItem, which SwiftUI drops when the state it reads (visibleMonth) changes while on screen — it only reappeared on an unrelated rebuild (opening/closing the menu) and vanished again on the next month change. Switched to `.navigationTitle`, which the system updates reliably. Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Views/Calendar/CalendarHostView.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index 78c4164..6e9e99f 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -112,6 +112,12 @@ struct CalendarHostView: View { .overlay(alignment: .top) { if let err = store.lastError { errorBannerView(err).padding(.top, 8) } } + // The month title uses `.navigationTitle` rather than a + // `.principal` ToolbarItem: a principal item disappears when the + // state it reads (visibleMonth) changes while on screen (month + // change / scroll), reappearing only on an unrelated rebuild. + // navigationTitle is updated reliably by the system. + .navigationTitle(titleString) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { @@ -121,12 +127,6 @@ struct CalendarHostView: View { Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout) } } - ToolbarItem(placement: .principal) { - Text(titleString) - .font(.headline) - .lineLimit(1) - .minimumScaleFactor(0.7) - } ToolbarItem(placement: .navigationBarTrailing) { HStack(spacing: 8) { if !groups.isEmpty { groupMenu } From 7f76df2600570e163352d8aee560bc36832f6e41 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 1 Jun 2026 09:00:48 +0200 Subject: [PATCH 20/90] fix: Liquid Glass month title now updates reliably (custom glass bar) navigationTitle/principal items in the NavigationStack toolbar silently fail to refresh on scroll-driven state changes (visibleMonth), so the month label vanished on month change and only returned after an unrelated rebuild. The glass variant now uses a custom top bar with the same inline Text title as the flat variant (proven to update), styled with glassEffect (iOS 26) / ultraThinMaterial. Co-Authored-By: Claude Opus 4.8 --- .../Views/Calendar/CalendarHostView.swift | 60 ++++++++----------- 1 file changed, 25 insertions(+), 35 deletions(-) diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index 6e9e99f..cb5f435 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -101,7 +101,10 @@ struct CalendarHostView: View { // MARK: – Liquid Glass variant private var glassVariant: some View { - NavigationStack { + VStack(spacing: 0) { + glassTopBar + groupBanner + errorBanner calendarContent .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(hex: bgHex)) @@ -109,38 +112,6 @@ struct CalendarHostView: View { loadingIndicator.padding(.top, 12) } .animation(.easeInOut(duration: 0.2), value: store.isLoading || store.isCachingBackground) - .overlay(alignment: .top) { - if let err = store.lastError { errorBannerView(err).padding(.top, 8) } - } - // The month title uses `.navigationTitle` rather than a - // `.principal` ToolbarItem: a principal item disappears when the - // state it reads (visibleMonth) changes while on screen (month - // change / scroll), reappearing only on an unrelated rebuild. - // navigationTitle is updated reliably by the system. - .navigationTitle(titleString) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - HStack(spacing: 2) { - Button { store.navigatePrev() } label: { Image(systemName: "chevron.left") } - Button { store.navigateNext() } label: { Image(systemName: "chevron.right") } - Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout) - } - } - ToolbarItem(placement: .navigationBarTrailing) { - HStack(spacing: 8) { - if !groups.isEmpty { groupMenu } - viewPickerMenu - Button { showFilter = true } label: { - Image(systemName: "line.3.horizontal.decrease.circle") - .foregroundStyle(store.hiddenCalendarKeys.isEmpty ? .primary : Color.accentColor) - } - .accessibilityLabel(L10n.t("filter.button", appLang)) - Button { showMenu = true } label: { Image(systemName: "line.3.horizontal") } - } - } - } - .safeAreaInset(edge: .top) { groupBanner } } .overlay(alignment: .bottomTrailing) { glassFAB } .modifier(calendarSheets) @@ -163,7 +134,10 @@ struct CalendarHostView: View { // MARK: – Top bar (flat mode) - private var topBar: some View { + /// Shared bar contents (chevrons / today / title / group / view / filter / menu). + /// Used by both the flat and the glass top bar so the inline title — which + /// updates reliably on month change — is identical in both modes. + @ViewBuilder private var barContents: some View { HStack(spacing: 0) { HStack(spacing: 2) { Button { store.navigatePrev() } label: { @@ -199,7 +173,23 @@ struct CalendarHostView: View { .padding(.trailing, 2) } .frame(height: 48) - .background(.bar) + } + + private var topBar: some View { + barContents.background(.bar) + } + + /// Liquid-glass top bar — a custom bar (not the NavigationStack toolbar) so + /// the inline month title reliably refreshes on month change. The system + /// navigation-bar title silently fails to update on scroll-driven state + /// changes (it only reappeared after an unrelated rebuild like opening the + /// menu), which is the bug this fixes. + @ViewBuilder private var glassTopBar: some View { + if #available(iOS 26, *) { + barContents.glassEffect(in: Rectangle()) + } else { + barContents.background(.ultraThinMaterial) + } } private var filterButton: some View { From 8521a285202eb1f79eebc9db810f64932d43d482 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 1 Jun 2026 17:22:02 +0200 Subject: [PATCH 21/90] fix: visible Liquid Glass again, group icon on group events, week today colour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Liquid Glass: the calendar content now scrolls underneath a translucent safeAreaInset bar (real glass look restored) while the inline title stays reliable — toggling Liquid Glass is visibly different again. - Group events are prefixed with the group's own emoji icon (from group settings) instead of a generic people glyph, so they're recognisable. - Week view: today's column header now uses the configured "today" colour instead of the accent colour (matches the current-time line). Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/CalendarStore.swift | 7 +++-- .../Views/Calendar/CalendarHostView.swift | 28 +++++++++++-------- Calendarr iOS/Views/Calendar/WeekView.swift | 2 +- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index 725cfd4..5647875 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -280,10 +280,13 @@ class CalendarStore { private func decorateGroupEvent(_ ev: CalEvent) -> CalEvent { var e = ev let me = UserDefaults.standard.integer(forKey: "userId") + // Use the group's own icon (set in group settings) so group events are + // recognisable; fall back to a generic people glyph. + 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 = "👥 \(first(c.displayName)): \(ev.title)" } - else { e.title = "👥 \(ev.title)" } + 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)" } diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index cb5f435..18ce9f1 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -101,18 +101,24 @@ struct CalendarHostView: View { // MARK: – Liquid Glass variant private var glassVariant: some View { - VStack(spacing: 0) { - glassTopBar - groupBanner - errorBanner - calendarContent - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(hex: bgHex)) - .overlay(alignment: .top) { - loadingIndicator.padding(.top, 12) + // Floating glass bar via safeAreaInset: the calendar content scrolls + // *underneath* the translucent bar (the actual Liquid Glass look), while + // the title is a plain inline Text in the bar — which, unlike the + // NavigationStack toolbar title, refreshes reliably on month change. + 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) + .safeAreaInset(edge: .top, spacing: 0) { + VStack(spacing: 0) { + glassTopBar + groupBanner + errorBanner } - .animation(.easeInOut(duration: 0.2), value: store.isLoading || store.isCachingBackground) - } + } .overlay(alignment: .bottomTrailing) { glassFAB } .modifier(calendarSheets) .task { await startup() } diff --git a/Calendarr iOS/Views/Calendar/WeekView.swift b/Calendarr iOS/Views/Calendar/WeekView.swift index 55128f9..19c53e5 100644 --- a/Calendarr iOS/Views/Calendar/WeekView.swift +++ b/Calendarr iOS/Views/Calendar/WeekView.swift @@ -61,7 +61,7 @@ struct WeekView: View { ForEach(weekDays, id: \.self) { day in Text(headerFmt.string(from: day).uppercased()) .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(cal.isDateInToday(day) ? Color.accentColor : Color(hex: textHex).opacity(secondaryTextOpacity(textContrast))) + .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) From b9547c15f9bae216ffbd3f6a47c57a319b91f651 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 1 Jun 2026 17:54:23 +0200 Subject: [PATCH 22/90] feat: render server display_title for group events (consistent across clients) CalEvent parses display_title; the combined view uses it (group icon + owner prefix from the server) instead of client-side decoration, with a fallback for older servers. Raw title kept for editing. Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/CalEvent.swift | 6 +++++- Calendarr iOS/Models/CalendarStore.swift | 10 ++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Calendarr iOS/Models/CalEvent.swift b/Calendarr iOS/Models/CalEvent.swift index efdbb40..b1259d7 100644 --- a/Calendarr iOS/Models/CalEvent.swift +++ b/Calendarr iOS/Models/CalEvent.swift @@ -38,6 +38,9 @@ struct CalEvent: Identifiable, Hashable { 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 // Group view supplies a server-resolved colour; otherwise per-event then calendar colour. var effectiveColor: String { displayColor ?? color ?? calendarColor } @@ -78,7 +81,8 @@ struct CalEvent: Identifiable, Hashable { 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 } + displayColor: (json["display_color"] as? String).flatMap { $0.isEmpty ? nil : $0 }, + displayTitle: (json["display_title"] as? String).flatMap { $0.isEmpty ? nil : $0 } ) } } diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index 5647875..eaf2581 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -278,10 +278,16 @@ class CalendarStore { /// 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") - // Use the group's own icon (set in group settings) so group events are - // recognisable; fall back to a generic people glyph. let groupIcon = activeGroup?.icon ?? "👥" func first(_ s: String) -> String { s.split(separator: " ").first.map(String.init) ?? s } if ev.isGroupEvent { From b61a90d960d26ff68b1c3d561d08369e9b27f3c7 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 1 Jun 2026 18:00:30 +0200 Subject: [PATCH 23/90] feat: hide individual member calendars in the group view (iOS) The calendar filter, when a group overlay is active, now lists the group's members (+ the shared group calendar) and lets you hide each one individually (Outlook-style). Filtering is client-side via CalendarStore.hiddenGroupKeys (per-member gm: / group-calendar gc keys), reset when switching groups. Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/CalendarStore.swift | 36 ++++++++++- Calendarr iOS/Models/Localization.swift | 2 + .../Views/Calendar/CalendarHostView.swift | 1 + Calendarr iOS/Views/CalendarFilterSheet.swift | 59 +++++++++++++++++++ 4 files changed, 95 insertions(+), 3 deletions(-) diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index eaf2581..6f924e2 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -70,6 +70,15 @@ class CalendarStore { /// show/hide list. Re-activation happens in AccountsView. var banishedCalendarKeys: Set = CalendarStore.loadBanishedKeys() + /// Group-overlay visibility: which members' calendars (`gm:`) 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 = [] + + static func groupMemberKey(_ ownerId: Int) -> String { "gm:\(ownerId)" } + static let groupCalendarKey = "gc" + // Cache bookkeeping private var cachedStart: Date? = nil private var cachedEnd: Date? = nil @@ -114,6 +123,19 @@ class CalendarStore { 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) { + 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 "-" for local / ical / google / homeassistant @@ -219,10 +241,18 @@ class CalendarStore { /// `start` / `end` are kept in the signature for call-site clarity. func refreshFromCache(start: Date, end: Date) { _ = (start, end) - // In group overlay mode show everything (the per-calendar hide/banish - // toggles are for the personal view only). + // 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 { - events = allCachedEvents + 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 diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index 1be1469..67a2a2b 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -164,6 +164,7 @@ private let strings: [String: [String: String]] = [ "group.name": "Name", "group.icon": "Icon", "group.members": "Mitglieder", + "group.calendar": "Gruppenkalender", "group.member_colors": "Farben der Mitglieder", "group.delete": "Gruppe löschen", @@ -465,6 +466,7 @@ private let strings: [String: [String: String]] = [ "group.name": "Name", "group.icon": "Icon", "group.members": "Members", + "group.calendar": "Group calendar", "group.member_colors": "Member colours", "group.delete": "Delete group", diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index 18ce9f1..4fb127d 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -247,6 +247,7 @@ struct CalendarHostView: View { 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() } diff --git a/Calendarr iOS/Views/CalendarFilterSheet.swift b/Calendarr iOS/Views/CalendarFilterSheet.swift index 57e9fc1..23d9ca6 100644 --- a/Calendarr iOS/Views/CalendarFilterSheet.swift +++ b/Calendarr iOS/Views/CalendarFilterSheet.swift @@ -20,12 +20,18 @@ struct CalendarFilterSheet: View { @State private var banished: Set = [] /// All non-banished keys discovered during load — used by bulk show/hide. @State private var allKeys: Set = [] + /// Group-mode: the active group's full detail (members + colours) and the + /// per-member / group-calendar hidden keys. + @State private var groupDetail: CalGroup? = nil + @State private var hiddenGroup: Set = [] var body: some View { NavigationStack { Group { if isLoading { ProgressView(L10n.t("filter.loading", appLang)) + } else if store.activeGroup != nil { + groupFilterList } else if allKeys.isEmpty { Text(L10n.t("filter.empty", appLang)) .foregroundStyle(.secondary) @@ -167,8 +173,61 @@ struct CalendarFilterSheet: View { } } + // MARK: – Group overlay filter (hide individual members / the group calendar) + + @ViewBuilder + private var groupFilterList: some View { + if let g = groupDetail { + List { + Section(header: Text("\(g.icon ?? "👥") \(g.name)")) { + ForEach(g.members ?? []) { m in + groupRow(name: m.displayName ?? "—", + colorHex: m.color ?? "#4285f4", + key: CalendarStore.groupMemberKey(m.id)) + } + groupRow(name: L10n.t("group.calendar", appLang), + colorHex: g.groupCalendarColor ?? "#4285f4", + key: CalendarStore.groupCalendarKey) + } + } + } else { + Text(L10n.t("filter.empty", appLang)).foregroundStyle(.secondary) + } + } + + @ViewBuilder + private func groupRow(name: String, colorHex: String, key: String) -> some View { + let isVisible = !hiddenGroup.contains(key) + Button { + if isVisible { hiddenGroup.insert(key) } else { hiddenGroup.remove(key) } + store.setGroupKeyHidden(key, hidden: isVisible) + } label: { + HStack(spacing: 12) { + Circle() + .fill(Color(hex: colorHex)) + .frame(width: 14, height: 14) + .opacity(isVisible ? 1.0 : 0.35) + Text(name) + .foregroundStyle(isVisible ? .primary : .secondary) + .strikethrough(!isVisible, color: .secondary) + Spacer() + Image(systemName: isVisible ? "eye" : "eye.slash") + .foregroundStyle(isVisible ? Color.accentColor : .secondary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + private func load() async { isLoading = true + // Group overlay: list members (+ the group calendar) to hide individually. + if let g = store.activeGroup { + hiddenGroup = store.hiddenGroupKeys + groupDetail = try? await api.getGroup(id: g.id) + isLoading = false + return + } hidden = store.hiddenCalendarKeys banished = store.banishedCalendarKeys async let c = (try? await api.getCalDAVAccounts()) ?? [] From 51218b9aa31506cebd9d2e9137bd7d3fec57233d Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 1 Jun 2026 18:22:16 +0200 Subject: [PATCH 24/90] fix: restore real Liquid Glass bar; drive title via @State so it refreshes The custom safeAreaInset bar removed the actual iOS-26 Liquid Glass look ("no glass even though enabled"). Restored the system NavigationStack glass toolbar and instead fixed the disappearing month title by driving it from a @State (navTitle) updated via onChange(of: titleString) and keyed with .id, which forces the system bar to refresh on month change. Co-Authored-By: Claude Opus 4.8 --- .../Views/Calendar/CalendarHostView.swift | 77 +++++++++++-------- 1 file changed, 47 insertions(+), 30 deletions(-) diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index 4fb127d..b9298c9 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -31,6 +31,10 @@ struct CalendarHostView: View { @State private var showFilter = false @State private var didApplyDefaultView = false @State private var groups: [CalGroup] = [] + // Drives the Liquid-Glass navigation-bar title. Kept in @State and updated + // via onChange so the system toolbar reliably refreshes on month change + // (reading the computed title directly in the toolbar did not update). + @State private var navTitle = "" private var titleString: String { if store.viewType == .month { @@ -101,27 +105,53 @@ struct CalendarHostView: View { // MARK: – Liquid Glass variant private var glassVariant: some View { - // Floating glass bar via safeAreaInset: the calendar content scrolls - // *underneath* the translucent bar (the actual Liquid Glass look), while - // the title is a plain inline Text in the bar — which, unlike the - // NavigationStack toolbar title, refreshes reliably on month change. - 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) - .safeAreaInset(edge: .top, spacing: 0) { - VStack(spacing: 0) { - glassTopBar - groupBanner - errorBanner + // Real iOS-26 Liquid Glass: the system NavigationStack toolbar renders the + // glass bar. The title is driven by `navTitle` (@State) — updated via + // onChange(of: titleString) and keyed with .id so the system bar reliably + // refreshes on month change (binding the computed title directly did not). + 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) + .overlay(alignment: .top) { + if let err = store.lastError { errorBannerView(err).padding(.top, 8) } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + HStack(spacing: 2) { + Button { store.navigatePrev() } label: { Image(systemName: "chevron.left") } + Button { store.navigateNext() } label: { Image(systemName: "chevron.right") } + Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout) + } + } + ToolbarItem(placement: .principal) { + Text(navTitle) + .font(.headline) + .lineLimit(1) + .minimumScaleFactor(0.7) + .id(navTitle) // force re-creation so the bar refreshes + } + ToolbarItem(placement: .navigationBarTrailing) { + HStack(spacing: 8) { + if !groups.isEmpty { groupMenu } + viewPickerMenu + filterButton + Button { showMenu = true } label: { Image(systemName: "line.3.horizontal") } + } + } + } + .safeAreaInset(edge: .top) { groupBanner } + } .overlay(alignment: .bottomTrailing) { glassFAB } .modifier(calendarSheets) .task { await startup() } + .task { navTitle = titleString } + .onChange(of: titleString) { _, new in navTitle = new } .onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } } .onChange(of: store.viewType) { _, _ in Task { await onNavigate() } } .onChange(of: cacheMonths) { _, _ in Task { await recache() } } @@ -185,19 +215,6 @@ struct CalendarHostView: View { barContents.background(.bar) } - /// Liquid-glass top bar — a custom bar (not the NavigationStack toolbar) so - /// the inline month title reliably refreshes on month change. The system - /// navigation-bar title silently fails to update on scroll-driven state - /// changes (it only reappeared after an unrelated rebuild like opening the - /// menu), which is the bug this fixes. - @ViewBuilder private var glassTopBar: some View { - if #available(iOS 26, *) { - barContents.glassEffect(in: Rectangle()) - } else { - barContents.background(.ultraThinMaterial) - } - } - private var filterButton: some View { Button { showFilter = true } label: { Image(systemName: "line.3.horizontal.decrease.circle") From 451d3d4d6bd5e7a6dd15b356b65f66cddd363911 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 1 Jun 2026 19:06:53 +0200 Subject: [PATCH 25/90] fix: Liquid Glass month title updates via @Observable store (visibleMonth) The system NavigationStack toolbar title would not refresh on a plain @State change (title kept disappearing on iPhone). Moved visibleMonth into the @Observable CalendarStore so the toolbar's read is tracked with @Observable's fine-grained observation and refreshes on month change. Reverted the @State/.id workaround. Real system glass bar retained. Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/CalendarStore.swift | 4 ++++ .../Views/Calendar/CalendarHostView.swift | 21 +++++++------------ Calendarr iOS/Views/Calendar/MonthView.swift | 5 ++--- 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index 6f924e2..720d085 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -51,6 +51,10 @@ class CalendarStore { var events: [CalEvent] = [] var viewType: CalViewType = .month var currentDate: Date = .now + // The month currently scrolled into view (month view). Lives in the store so + // the Liquid-Glass navigation title — read in the system toolbar — updates + // via @Observable tracking (a plain @State did not refresh the toolbar). + var visibleMonth: Date = .now var isLoading = false var isCachingBackground = false var lastError: String? = nil diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index b9298c9..572ca7b 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -27,21 +27,16 @@ struct CalendarHostView: View { @State private var store = CalendarStore() @State private var editorContext: CalEditorContext? = nil @State private var selectedEvent: CalEvent? = nil - @State private var visibleMonth: Date = .now @State private var showFilter = false @State private var didApplyDefaultView = false @State private var groups: [CalGroup] = [] - // Drives the Liquid-Glass navigation-bar title. Kept in @State and updated - // via onChange so the system toolbar reliably refreshes on month change - // (reading the computed title directly in the toolbar did not update). - @State private var navTitle = "" private var titleString: String { if store.viewType == .month { let f = DateFormatter() f.locale = L10n.locale(appLang) f.dateFormat = "LLLL yyyy" - return f.string(from: visibleMonth).capitalized(with: L10n.locale(appLang)) + return f.string(from: store.visibleMonth).capitalized(with: L10n.locale(appLang)) } return store.titleForCurrentView(language: appLang) } @@ -89,7 +84,7 @@ struct CalendarHostView: View { .onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } } .onChange(of: store.viewType) { _, _ in Task { await onNavigate() } } .onChange(of: cacheMonths) { _, _ in Task { await recache() } } - .onChange(of: visibleMonth) { _, new in Task { await ensureLoaded(around: new) } } + .onChange(of: store.visibleMonth) { _, new in Task { await ensureLoaded(around: new) } } .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await SettingsSync.pull(api: api) } } } .onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in store.syncBanishedFromDefaults() @@ -130,11 +125,12 @@ struct CalendarHostView: View { } } ToolbarItem(placement: .principal) { - Text(navTitle) + // Reads store.visibleMonth (@Observable) so the system + // toolbar refreshes on month change. + Text(titleString) .font(.headline) .lineLimit(1) .minimumScaleFactor(0.7) - .id(navTitle) // force re-creation so the bar refreshes } ToolbarItem(placement: .navigationBarTrailing) { HStack(spacing: 8) { @@ -150,12 +146,10 @@ struct CalendarHostView: View { .overlay(alignment: .bottomTrailing) { glassFAB } .modifier(calendarSheets) .task { await startup() } - .task { navTitle = titleString } - .onChange(of: titleString) { _, new in navTitle = new } .onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } } .onChange(of: store.viewType) { _, _ in Task { await onNavigate() } } .onChange(of: cacheMonths) { _, _ in Task { await recache() } } - .onChange(of: visibleMonth) { _, new in Task { await ensureLoaded(around: new) } } + .onChange(of: store.visibleMonth) { _, new in Task { await ensureLoaded(around: new) } } .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await SettingsSync.pull(api: api) } } } .onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in store.syncBanishedFromDefaults() @@ -332,8 +326,7 @@ struct CalendarHostView: View { onShowDay: { day in store.currentDate = day store.viewType = .day - }, - visibleMonth: $visibleMonth) + }) case .week: WeekView(store: store, onEventTap: { selectedEvent = $0 }, diff --git a/Calendarr iOS/Views/Calendar/MonthView.swift b/Calendarr iOS/Views/Calendar/MonthView.swift index 1a1f77b..d06813e 100644 --- a/Calendarr iOS/Views/Calendar/MonthView.swift +++ b/Calendarr iOS/Views/Calendar/MonthView.swift @@ -19,7 +19,6 @@ struct MonthView: View { let onCreateEvent: (Date) -> Void let onShowWeek: (Date) -> Void let onShowDay: (Date) -> Void - @Binding var visibleMonth: Date @AppStorage("appLanguage") private var appLang = "system" @AppStorage("monthDividerColor") private var dividerHex = "#7090c0" @@ -117,8 +116,8 @@ struct MonthView: View { 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 visibleMonth != month { - visibleMonth = month + if store.visibleMonth != month { + store.visibleMonth = month } } } From 68349d36e5e3a8a5359488b63a00663766439e99 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 1 Jun 2026 19:20:32 +0200 Subject: [PATCH 26/90] feat: non-emoji group icons (SF Symbols) for consistent cross-platform look MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group icons are now semantic keys (people/home/heart/work/school/sports/party/ pet/travel/music/food/star) rendered as SF Symbols in the picker, group list, switcher, banner and filter — instead of OS emoji that looked different on every platform. Legacy emoji values still render as a fallback. GroupCombinedView uses the server display_title. Co-Authored-By: Claude Opus 4.8 --- .../Views/Calendar/CalendarHostView.swift | 9 ++- Calendarr iOS/Views/CalendarFilterSheet.swift | 2 +- Calendarr iOS/Views/GroupsView.swift | 75 +++++++++++++++---- 3 files changed, 67 insertions(+), 19 deletions(-) diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index 572ca7b..f392ea6 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -229,8 +229,8 @@ struct CalendarHostView: View { } ForEach(groups) { g in Button { switchGroup(g) } label: { - Label("\(g.icon ?? "👥") \(g.name)", - systemImage: store.activeGroup?.id == g.id ? "checkmark" : "person.2") + Label(g.name, + systemImage: store.activeGroup?.id == g.id ? "checkmark" : GroupIcons.symbol(g.icon)) } } } label: { @@ -244,8 +244,9 @@ struct CalendarHostView: View { @ViewBuilder private var groupBanner: some View { if let g = store.activeGroup { - HStack(spacing: 8) { - Text("\(L10n.t("groups.view_label", appLang)): \(g.icon ?? "👥") \(g.name)") + 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) } diff --git a/Calendarr iOS/Views/CalendarFilterSheet.swift b/Calendarr iOS/Views/CalendarFilterSheet.swift index 23d9ca6..9f00ed9 100644 --- a/Calendarr iOS/Views/CalendarFilterSheet.swift +++ b/Calendarr iOS/Views/CalendarFilterSheet.swift @@ -179,7 +179,7 @@ struct CalendarFilterSheet: View { private var groupFilterList: some View { if let g = groupDetail { List { - Section(header: Text("\(g.icon ?? "👥") \(g.name)")) { + Section(header: Label(g.name, systemImage: GroupIcons.symbol(g.icon))) { ForEach(g.members ?? []) { m in groupRow(name: m.displayName ?? "—", colorHex: m.color ?? "#4285f4", diff --git a/Calendarr iOS/Views/GroupsView.swift b/Calendarr iOS/Views/GroupsView.swift index 7fd018e..b2b1daa 100644 --- a/Calendarr iOS/Views/GroupsView.swift +++ b/Calendarr iOS/Views/GroupsView.swift @@ -1,5 +1,50 @@ 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 { @@ -23,7 +68,7 @@ struct GroupsView: View { GroupCombinedView(api: api, group: g) } label: { HStack { - Text(g.icon ?? "👥") + GroupIconView(icon: g.icon) Text(g.name) Spacer() if let n = g.memberCount { @@ -71,12 +116,12 @@ struct GroupEditSheet: View { @AppStorage("appLanguage") private var appLang = "system" @State private var name = "" - @State private var icon = "👥" + @State private var icon = "people" @State private var directory: [DirectoryUser] = [] @State private var selected: Set = [] @State private var error = "" - private let icons = ["👥", "👨‍👩‍👧", "🏠", "❤️", "🧑‍🤝‍🧑", "⚽", "🎓", "💼", "🎉", "🐶", "✈️", "🎵", "🍕", "📚", "🌳", "⭐"] + private let icons = GroupIcons.keys private let cols = [GridItem(.adaptive(minimum: 46))] var body: some View { @@ -88,9 +133,11 @@ struct GroupEditSheet: View { Section(L10n.t("group.icon", appLang)) { LazyVGrid(columns: cols, spacing: 8) { ForEach(icons, id: \.self) { ic in - Text(ic).font(.title2) + 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 } } @@ -144,13 +191,13 @@ struct GroupManageSheet: View { @State private var group: CalGroup? @State private var name = "" - @State private var icon = "👥" + @State private var icon = "people" @State private var directory: [DirectoryUser] = [] @State private var memberIds: Set = [] @State private var showDeleteConfirm = false @State private var error = "" - private let icons = ["👥", "👨‍👩‍👧", "🏠", "❤️", "🧑‍🤝‍🧑", "⚽", "🎓", "💼", "🎉", "🐶", "✈️", "🎵", "🍕", "📚", "🌳", "⭐"] + private let icons = GroupIcons.keys private let cols = [GridItem(.adaptive(minimum: 46))] var body: some View { @@ -162,9 +209,11 @@ struct GroupManageSheet: View { Section(L10n.t("group.icon", appLang)) { LazyVGrid(columns: cols, spacing: 8) { ForEach(icons, id: \.self) { ic in - Text(ic).font(.title2) + 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 } } @@ -218,7 +267,7 @@ struct GroupManageSheet: View { if let g = try? await api.getGroup(id: groupId) { group = g name = g.name - icon = g.icon ?? "👥" + 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 }) } @@ -324,7 +373,7 @@ struct GroupCombinedView: View { Text(L10n.t("groups.combined_empty", appLang)).foregroundStyle(.secondary) } } - .navigationTitle("\(group.icon ?? "👥") \(group.name)") + .navigationTitle(group.name) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarTrailing) { @@ -340,13 +389,11 @@ struct GroupCombinedView: View { .task(id: anchor) { await load() } } - // Prefix others' events with their first name; group events with 👥 + creator. + // 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 ev.isGroupEvent { - if let c = ev.creator, c.id != me { return "👥 \(firstName(c.displayName)): \(ev.title)" } - return "👥 \(ev.title)" - } + 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 } From e7d8effb470b1bd99e12a81a367f2e2479ab7f1e Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 1 Jun 2026 21:30:07 +0200 Subject: [PATCH 27/90] fix: Liquid Glass month title as inline content strip (not the system toolbar) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NavigationStack toolbar title never refreshes on month change on iOS 26 (4 approaches tried: principal Text, navigationTitle, @Observable store). The title now renders as a normal inline Text in a top safe-area inset just below the system glass toolbar — the same mechanism as the flat variant, which does update. The system toolbar keeps the buttons + the real Liquid Glass look. Co-Authored-By: Claude Opus 4.8 --- .../Views/Calendar/CalendarHostView.swift | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index f392ea6..61d5bee 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -101,9 +101,10 @@ struct CalendarHostView: View { private var glassVariant: some View { // Real iOS-26 Liquid Glass: the system NavigationStack toolbar renders the - // glass bar. The title is driven by `navTitle` (@State) — updated via - // onChange(of: titleString) and keyed with .id so the system bar reliably - // refreshes on month change (binding the computed title directly did not). + // 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) @@ -112,9 +113,6 @@ struct CalendarHostView: View { loadingIndicator.padding(.top, 12) } .animation(.easeInOut(duration: 0.2), value: store.isLoading || store.isCachingBackground) - .overlay(alignment: .top) { - if let err = store.lastError { errorBannerView(err).padding(.top, 8) } - } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { @@ -124,14 +122,6 @@ struct CalendarHostView: View { Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout) } } - ToolbarItem(placement: .principal) { - // Reads store.visibleMonth (@Observable) so the system - // toolbar refreshes on month change. - Text(titleString) - .font(.headline) - .lineLimit(1) - .minimumScaleFactor(0.7) - } ToolbarItem(placement: .navigationBarTrailing) { HStack(spacing: 8) { if !groups.isEmpty { groupMenu } @@ -141,7 +131,19 @@ struct CalendarHostView: View { } } } - .safeAreaInset(edge: .top) { groupBanner } + .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) From 587a0e65fa4382fbb085a33e926520edd1d5e9e6 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sat, 6 Jun 2026 16:21:08 +0200 Subject: [PATCH 28/90] feat: event reminders + default reminder setting + local notifications (iOS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-event reminders (multiple, local calendars only) in the editor, prefilled from a new "default reminder" setting that applies to all events otherwise. CalEvent gains `reminders`; AppSettings/SettingsSync sync default_reminder_minutes (always group). New NotificationScheduler requests permission and schedules the soonest ≤60 upcoming reminders via UNUserNotificationCenter, rescheduling on load/sync/edit and when the default changes (skipped in group overlay). Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/AppSettings.swift | 3 + Calendarr iOS/Models/CalEvent.swift | 5 +- Calendarr iOS/Models/CalendarStore.swift | 9 +++ Calendarr iOS/Models/ReminderOptions.swift | 37 +++++++++++ Calendarr iOS/Services/CalendarrAPI.swift | 6 +- .../Services/NotificationScheduler.swift | 65 +++++++++++++++++++ Calendarr iOS/Services/SettingsSync.swift | 5 ++ .../Views/Calendar/CalendarHostView.swift | 8 +++ .../Views/Calendar/EventEditorSheet.swift | 31 ++++++++- Calendarr iOS/Views/SettingsView.swift | 23 +++++++ 10 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 Calendarr iOS/Models/ReminderOptions.swift create mode 100644 Calendarr iOS/Services/NotificationScheduler.swift diff --git a/Calendarr iOS/Models/AppSettings.swift b/Calendarr iOS/Models/AppSettings.swift index bbf5248..8bf2642 100644 --- a/Calendarr iOS/Models/AppSettings.swift +++ b/Calendarr iOS/Models/AppSettings.swift @@ -18,6 +18,7 @@ struct AppSettings: Codable { var lineColor: String = "#3A3A3C" var privateEventVisibility: String = "busy" // 'hidden' | 'busy' var groupVisibleCalendarId: Int? = nil + var defaultReminderMinutes: Int? = nil // minutes before start; nil = off enum CodingKeys: String, CodingKey { case defaultView = "default_view" @@ -37,6 +38,7 @@ struct AppSettings: Codable { case lineColor = "line_color" case privateEventVisibility = "private_event_visibility" case groupVisibleCalendarId = "group_visible_calendar_id" + case defaultReminderMinutes = "default_reminder_minutes" } init() {} @@ -66,6 +68,7 @@ struct AppSettings: Codable { 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) } } diff --git a/Calendarr iOS/Models/CalEvent.swift b/Calendarr iOS/Models/CalEvent.swift index b1259d7..3120d11 100644 --- a/Calendarr iOS/Models/CalEvent.swift +++ b/Calendarr iOS/Models/CalEvent.swift @@ -41,6 +41,8 @@ struct CalEvent: Identifiable, Hashable { // 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] = [] // Group view supplies a server-resolved colour; otherwise per-event then calendar colour. var effectiveColor: String { displayColor ?? color ?? calendarColor } @@ -82,7 +84,8 @@ struct CalEvent: Identifiable, Hashable { 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 } + 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)") } ?? [] ) } } diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index 720d085..691a860 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -264,6 +264,15 @@ class CalendarStore { return !hiddenCalendarKeys.contains(key) && !banishedCalendarKeys.contains(key) } + // Personal events drive local reminder notifications. + NotificationScheduler.reschedule(events: allCachedEvents) + } + + /// Recompute scheduled reminder notifications from the personal cache + /// (skipped while a group overlay is active). + func rescheduleNotifications() { + guard activeGroup == nil else { return } + NotificationScheduler.reschedule(events: allCachedEvents) } /// Optimistically drop a just-deleted event from the cache so it disappears diff --git a/Calendarr iOS/Models/ReminderOptions.swift b/Calendarr iOS/Models/ReminderOptions.swift new file mode 100644 index 0000000..05e5a38 --- /dev/null +++ b/Calendarr iOS/Models/ReminderOptions.swift @@ -0,0 +1,37 @@ +import Foundation + +/// Reminder offset options (minutes before an event's start; 0 = at start) and +/// their localized labels. Shared by the event editor, the settings default +/// picker, and the notification scheduler so the choices stay consistent. +enum ReminderOptions { + /// Selectable offsets in minutes-before-start. + static let all: [Int] = [0, 5, 10, 15, 30, 60, 120, 1440, 2880] + + private static func isEnglish(_ appLang: String) -> Bool { + if appLang == "en" { return true } + if appLang == "de" { return false } + return (Locale.current.language.languageCode?.identifier ?? "de").hasPrefix("en") + } + + static func label(_ minutes: Int, _ appLang: String) -> String { + let en = isEnglish(appLang) + if minutes <= 0 { return en ? "At start time" : "Zur Startzeit" } + if minutes < 60 { return en ? "\(minutes) min before" : "\(minutes) Min. vorher" } + if minutes < 1440 { + let h = minutes / 60 + return en ? "\(h) h before" : "\(h) Std. vorher" + } + let d = minutes / 1440 + return en ? "\(d) day\(d == 1 ? "" : "s") before" : "\(d) Tag\(d == 1 ? "" : "e") vorher" + } + + static func sectionTitle(_ l: String) -> String { isEnglish(l) ? "Reminders" : "Benachrichtigungen" } + static func addLabel(_ l: String) -> String { isEnglish(l) ? "Add reminder" : "Benachrichtigung hinzufügen" } + static func off(_ l: String) -> String { isEnglish(l) ? "Off" : "Aus" } + static func defaultTitle(_ l: String) -> String { isEnglish(l) ? "Default reminder" : "Standardbenachrichtigung" } + static func defaultFooter(_ l: String) -> String { + isEnglish(l) + ? "Applies to all events unless an event has its own reminders." + : "Gilt für alle Termine, sofern ein Termin keine eigenen Benachrichtigungen hat." + } +} diff --git a/Calendarr iOS/Services/CalendarrAPI.swift b/Calendarr iOS/Services/CalendarrAPI.swift index f87631b..c245fe8 100644 --- a/Calendarr iOS/Services/CalendarrAPI.swift +++ b/Calendarr iOS/Services/CalendarrAPI.swift @@ -229,7 +229,7 @@ class CalendarrAPI { func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date, isAllDay: Bool, location: String, description: String, color: String?, - isPrivate: Bool = false) async throws -> CalEvent { + isPrivate: Bool = false, reminders: [Int]? = nil) async throws -> CalEvent { var body: [String: Any] = [ "calendar_id": calendarId, "title": title, @@ -241,6 +241,7 @@ class CalendarrAPI { "private": isPrivate ] if let c = color, !c.isEmpty { body["color"] = c } + if let reminders { body["reminders"] = reminders } let data = try await request("/api/local/events", method: "POST", body: body) guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let ev = CalEvent.from(json: json) else { throw APIError.decodingError } @@ -249,7 +250,7 @@ class CalendarrAPI { func updateLocalEvent(uid: String, title: String, start: Date, end: Date, isAllDay: Bool, location: String, description: String, color: String?, - isPrivate: Bool = false) async throws { + isPrivate: Bool = false, reminders: [Int]? = nil) async throws { var body: [String: Any] = [ "title": title, "start": formatISO(start, allDay: isAllDay), @@ -260,6 +261,7 @@ class CalendarrAPI { "private": isPrivate ] if let c = color { body["color"] = c } + if let reminders { body["reminders"] = reminders } _ = try await request("/api/local/events/\(uid)", method: "PUT", body: body) } diff --git a/Calendarr iOS/Services/NotificationScheduler.swift b/Calendarr iOS/Services/NotificationScheduler.swift new file mode 100644 index 0000000..e65f36b --- /dev/null +++ b/Calendarr iOS/Services/NotificationScheduler.swift @@ -0,0 +1,65 @@ +import Foundation +import UserNotifications + +extension Notification.Name { + /// Posted when the default-reminder setting changes, so the calendar host + /// can recompute the scheduled local notifications from its cached events. + static let rescheduleReminders = Notification.Name("rescheduleReminders") +} + +/// Schedules local OS notifications for upcoming events. Per-event reminders +/// (local events) take precedence; otherwise the user's default reminder applies +/// to every event (incl. external). Re-run whenever events or the default change. +enum NotificationScheduler { + + static func requestAuthorizationIfNeeded() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in } + } + + /// Recompute and (re)schedule notifications from the given events. The iOS + /// pending-notification cap is 64, so only the soonest are scheduled. + static func reschedule(events: [CalEvent]) { + let center = UNUserNotificationCenter.current() + center.getNotificationSettings { settings in + guard settings.authorizationStatus == .authorized + || settings.authorizationStatus == .provisional else { return } + + let defaultMin = (UserDefaults.standard.object(forKey: "defaultReminderMinutes") as? Int) ?? -1 + let now = Date() + var pending: [(fire: Date, event: CalEvent)] = [] + for ev in events { + let offsets = ev.reminders.isEmpty + ? (defaultMin >= 0 ? [defaultMin] : []) + : ev.reminders + for m in offsets { + let fire = ev.startDate.addingTimeInterval(-Double(m) * 60) + if fire > now { pending.append((fire, ev)) } + } + } + pending.sort { $0.fire < $1.fire } + let limited = pending.prefix(60) // stay safely under the 64 system cap + + center.removeAllPendingNotificationRequests() + for item in limited { + let content = UNMutableNotificationContent() + content.title = item.event.title + content.body = bodyText(item.event) + content.sound = .default + let comps = Calendar.current.dateComponents( + [.year, .month, .day, .hour, .minute, .second], from: item.fire) + let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: false) + center.add(UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger)) + } + } + } + + private static func bodyText(_ ev: CalEvent) -> String { + var parts: [String] = [] + if !ev.isAllDay { + let f = DateFormatter(); f.timeStyle = .short; f.dateStyle = .none + parts.append(f.string(from: ev.startDate)) + } + if !ev.location.isEmpty { parts.append(ev.location) } + return parts.joined(separator: " · ") + } +} diff --git a/Calendarr iOS/Services/SettingsSync.swift b/Calendarr iOS/Services/SettingsSync.swift index 6bc1101..223b119 100644 --- a/Calendarr iOS/Services/SettingsSync.swift +++ b/Calendarr iOS/Services/SettingsSync.swift @@ -37,6 +37,7 @@ enum SettingsSync { static let defaultView = "defaultView" static let weekStartDay = "weekStartDay" static let dimPastEvents = "dimPastEvents" + static let defaultReminder = "defaultReminderMinutes" // Int, -1 = off // master switch static let enabled = "settingsSync" } @@ -71,6 +72,8 @@ enum SettingsSync { s.defaultView = str(Key.defaultView, "month") s.weekStartDay = str(Key.weekStartDay, "monday") s.dimPastEvents = UserDefaults.standard.bool(forKey: Key.dimPastEvents) + let rem = int(Key.defaultReminder, -1) + s.defaultReminderMinutes = rem < 0 ? nil : rem return s } @@ -84,6 +87,7 @@ enum SettingsSync { d.set(s.defaultView, forKey: Key.defaultView) d.set(s.weekStartDay, forKey: Key.weekStartDay) d.set(s.dimPastEvents, forKey: Key.dimPastEvents) + d.set(s.defaultReminderMinutes ?? -1, forKey: Key.defaultReminder) guard includeOptional else { return } // NOTE: textColor / backgroundColor / lineColor are intentionally NOT // synced – the server has no columns for them (iOS-only). Writing the @@ -134,6 +138,7 @@ enum SettingsSync { merged.defaultView = local.defaultView merged.weekStartDay = local.weekStartDay merged.dimPastEvents = local.dimPastEvents + merged.defaultReminderMinutes = local.defaultReminderMinutes if isEnabled { merged.primaryColor = local.primaryColor merged.accentColor = local.accentColor diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index 61d5bee..d2eea84 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -95,6 +95,9 @@ struct CalendarHostView: View { .onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in Task { await forceReload() } } + .onReceive(NotificationCenter.default.publisher(for: .rescheduleReminders)) { _ in + store.rescheduleNotifications() + } } // MARK: – Liquid Glass variant @@ -162,6 +165,9 @@ struct CalendarHostView: View { .onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in Task { await forceReload() } } + .onReceive(NotificationCenter.default.publisher(for: .rescheduleReminders)) { _ in + store.rescheduleNotifications() + } } // MARK: – Top bar (flat mode) @@ -407,6 +413,8 @@ struct CalendarHostView: View { // 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) diff --git a/Calendarr iOS/Views/Calendar/EventEditorSheet.swift b/Calendarr iOS/Views/Calendar/EventEditorSheet.swift index 1f9b6c8..87d7f28 100644 --- a/Calendarr iOS/Views/Calendar/EventEditorSheet.swift +++ b/Calendarr iOS/Views/Calendar/EventEditorSheet.swift @@ -10,6 +10,7 @@ struct EventEditorSheet: View { @Environment(\.dismiss) var dismiss @AppStorage("appLanguage") private var appLang = "system" + @AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1 @State private var title = "" @State private var isAllDay = false @State private var startDate = Date() @@ -19,6 +20,7 @@ struct EventEditorSheet: View { @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 = "" @@ -81,6 +83,27 @@ struct EventEditorSheet: View { Toggle(L10n.t("event.private", appLang), isOn: $isPrivate) .tint(Color.accentColor) } + + Section(ReminderOptions.sectionTitle(appLang)) { + ForEach(Array(reminders.enumerated()), id: \.offset) { idx, _ in + Picker(ReminderOptions.sectionTitle(appLang), selection: Binding( + get: { reminders.indices.contains(idx) ? reminders[idx] : 0 }, + set: { if reminders.indices.contains(idx) { reminders[idx] = $0 } } + )) { + ForEach(ReminderOptions.all, id: \.self) { opt in + Text(ReminderOptions.label(opt, appLang)).tag(opt) + } + } + .labelsHidden() + } + .onDelete { reminders.remove(atOffsets: $0) } + Button { + let next = ReminderOptions.all.first { !reminders.contains($0) } ?? 15 + reminders.append(next) + } label: { + Label(ReminderOptions.addLabel(appLang), systemImage: "bell.badge.plus") + } + } } Section(L10n.t("event.color_section", appLang)) { @@ -149,6 +172,7 @@ struct EventEditorSheet: View { 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: "") @@ -167,6 +191,7 @@ struct EventEditorSheet: View { notes = ev.notes color = ev.color ?? "" isPrivate = ev.isPrivate + reminders = ev.reminders selectedCalendarId = store.writableCalendars.first?.id ?? "" } else { let cal = Calendar.current @@ -174,6 +199,8 @@ struct EventEditorSheet: View { minute: 0, second: 0, of: initialDate) ?? initialDate endDate = startDate.addingTimeInterval(3600) selectedCalendarId = store.writableCalendars.first?.id ?? "" + // New events inherit the user's default reminder (editable). + if defaultReminderMinutes >= 0 { reminders = [defaultReminderMinutes] } } } @@ -193,7 +220,7 @@ struct EventEditorSheet: View { 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) + 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: "") @@ -214,7 +241,7 @@ struct EventEditorSheet: View { _ = try await api.createLocalEvent(calendarId: cal.numericId, title: title, start: start, end: end, isAllDay: isAllDay, location: location, description: notes, color: colorVal, - isPrivate: isPrivate) + isPrivate: isPrivate, reminders: reminders) case "google": try await api.createGoogleEvent(calendarDbId: cal.numericId, title: title, start: start, end: end, isAllDay: isAllDay, diff --git a/Calendarr iOS/Views/SettingsView.swift b/Calendarr iOS/Views/SettingsView.swift index 0c9e6c6..9a8ed37 100644 --- a/Calendarr iOS/Views/SettingsView.swift +++ b/Calendarr iOS/Views/SettingsView.swift @@ -22,6 +22,7 @@ struct SettingsView: View { @AppStorage("defaultView") private var defaultView = "month" @AppStorage("weekStartDay") private var weekStartDay = "monday" @AppStorage("dimPastEvents") private var dimPastEvents = false + @AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1 // Profile chapter (server-backed; loaded on appear). @State private var displayName = "" @@ -37,6 +38,7 @@ struct SettingsView: View { Form { profilSection privatsphaereSection + benachrichtigungenSection geteilterKalenderSection liquidGlassSection cacheSection @@ -105,6 +107,27 @@ struct SettingsView: View { } } + // MARK: – Benachrichtigungen + + var benachrichtigungenSection: some View { + Section { + Picker(ReminderOptions.defaultTitle(appLang), selection: $defaultReminderMinutes) { + Text(ReminderOptions.off(appLang)).tag(-1) + ForEach(ReminderOptions.all, id: \.self) { m in + Text(ReminderOptions.label(m, appLang)).tag(m) + } + } + .onChange(of: defaultReminderMinutes) { _, _ in + SettingsSync.push(api: api) + NotificationCenter.default.post(name: .rescheduleReminders, object: nil) + } + } header: { + Text(ReminderOptions.sectionTitle(appLang)) + } footer: { + Text(ReminderOptions.defaultFooter(appLang)).font(.caption) + } + } + // MARK: – Privatsphäre var privatsphaereSection: some View { From f480b438cbfb4e7fb8a8fa44e423a3921e3a50bb Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sat, 6 Jun 2026 16:23:56 +0200 Subject: [PATCH 29/90] =?UTF-8?q?feat:=20top=20bar=20declutter=20=E2=80=94?= =?UTF-8?q?=20view/filter/groups/sync=20into=20one=20burger=20popup=20(iOS?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The top bar now shows only nav + title + a single burger. Tapping it opens a compact menu: View (with a fixed icon, no longer per-view), Filter, Groups (if any), Sync, and an "Einstellungen" entry that opens the existing full menu. Removed the separate group/view/filter icons from both the flat bar and the Liquid Glass toolbar. Co-Authored-By: Claude Opus 4.8 --- .../Views/Calendar/CalendarHostView.swift | 105 ++++++++---------- 1 file changed, 49 insertions(+), 56 deletions(-) diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index d2eea84..c2285aa 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -126,12 +126,7 @@ struct CalendarHostView: View { } } ToolbarItem(placement: .navigationBarTrailing) { - HStack(spacing: 8) { - if !groups.isEmpty { groupMenu } - viewPickerMenu - filterButton - Button { showMenu = true } label: { Image(systemName: "line.3.horizontal") } - } + menuButton } } .safeAreaInset(edge: .top, spacing: 0) { @@ -200,15 +195,8 @@ struct CalendarHostView: View { .minimumScaleFactor(0.7) .layoutPriority(1) Spacer(minLength: 6) - if !groups.isEmpty { groupMenu } - viewPickerMenu - filterButton - Button { showMenu = true } label: { - Image(systemName: "line.3.horizontal") - .font(.system(size: 18, weight: .medium)) - .frame(width: 36, height: 36) - } - .padding(.trailing, 2) + menuButton + .padding(.trailing, 2) } .frame(height: 48) } @@ -217,39 +205,6 @@ struct CalendarHostView: View { barContents.background(.bar) } - private var filterButton: some View { - Button { showFilter = true } label: { - Image(systemName: "line.3.horizontal.decrease.circle") - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(store.hiddenCalendarKeys.isEmpty ? .primary : Color.accentColor) - .frame(width: 36, height: 36) - } - .accessibilityLabel(L10n.t("filter.button", appLang)) - } - - // Group switcher: "Persönlich" + each group. Selecting a group flips the - // calendar into the combined overlay (like the web's group view). - private var groupMenu: some View { - Menu { - Button { switchGroup(nil) } label: { - Label(L10n.t("groups.personal", appLang), - systemImage: store.activeGroup == nil ? "checkmark" : "person") - } - ForEach(groups) { g in - Button { switchGroup(g) } label: { - Label(g.name, - systemImage: store.activeGroup?.id == g.id ? "checkmark" : GroupIcons.symbol(g.icon)) - } - } - } label: { - Image(systemName: store.activeGroup == nil ? "person.2" : "person.2.fill") - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(store.activeGroup == nil ? .primary : Color.accentColor) - .frame(width: 36, height: 36) - } - .accessibilityLabel(L10n.t("groups.title", appLang)) - } - @ViewBuilder private var groupBanner: some View { if let g = store.activeGroup { HStack(spacing: 6) { @@ -273,20 +228,58 @@ struct CalendarHostView: View { Task { await forceReload() } } - private var viewPickerMenu: some View { + /// The single top-bar action: a compact popup holding view / filter / + /// groups / sync, plus an "Einstellungen" entry that opens the full menu. + /// (Replaces the separate view / filter / group icons in the bar.) + private var menuButton: some View { Menu { - ForEach(CalViewType.allCases, id: \.self) { vt in - Button { store.viewType = vt } label: { - Label(vt.label(appLang), systemImage: vt.systemImage) + // View (fixed icon, not per-view) + Menu { + ForEach(CalViewType.allCases, id: \.self) { vt in + Button { store.viewType = vt } label: { + Label(vt.label(appLang), systemImage: store.viewType == vt ? "checkmark" : vt.systemImage) + } + } + } label: { + Label(L10n.t("view.change", appLang), systemImage: "rectangle.3.group") + } + // Filter + Button { showFilter = true } label: { + Label(L10n.t("filter.button", appLang), systemImage: "line.3.horizontal.decrease.circle") + } + // Groups + if !groups.isEmpty { + Menu { + Button { switchGroup(nil) } label: { + Label(L10n.t("groups.personal", appLang), + systemImage: store.activeGroup == nil ? "checkmark" : "person") + } + ForEach(groups) { g in + Button { switchGroup(g) } label: { + Label(g.name, + systemImage: store.activeGroup?.id == g.id ? "checkmark" : GroupIcons.symbol(g.icon)) + } + } + } label: { + Label(L10n.t("groups.title", appLang), systemImage: "person.2") } } + // Sync + Button { Task { await SettingsSync.pull(api: api); await forceReload() } } label: { + Label(L10n.t("menu.sync", appLang), systemImage: "arrow.triangle.2.circlepath") + } + Divider() + // Full settings menu + Button { showMenu = true } label: { + Label(L10n.t("menu.section.settings", appLang), systemImage: "gearshape") + } } label: { - Image(systemName: store.viewType.systemImage) - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(.primary) + 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("view.change", appLang)) + .accessibilityLabel(L10n.t("nav.menu", appLang)) } // MARK: – Error banner From 59a879ea2308d87a626053d2e614e23ca3dd0216 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 7 Jun 2026 10:14:08 +0200 Subject: [PATCH 30/90] fix: move Today button to the right so the month title sits centered (iOS) Left side now holds only the prev/next chevrons; "Heute" moved next to the burger menu on the right (both flat and Liquid Glass bars), so the month title is centered again instead of pushed right by a heavy left cluster. Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Views/Calendar/CalendarHostView.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index c2285aa..f6401e2 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -122,11 +122,13 @@ struct CalendarHostView: View { HStack(spacing: 2) { Button { store.navigatePrev() } label: { Image(systemName: "chevron.left") } Button { store.navigateNext() } label: { Image(systemName: "chevron.right") } - Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout) } } ToolbarItem(placement: .navigationBarTrailing) { - menuButton + HStack(spacing: 8) { + Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout) + menuButton + } } } .safeAreaInset(edge: .top, spacing: 0) { @@ -183,9 +185,6 @@ struct CalendarHostView: View { .font(.system(size: 17, weight: .medium)) .frame(width: 36, height: 36) } - Button(L10n.t("nav.today", appLang)) { store.moveToToday() } - .font(.callout).padding(.horizontal, 6) - .lineLimit(1).fixedSize() } .padding(.leading, 6) Spacer(minLength: 6) @@ -195,6 +194,9 @@ struct CalendarHostView: View { .minimumScaleFactor(0.7) .layoutPriority(1) Spacer(minLength: 6) + Button(L10n.t("nav.today", appLang)) { store.moveToToday() } + .font(.callout).padding(.horizontal, 6) + .lineLimit(1).fixedSize() menuButton .padding(.trailing, 2) } From 13d80981c65ce0307b7c971fda8e9e51e642184f Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 8 Jun 2026 19:20:10 +0200 Subject: [PATCH 31/90] Liquid Glass ICON --- Calendarr iOS.xcodeproj/project.pbxproj | 6 ++- .../AppIcon.icon/Assets/calendar 2.svg | 1 + Calendarr iOS/AppIcon.icon/icon.json | 46 +++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 Calendarr iOS/AppIcon.icon/Assets/calendar 2.svg create mode 100644 Calendarr iOS/AppIcon.icon/icon.json diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index f08e5b9..5ec0415 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -506,11 +506,12 @@ INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0; + MARKETING_VERSION = 2.5; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -549,11 +550,12 @@ INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0; + MARKETING_VERSION = 2.5; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/Calendarr iOS/AppIcon.icon/Assets/calendar 2.svg b/Calendarr iOS/AppIcon.icon/Assets/calendar 2.svg new file mode 100644 index 0000000..2a58c62 --- /dev/null +++ b/Calendarr iOS/AppIcon.icon/Assets/calendar 2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Calendarr iOS/AppIcon.icon/icon.json b/Calendarr iOS/AppIcon.icon/icon.json new file mode 100644 index 0000000..28a430d --- /dev/null +++ b/Calendarr iOS/AppIcon.icon/icon.json @@ -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" + } +} \ No newline at end of file From c0edca338eb41302cc0394f21ac8f13de8ed7e2a Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 9 Jun 2026 20:14:39 +0200 Subject: [PATCH 32/90] iOS: localization fixes, per-calendar reminders, widget polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1 — Localization: route the remaining hardcoded German strings through L10n (LoginView, ServerSetupView, SettingsView email, EventDetailSheet) so "System Default" + English device language shows fully English text. C2 — Per-calendar reminders: parse the new reminders_enabled flag on every calendar type; CalendarStore persists a reminderDisabledKeys set and passes it to NotificationScheduler, which skips events of muted calendars (default and per-event reminders). Filter sheet gains a per-calendar reminder toggle (leading swipe + bell.slash indicator), reconciled from the server and synced back via PUT. C3 — Widgets: - Shared WidgetTime.range helper; Today / Today & Tomorrow / Three Days / Up Next now show start–end instead of only the start time. - This Week: show up to 6 events per day (was 3) to use the height. - Two Weeks: mini event-title pills instead of bare dots. - Two Months: weeks expand to fill the column (no more empty lower third). - Day & Events: smaller header/strip/rows so content stops clipping. - Next 5 days → Next 7 days (range + labels), higher row cap. Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/AppSettings.swift | 12 +++++ Calendarr iOS/Models/CalendarStore.swift | 45 +++++++++++++++- Calendarr iOS/Models/Localization.swift | 52 +++++++++++++++++++ Calendarr iOS/Services/CalendarrAPI.swift | 15 ++++++ .../Services/NotificationScheduler.swift | 9 +++- .../Views/Calendar/EventDetailSheet.swift | 31 +++++------ Calendarr iOS/Views/CalendarFilterSheet.swift | 41 +++++++++++++++ Calendarr iOS/Views/LoginView.swift | 19 +++---- Calendarr iOS/Views/ServerSetupView.swift | 9 ++-- Calendarr iOS/Views/SettingsView.swift | 4 +- CalendarrWidgets/CalendarDayWidgetView.swift | 12 ++--- CalendarrWidgets/ThisWeekWidgetView.swift | 6 +-- CalendarrWidgets/ThreeDaysWidgetView.swift | 2 +- CalendarrWidgets/TodayWidgetView.swift | 2 +- CalendarrWidgets/TwoDaysWidgetView.swift | 2 +- CalendarrWidgets/TwoMonthWidgetView.swift | 4 +- CalendarrWidgets/TwoWeeksWidgetView.swift | 23 ++++---- CalendarrWidgets/UpNextWidgetView.swift | 2 +- CalendarrWidgets/UpcomingWidgetView.swift | 4 +- CalendarrWidgets/WidgetSupport.swift | 27 +++++++--- 20 files changed, 256 insertions(+), 65 deletions(-) diff --git a/Calendarr iOS/Models/AppSettings.swift b/Calendarr iOS/Models/AppSettings.swift index 8bf2642..d392685 100644 --- a/Calendarr iOS/Models/AppSettings.swift +++ b/Calendarr iOS/Models/AppSettings.swift @@ -92,10 +92,12 @@ struct CalDAVCalendar: Codable, Identifiable { 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" } } @@ -108,10 +110,12 @@ struct LocalCalendar: Codable, Identifiable { var sharedBy: String? = nil var permission: String? = nil var group: Bool = false + var remindersEnabled: Bool = true enum CodingKeys: String, CodingKey { case id, name, color, enabled, owned, permission, group case sharedBy = "shared_by" + case remindersEnabled = "reminders_enabled" } init(from decoder: Decoder) throws { @@ -124,6 +128,7 @@ struct LocalCalendar: Codable, Identifiable { 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 } } @@ -135,11 +140,13 @@ struct ICalSubscription: Codable, Identifiable { 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" } } @@ -155,10 +162,12 @@ struct GoogleCalendar: Codable, Identifiable { 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" } } @@ -182,11 +191,13 @@ struct HACalendar: Codable, Identifiable { 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 { @@ -197,6 +208,7 @@ struct HACalendar: Codable, Identifiable { 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 } } diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index 691a860..95c7ed2 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -74,6 +74,11 @@ class CalendarStore { /// show/hide list. Re-activation happens in AccountsView. var banishedCalendarKeys: Set = 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 = CalendarStore.loadReminderDisabledKeys() + /// Group-overlay visibility: which members' calendars (`gm:`) and the /// group calendar (`gc`) are hidden in the combined view — like hiding /// individual people in Outlook. In-memory; resets when leaving/switching a @@ -213,6 +218,42 @@ class CalendarStore { publishWidgetSnapshot() } + // MARK: – Reminder-disabled-calendar persistence + + private static let reminderDisabledKeysDefaultsKey = "reminderDisabledCalendarKeys" + + static func loadReminderDisabledKeys() -> Set { + 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) { + 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) { + 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 } @@ -265,14 +306,14 @@ class CalendarStore { && !banishedCalendarKeys.contains(key) } // Personal events drive local reminder notifications. - NotificationScheduler.reschedule(events: allCachedEvents) + 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) + NotificationScheduler.reschedule(events: allCachedEvents, disabledCalendarKeys: reminderDisabledKeys) } /// Optimistically drop a just-deleted event from the cache so it disappears diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index 67a2a2b..1f16d87 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -259,6 +259,30 @@ private let strings: [String: [String: String]] = [ "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", + "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…", @@ -292,6 +316,8 @@ private let strings: [String: [String: String]] = [ "filter.hide_all": "Alle ausblenden", "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", @@ -561,6 +587,30 @@ private let strings: [String: [String: String]] = [ "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", + "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…", @@ -594,6 +644,8 @@ private let strings: [String: [String: String]] = [ "filter.hide_all": "Hide all", "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", diff --git a/Calendarr iOS/Services/CalendarrAPI.swift b/Calendarr iOS/Services/CalendarrAPI.swift index c245fe8..8ffdc22 100644 --- a/Calendarr iOS/Services/CalendarrAPI.swift +++ b/Calendarr iOS/Services/CalendarrAPI.swift @@ -402,6 +402,21 @@ class CalendarrAPI { 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 func updateLocalCalendarColor(id: Int, color: String) async throws { diff --git a/Calendarr iOS/Services/NotificationScheduler.swift b/Calendarr iOS/Services/NotificationScheduler.swift index e65f36b..0a402b9 100644 --- a/Calendarr iOS/Services/NotificationScheduler.swift +++ b/Calendarr iOS/Services/NotificationScheduler.swift @@ -18,7 +18,9 @@ enum NotificationScheduler { /// Recompute and (re)schedule notifications from the given events. The iOS /// pending-notification cap is 64, so only the soonest are scheduled. - static func reschedule(events: [CalEvent]) { + /// `disabledCalendarKeys` ("source:id") are calendars with reminders turned + /// off — their events never generate notifications. + static func reschedule(events: [CalEvent], disabledCalendarKeys: Set = []) { let center = UNUserNotificationCenter.current() center.getNotificationSettings { settings in guard settings.authorizationStatus == .authorized @@ -28,6 +30,11 @@ enum NotificationScheduler { 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 diff --git a/Calendarr iOS/Views/Calendar/EventDetailSheet.swift b/Calendarr iOS/Views/Calendar/EventDetailSheet.swift index 2bf8d39..9816188 100644 --- a/Calendarr iOS/Views/Calendar/EventDetailSheet.swift +++ b/Calendarr iOS/Views/Calendar/EventDetailSheet.swift @@ -11,6 +11,7 @@ struct EventDetailSheet: View { 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 @@ -33,10 +34,10 @@ struct EventDetailSheet: View { if event.isAllDay { if Calendar.current.isDate(event.startDate, inSameDayAs: event.endDate) || event.endDate == event.startDate { - return "Ganztägig · \(dateFmt.string(from: 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 "Ganztägig · \(dateFmt.string(from: event.startDate)) – \(dateFmt.string(from: end))" + 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))" } @@ -85,27 +86,27 @@ struct EventDetailSheet: View { Section { HStack { - Label("Kalender", systemImage: "calendar") + Label(L10n.t("event.calendar_section", appLang), systemImage: "calendar") Spacer() Text(event.calendarName) .foregroundStyle(.secondary) } HStack { - Label("Quelle", systemImage: "server.rack") + 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("Erstellt von", systemImage: "person") + Label(L10n.t("detail.created_by", appLang), systemImage: "person") Spacer() Text(creator.displayName) .foregroundStyle(.secondary) } } if event.isPrivate { - Label("Privat", systemImage: "lock") + Label(L10n.t("event.private", appLang), systemImage: "lock") .foregroundStyle(.secondary) } } @@ -115,7 +116,7 @@ struct EventDetailSheet: View { Button { showCopySheet = true } label: { - Label("Termin kopieren", systemImage: "doc.on.doc") + Label(L10n.t("event.copy_title", appLang), systemImage: "doc.on.doc") } } } @@ -125,7 +126,7 @@ struct EventDetailSheet: View { Button(role: .destructive) { showDeleteConfirm = true } label: { - Label("Termin löschen", systemImage: "trash") + Label(L10n.t("detail.delete", appLang), systemImage: "trash") .foregroundStyle(.red) } .disabled(isDeleting) @@ -133,29 +134,29 @@ struct EventDetailSheet: View { } } .listStyle(.insetGrouped) - .navigationTitle("Termin") + .navigationTitle(L10n.t("detail.title", appLang)) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button("Schliessen") { + Button(L10n.t("common.close", appLang)) { Task { await onDone(nil, false) } } } if canEdit { ToolbarItem(placement: .primaryAction) { - Button("Bearbeiten") { + Button(L10n.t("detail.edit", appLang)) { Task { await onDone(event, false) } } } } } - .alert("Termin löschen?", isPresented: $showDeleteConfirm) { - Button("Löschen", role: .destructive) { + .alert(L10n.t("detail.delete_confirm_title", appLang), isPresented: $showDeleteConfirm) { + Button(L10n.t("common.delete", appLang), role: .destructive) { Task { await deleteEvent() } } - Button("Abbrechen", role: .cancel) {} + Button(L10n.t("common.cancel", appLang), role: .cancel) {} } message: { - Text("\"\(event.title)\" wird dauerhaft gelöscht.") + Text("\"\(event.title)\" \(L10n.t("detail.delete_msg_suffix", appLang))") } .sheet(isPresented: $showCopySheet) { EventEditorSheet( diff --git a/Calendarr iOS/Views/CalendarFilterSheet.swift b/Calendarr iOS/Views/CalendarFilterSheet.swift index 9f00ed9..ccd79dc 100644 --- a/Calendarr iOS/Views/CalendarFilterSheet.swift +++ b/Calendarr iOS/Views/CalendarFilterSheet.swift @@ -18,6 +18,8 @@ struct CalendarFilterSheet: View { @State private var isLoading = true @State private var hidden: Set = [] @State private var banished: Set = [] + /// Calendars whose events do not generate reminder notifications. + @State private var reminderDisabled: Set = [] /// All non-banished keys discovered during load — used by bulk show/hide. @State private var allKeys: Set = [] /// Group-mode: the active group's full detail (members + colours) and the @@ -155,12 +157,27 @@ struct CalendarFilterSheet: View { .foregroundStyle(isVisible ? .primary : .secondary) .strikethrough(!isVisible, color: .secondary) Spacer() + if reminderDisabled.contains(key) { + Image(systemName: "bell.slash") + .font(.caption) + .foregroundStyle(.secondary) + } Image(systemName: isVisible ? "eye" : "eye.slash") .foregroundStyle(isVisible ? Color.accentColor : .secondary) } .contentShape(Rectangle()) } .buttonStyle(.plain) + .swipeActions(edge: .leading, allowsFullSwipe: false) { + let disabled = reminderDisabled.contains(key) + Button { + toggleReminders(forKey: key) + } label: { + Label(L10n.t(disabled ? "filter.reminders_on" : "filter.reminders_off", appLang), + systemImage: disabled ? "bell" : "bell.slash") + } + .tint(.orange) + } .swipeActions(edge: .trailing, allowsFullSwipe: false) { Button(role: .destructive) { hidden.remove(key) @@ -173,6 +190,17 @@ struct CalendarFilterSheet: View { } } + /// Flip a calendar's reminder mute, persist locally + on the server, reschedule. + private func toggleReminders(forKey key: String) { + let nowDisabled = !reminderDisabled.contains(key) + if nowDisabled { reminderDisabled.insert(key) } else { reminderDisabled.remove(key) } + store.setReminderDisabled(key, disabled: nowDisabled) + if let parsed = CalendarStore.parseCalendarKey(key) { + Task { try? await api.setCalendarRemindersEnabled( + source: parsed.source, calendarId: parsed.id, enabled: !nowDisabled) } + } + } + // MARK: – Group overlay filter (hide individual members / the group calendar) @ViewBuilder @@ -250,6 +278,19 @@ struct CalendarFilterSheet: View { store.setBanishedCalendars(b) banished = b + // Reconcile reminder-muted state from the server's reminders_enabled flags. + var rd = Set() + func applyReminders(_ source: String, _ id: Int, _ enabled: Bool) { + if !enabled { rd.insert(CalendarStore.calendarKey(source: source, calendarId: "\(id)")) } + } + for cal in localCalendars { applyReminders("local", cal.id, cal.remindersEnabled) } + for acc in caldavAccounts { for cal in acc.calendars ?? [] { applyReminders("caldav", cal.id, cal.remindersEnabled ?? true) } } + for sub in icalSubs { applyReminders("ical", sub.id, sub.remindersEnabled ?? true) } + for acc in googleAccounts { for cal in acc.calendars ?? [] { applyReminders("google", cal.id, cal.remindersEnabled ?? true) } } + for acc in haAccounts { for cal in acc.calendars ?? [] { applyReminders("homeassistant", cal.id, cal.remindersEnabled) } } + store.setReminderDisabledKeys(rd) + reminderDisabled = rd + var keys = Set() for cal in localCalendars { keys.insert(CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)")) diff --git a/Calendarr iOS/Views/LoginView.swift b/Calendarr iOS/Views/LoginView.swift index f7f8509..a5b9ff8 100644 --- a/Calendarr iOS/Views/LoginView.swift +++ b/Calendarr iOS/Views/LoginView.swift @@ -2,6 +2,7 @@ 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 = "" @@ -32,10 +33,10 @@ struct LoginView: View { VStack(spacing: 16) { VStack(alignment: .leading, spacing: 6) { - Text("Benutzername") + Text(L10n.t("login.username", appLang)) .font(.footnote.weight(.medium)) .foregroundStyle(.secondary) - TextField("Benutzername", text: $username) + TextField(L10n.t("login.username", appLang), text: $username) .textInputAutocapitalization(.never) .autocorrectionDisabled() .padding(12) @@ -44,10 +45,10 @@ struct LoginView: View { } VStack(alignment: .leading, spacing: 6) { - Text("Passwort") + Text(L10n.t("login.password", appLang)) .font(.footnote.weight(.medium)) .foregroundStyle(.secondary) - SecureField("Passwort", text: $password) + SecureField(L10n.t("login.password", appLang), text: $password) .padding(12) .background(.quaternary) .clipShape(RoundedRectangle(cornerRadius: 10)) @@ -55,10 +56,10 @@ struct LoginView: View { if needsTOTP { VStack(alignment: .leading, spacing: 6) { - Text("2FA-Code") + Text(L10n.t("login.totp", appLang)) .font(.footnote.weight(.medium)) .foregroundStyle(.secondary) - TextField("6-stelliger Code", text: $totpCode) + TextField(L10n.t("login.totp_placeholder", appLang), text: $totpCode) .keyboardType(.numberPad) .padding(12) .background(.quaternary) @@ -67,7 +68,7 @@ struct LoginView: View { .transition(.move(edge: .top).combined(with: .opacity)) } - Toggle("Angemeldet bleiben", isOn: $rememberMe) + Toggle(L10n.t("login.remember", appLang), isOn: $rememberMe) .tint(Color.accentColor) if !error.isEmpty { @@ -84,7 +85,7 @@ struct LoginView: View { if isLoading { ProgressView().tint(.white) } else { - Text("Anmelden").fontWeight(.semibold) + Text(L10n.t("login.signin", appLang)).fontWeight(.semibold) } } .frame(maxWidth: .infinity) @@ -100,7 +101,7 @@ struct LoginView: View { Spacer().frame(height: 40) - Button("Anderen Server wählen") { + Button(L10n.t("login.choose_server", appLang)) { appState.resetServer() } .font(.footnote) diff --git a/Calendarr iOS/Views/ServerSetupView.swift b/Calendarr iOS/Views/ServerSetupView.swift index 9918266..e0da48e 100644 --- a/Calendarr iOS/Views/ServerSetupView.swift +++ b/Calendarr iOS/Views/ServerSetupView.swift @@ -2,6 +2,7 @@ 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 @@ -18,13 +19,13 @@ struct ServerSetupView: View { .foregroundStyle(Color.accentColor) Text("Calendarr") .font(.largeTitle.bold()) - Text("Server verbinden") + Text(L10n.t("server.connect_title", appLang)) .font(.subheadline) .foregroundStyle(.secondary) } VStack(alignment: .leading, spacing: 8) { - Text("Server-URL") + Text(L10n.t("server.url", appLang)) .font(.footnote.weight(.medium)) .foregroundStyle(.secondary) TextField("https://calendarr.example.com", text: $urlInput) @@ -50,7 +51,7 @@ struct ServerSetupView: View { ProgressView() .tint(.white) } else { - Text("Verbinden") + Text(L10n.t("server.connect", appLang)) .fontWeight(.semibold) } } @@ -88,7 +89,7 @@ struct ServerSetupView: View { _ = try await CalendarrAPI.checkSetupRequired(baseURL: url) appState.saveServer(url: url) } catch { - self.error = "Server nicht erreichbar. URL prüfen." + self.error = L10n.t("server.unreachable", appLang) } } } diff --git a/Calendarr iOS/Views/SettingsView.swift b/Calendarr iOS/Views/SettingsView.swift index 9a8ed37..f098bde 100644 --- a/Calendarr iOS/Views/SettingsView.swift +++ b/Calendarr iOS/Views/SettingsView.swift @@ -93,9 +93,9 @@ struct SettingsView: View { Text(loginName).foregroundStyle(.secondary) } HStack { - Text("E-Mail") + Text(L10n.t("settings.email", appLang)) Spacer() - TextField("E-Mail", text: $email) + TextField(L10n.t("settings.email", appLang), text: $email) .multilineTextAlignment(.trailing) .keyboardType(.emailAddress) .autocapitalization(.none) diff --git a/CalendarrWidgets/CalendarDayWidgetView.swift b/CalendarrWidgets/CalendarDayWidgetView.swift index 91521dc..974c60c 100644 --- a/CalendarrWidgets/CalendarDayWidgetView.swift +++ b/CalendarrWidgets/CalendarDayWidgetView.swift @@ -41,11 +41,11 @@ struct CalendarDayWidgetView: View { VStack(alignment: .leading, spacing: 0) { header(primary: primary) weekStrip(snapshot: s, primary: primary, accent: accent) - .padding(.vertical, 5) + .padding(.vertical, 3) Rectangle() .fill(Color(widgetHex: s.lineColorHex).opacity(0.4)) .frame(height: 0.5) - .padding(.bottom, 6) + .padding(.bottom, 4) eventList(accent: accent) } } else { @@ -61,9 +61,9 @@ struct CalendarDayWidgetView: View { private func header(primary: Color) -> some View { HStack(alignment: .top, spacing: 6) { Text("\(cal.component(.day, from: entry.date))") - .font(.system(size: 36, weight: .bold)) + .font(.system(size: 30, weight: .bold)) .foregroundStyle(primary) - .frame(width: 44, alignment: .leading) + .frame(width: 40, alignment: .leading) .minimumScaleFactor(0.7) VStack(alignment: .leading, spacing: 1) { Text(monthFmt.string(from: entry.date).uppercased()) @@ -125,12 +125,12 @@ struct CalendarDayWidgetView: View { .foregroundStyle(.secondary) Spacer(minLength: 0) } else { - VStack(alignment: .leading, spacing: 5) { + 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: 26) + .frame(width: 3, height: 22) VStack(alignment: .leading, spacing: 1) { Text(ev.title) .font(.system(size: 11, weight: .semibold)) diff --git a/CalendarrWidgets/ThisWeekWidgetView.swift b/CalendarrWidgets/ThisWeekWidgetView.swift index 0bdf1bd..dc8542b 100644 --- a/CalendarrWidgets/ThisWeekWidgetView.swift +++ b/CalendarrWidgets/ThisWeekWidgetView.swift @@ -89,11 +89,11 @@ struct ThisWeekWidgetView: View { .frame(width: 16, height: 16) .background(isToday ? primary : Color.clear) .clipShape(Circle()) - ForEach(evs.prefix(3)) { ev in + ForEach(evs.prefix(6)) { ev in eventPill(ev) } - if evs.count > 3 { - Text("+\(evs.count - 3)") + if evs.count > 6 { + Text("+\(evs.count - 6)") .font(.system(size: 6.5)) .foregroundStyle(accent) } diff --git a/CalendarrWidgets/ThreeDaysWidgetView.swift b/CalendarrWidgets/ThreeDaysWidgetView.swift index e303794..6c0bea9 100644 --- a/CalendarrWidgets/ThreeDaysWidgetView.swift +++ b/CalendarrWidgets/ThreeDaysWidgetView.swift @@ -121,7 +121,7 @@ struct ThreeDaysWidgetView: View { .lineLimit(1) Spacer(minLength: 0) } - Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start)) + Text(WidgetTime.range(ev, lang: lang)) .font(.system(size: 8)) .foregroundStyle(.secondary) .padding(.leading, 5) diff --git a/CalendarrWidgets/TodayWidgetView.swift b/CalendarrWidgets/TodayWidgetView.swift index 2caf11b..b513b56 100644 --- a/CalendarrWidgets/TodayWidgetView.swift +++ b/CalendarrWidgets/TodayWidgetView.swift @@ -74,7 +74,7 @@ struct TodayWidgetView: View { Text(ev.title) .font(.caption.weight(.medium)) .lineLimit(1) - Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start)) + Text(WidgetTime.range(ev, lang: lang)) .font(.caption2) .foregroundStyle(.secondary) } diff --git a/CalendarrWidgets/TwoDaysWidgetView.swift b/CalendarrWidgets/TwoDaysWidgetView.swift index 70a258a..c7fd7dd 100644 --- a/CalendarrWidgets/TwoDaysWidgetView.swift +++ b/CalendarrWidgets/TwoDaysWidgetView.swift @@ -92,7 +92,7 @@ struct TwoDaysWidgetView: View { Text(ev.title) .font(.system(size: 11, weight: .medium)) .lineLimit(1) - Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start)) + Text(WidgetTime.range(ev, lang: lang)) .font(.system(size: 9)) .foregroundStyle(.secondary) } diff --git a/CalendarrWidgets/TwoMonthWidgetView.swift b/CalendarrWidgets/TwoMonthWidgetView.swift index 2838ca3..1bc3568 100644 --- a/CalendarrWidgets/TwoMonthWidgetView.swift +++ b/CalendarrWidgets/TwoMonthWidgetView.swift @@ -110,9 +110,11 @@ struct TwoMonthWidgetView: View { 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) } } - Spacer(minLength: 0) } } diff --git a/CalendarrWidgets/TwoWeeksWidgetView.swift b/CalendarrWidgets/TwoWeeksWidgetView.swift index 6d18738..0138f9c 100644 --- a/CalendarrWidgets/TwoWeeksWidgetView.swift +++ b/CalendarrWidgets/TwoWeeksWidgetView.swift @@ -111,19 +111,22 @@ struct TwoWeeksWidgetView: View { .frame(width: 12, height: 12) .background(isToday ? primary : Color.clear) .clipShape(Circle()) - // Up to 3 colored dots - HStack(spacing: 1) { - ForEach(evs.prefix(3).indices, id: \.self) { i in - Circle() - .fill(Color(widgetHex: evs[i].colorHex)) - .frame(width: 3, height: 3) - } + // Up to 2 mini event-title pills (the cell has room for titles). + ForEach(evs.prefix(2)) { ev in + Text(ev.title) + .font(.system(size: 6, weight: .medium)) + .lineLimit(1) + .foregroundStyle(.white) + .padding(.horizontal, 1.5) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(widgetHex: ev.colorHex)) + .clipShape(RoundedRectangle(cornerRadius: 1.5)) } - .frame(height: 3) - if evs.count > 3 { - Text("+\(evs.count - 3)") + if evs.count > 2 { + Text("+\(evs.count - 2)") .font(.system(size: 6)) .foregroundStyle(accent) + .frame(maxWidth: .infinity, alignment: .leading) } Spacer(minLength: 0) } diff --git a/CalendarrWidgets/UpNextWidgetView.swift b/CalendarrWidgets/UpNextWidgetView.swift index a0bed0c..b4112d3 100644 --- a/CalendarrWidgets/UpNextWidgetView.swift +++ b/CalendarrWidgets/UpNextWidgetView.swift @@ -108,7 +108,7 @@ struct UpNextWidgetView: View { Text(ev.title) .font(.system(size: 10, weight: .semibold)) .lineLimit(1) - Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start)) + Text(WidgetTime.range(ev, lang: lang)) .font(.system(size: 9)) .foregroundStyle(.secondary) } diff --git a/CalendarrWidgets/UpcomingWidgetView.swift b/CalendarrWidgets/UpcomingWidgetView.swift index a48756c..7011699 100644 --- a/CalendarrWidgets/UpcomingWidgetView.swift +++ b/CalendarrWidgets/UpcomingWidgetView.swift @@ -4,7 +4,7 @@ import WidgetKit private let rowHeight: CGFloat = 16 private let dayHeaderHeight: CGFloat = 14 private let maxEventsPerDay: Int = 3 -private let maxTotalRows: Int = 15 +private let maxTotalRows: Int = 22 struct UpcomingWidgetView: View { let entry: CalendarrEntry @@ -16,7 +16,7 @@ struct UpcomingWidgetView: View { guard let s = snapshot else { return [] } let cal = Calendar.current let now = entry.date - let events = WidgetHelpers.upcoming(from: now, daysAhead: 5, in: s) + let events = WidgetHelpers.upcoming(from: now, daysAhead: 7, in: s) var buckets: [Date: [WidgetEvent]] = [:] for ev in events { let key = cal.startOfDay(for: ev.start) diff --git a/CalendarrWidgets/WidgetSupport.swift b/CalendarrWidgets/WidgetSupport.swift index 8df82c0..cc7968e 100644 --- a/CalendarrWidgets/WidgetSupport.swift +++ b/CalendarrWidgets/WidgetSupport.swift @@ -18,6 +18,21 @@ extension Color { } } +/// Shared event time formatting for all widgets: "start – end", or the +/// localized all-day label. Keeps every widget's event row consistent. +enum WidgetTime { + static func range(_ ev: WidgetEvent, lang: String) -> String { + if ev.isAllDay { return WidgetL10n.t("widget.allday", lang) } + let f = DateFormatter() + f.locale = WidgetL10n.locale(lang) + f.dateFormat = "HH:mm" + let start = f.string(from: ev.start) + // Hide a redundant identical end time (zero-length events). + if ev.end <= ev.start { return start } + return "\(start) – \(f.string(from: ev.end))" + } +} + enum WidgetL10n { static func t(_ key: String, _ stored: String) -> String { let lang: String @@ -46,14 +61,14 @@ enum WidgetL10n { "widget.no_events": "Keine Termine", "widget.allday": "Ganztägig", "widget.more": "+%d weitere", - "widget.upcoming": "Nächste 5 Tage", + "widget.upcoming": "Nächste 7 Tage", "widget.no_data": "Keine Daten – App einmal öffnen", "widget.display.today_title": "Heute", "widget.display.today_desc": "Heutige Termine auf einen Blick.", "widget.display.days_title": "Heute & Morgen", "widget.display.days_desc": "Termine der nächsten zwei Tage.", - "widget.display.upcoming_title": "Nächste 5 Tage", - "widget.display.upcoming_desc": "Termine der nächsten 5 Tage.", + "widget.display.upcoming_title": "Nächste 7 Tage", + "widget.display.upcoming_desc": "Termine der nächsten 7 Tage.", "widget.display.thisweek_title": "Diese Woche", "widget.display.thisweek_desc": "Wochenraster mit Terminen.", "widget.display.twoweeks_title": "Zwei Wochen", @@ -84,14 +99,14 @@ enum WidgetL10n { "widget.no_events": "No events", "widget.allday": "All-day", "widget.more": "+%d more", - "widget.upcoming": "Next 5 days", + "widget.upcoming": "Next 7 days", "widget.no_data": "No data – open the app once", "widget.display.today_title": "Today", "widget.display.today_desc": "Today's events at a glance.", "widget.display.days_title": "Today & tomorrow", "widget.display.days_desc": "Events for the next two days.", - "widget.display.upcoming_title": "Next 5 days", - "widget.display.upcoming_desc": "Events for the next 5 days.", + "widget.display.upcoming_title": "Next 7 days", + "widget.display.upcoming_desc": "Events for the next 7 days.", "widget.display.thisweek_title": "This Week", "widget.display.thisweek_desc": "Week grid with events.", "widget.display.twoweeks_title": "Two Weeks", From 864eb310720bfbabcc369d7492f8eb32f46b4d56 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 9 Jun 2026 20:32:34 +0200 Subject: [PATCH 33/90] iOS: align reminder options with web (5/15/30 min, 1 h, 1 day, 1 week) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop 10 min / 2 h / 2 days, add 1 week, and use full-word "… vorher" / "… before" labels so the choices match the web client. Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/ReminderOptions.swift | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/Calendarr iOS/Models/ReminderOptions.swift b/Calendarr iOS/Models/ReminderOptions.swift index 05e5a38..38cb32f 100644 --- a/Calendarr iOS/Models/ReminderOptions.swift +++ b/Calendarr iOS/Models/ReminderOptions.swift @@ -5,7 +5,7 @@ import Foundation /// picker, and the notification scheduler so the choices stay consistent. enum ReminderOptions { /// Selectable offsets in minutes-before-start. - static let all: [Int] = [0, 5, 10, 15, 30, 60, 120, 1440, 2880] + static let all: [Int] = [0, 5, 15, 30, 60, 1440, 10080] private static func isEnglish(_ appLang: String) -> Bool { if appLang == "en" { return true } @@ -16,13 +16,22 @@ enum ReminderOptions { static func label(_ minutes: Int, _ appLang: String) -> String { let en = isEnglish(appLang) if minutes <= 0 { return en ? "At start time" : "Zur Startzeit" } - if minutes < 60 { return en ? "\(minutes) min before" : "\(minutes) Min. vorher" } + if minutes < 60 { + return en ? "\(minutes) minutes before" : "\(minutes) Minuten vorher" + } if minutes < 1440 { let h = minutes / 60 - return en ? "\(h) h before" : "\(h) Std. vorher" + if en { return h == 1 ? "1 hour before" : "\(h) hours before" } + return h == 1 ? "1 Stunde vorher" : "\(h) Stunden vorher" } - let d = minutes / 1440 - return en ? "\(d) day\(d == 1 ? "" : "s") before" : "\(d) Tag\(d == 1 ? "" : "e") 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" } From 69a32121dfac8f8b391d624301eba9f96db9aaeb Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 9 Jun 2026 21:32:59 +0200 Subject: [PATCH 34/90] iOS: show more events in the "Next 7 days" widget The per-day cap of 3 made busy days collapse to "+N" so only ~6-7 events showed total. Raise it (25) and let the total-row cap govern; extraLarge fills up to 40 rows. Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS.xcodeproj/project.pbxproj | 4 ++-- CalendarrWidgets/UpcomingWidgetView.swift | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index 5ec0415..ac395ba 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -511,7 +511,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.5; + MARKETING_VERSION = 2.6; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -555,7 +555,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.5; + MARKETING_VERSION = 2.6; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/CalendarrWidgets/UpcomingWidgetView.swift b/CalendarrWidgets/UpcomingWidgetView.swift index 7011699..c8e5416 100644 --- a/CalendarrWidgets/UpcomingWidgetView.swift +++ b/CalendarrWidgets/UpcomingWidgetView.swift @@ -3,15 +3,20 @@ import WidgetKit private let rowHeight: CGFloat = 16 private let dayHeaderHeight: CGFloat = 14 -private let maxEventsPerDay: Int = 3 -private let maxTotalRows: Int = 22 +// Show all events of a day (the total-row cap below governs how much fits); +// a low per-day cap previously made busy days collapse to "+N" far too early. +private let maxEventsPerDay: Int = 25 struct UpcomingWidgetView: View { let entry: CalendarrEntry + @Environment(\.widgetFamily) private var family private var snapshot: WidgetSnapshot? { entry.snapshot } private var lang: String { snapshot?.language ?? "system" } + // Fill the available height: extraLarge (iPad) fits far more rows. + private var maxTotalRows: Int { family == .systemExtraLarge ? 40 : 22 } + private var groupedWithLimits: [(Date, [WidgetEvent], Int)] { guard let s = snapshot else { return [] } let cal = Calendar.current From 17ebf788ce28947f8579b66a3be2ecc2dfbbcaf2 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 11 Jun 2026 20:15:53 +0200 Subject: [PATCH 35/90] iOS: widget padding, week event limit, long-press day preview - CalendarDayWidget: add 6pt top padding to header so content isn't flush against the widget edge - ThisWeekWidget: increase per-day event cap from 6 to 8 to prevent "+1" overflow when there is vertical space available - MonthView: add DayContextPreviewView to the long-press context menu using .contextMenu(menuItems:preview:); shows all-day events as colored bars with chevron continuation arrows, timed events as dot+time+title rows Co-Authored-By: Claude Sonnet 4.6 --- Calendarr iOS/Views/Calendar/MonthView.swift | 139 +++++++++++++++++++ CalendarrWidgets/CalendarDayWidgetView.swift | 1 + CalendarrWidgets/ThisWeekWidgetView.swift | 6 +- 3 files changed, 143 insertions(+), 3 deletions(-) diff --git a/Calendarr iOS/Views/Calendar/MonthView.swift b/Calendarr iOS/Views/Calendar/MonthView.swift index d06813e..77dab66 100644 --- a/Calendarr iOS/Views/Calendar/MonthView.swift +++ b/Calendarr iOS/Views/Calendar/MonthView.swift @@ -206,7 +206,9 @@ private struct WeekRow: View { } 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) + 4 let mondayIdx = days.firstIndex(where: { cal.component(.weekday, from: $0) == 2 }) ?? 0 @@ -229,6 +231,11 @@ private struct WeekRow: View { } 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, @@ -240,6 +247,7 @@ private struct WeekRow: View { weekNumber: idx == mondayIdx ? weekNumber : nil, cwLabel: L10n.t("cal.cw", language), edge: edge, + dayEvents: eventsForDay, onTap: { onDayTap(day) }, onCreateEvent: { onCreateEvent(day) }, onShowWeek: { onShowWeek(day) }, @@ -286,6 +294,7 @@ private struct DayCell: View { let weekNumber: Int? let cwLabel: String let edge: DividerEdge + let dayEvents: [CalEvent] let onTap: () -> Void let onCreateEvent: () -> Void let onShowWeek: () -> Void @@ -372,10 +381,140 @@ private struct DayCell: View { 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)! + // isAllDay endDate is exclusive + let cLeft = event.startDate < dayStart + let cRight = event.endDate > dayEnd + + HStack(spacing: 0) { + if cLeft { + Image(systemName: "chevron.left") + .font(.system(size: 8, weight: .semibold)) + .foregroundStyle(.white) + .padding(.leading, 3) + } + Text(event.title) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.white) + .lineLimit(1) + .padding(.horizontal, 6) + Spacer(minLength: 0) + if cRight { + Image(systemName: "chevron.right") + .font(.system(size: 8, weight: .semibold)) + .foregroundStyle(.white) + .padding(.trailing, 3) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 3) + .background(color) + .clipShape( + UnevenRoundedRectangle( + topLeadingRadius: cLeft ? 0 : 4, + bottomLeadingRadius: cLeft ? 0 : 4, + bottomTrailingRadius: cRight ? 0 : 4, + topTrailingRadius: cRight ? 0 : 4 + ) + ) + .padding(.leading, cLeft ? 0 : 0) + .padding(.trailing, cRight ? 0 : 0) + } +} + +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) + Text(event.title) + .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 { diff --git a/CalendarrWidgets/CalendarDayWidgetView.swift b/CalendarrWidgets/CalendarDayWidgetView.swift index 974c60c..3a96b2c 100644 --- a/CalendarrWidgets/CalendarDayWidgetView.swift +++ b/CalendarrWidgets/CalendarDayWidgetView.swift @@ -40,6 +40,7 @@ struct CalendarDayWidgetView: View { 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() diff --git a/CalendarrWidgets/ThisWeekWidgetView.swift b/CalendarrWidgets/ThisWeekWidgetView.swift index dc8542b..aa9b7fd 100644 --- a/CalendarrWidgets/ThisWeekWidgetView.swift +++ b/CalendarrWidgets/ThisWeekWidgetView.swift @@ -89,11 +89,11 @@ struct ThisWeekWidgetView: View { .frame(width: 16, height: 16) .background(isToday ? primary : Color.clear) .clipShape(Circle()) - ForEach(evs.prefix(6)) { ev in + ForEach(evs.prefix(8)) { ev in eventPill(ev) } - if evs.count > 6 { - Text("+\(evs.count - 6)") + if evs.count > 8 { + Text("+\(evs.count - 8)") .font(.system(size: 6.5)) .foregroundStyle(accent) } From 1dbe4056898de3a393b17ffc98dcd5f80711423f Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 11 Jun 2026 20:34:58 +0200 Subject: [PATCH 36/90] iOS: replace chevron icons with clip-path shape, fix +N overlap in month view - DayPreviewAllDayBar: use custom ChevronBarShape (polygon clip) instead of chevron SF Symbols; bars have tapered pointed ends for continuation - MonthView rowHeight: increase bottom padding from +4 to +16 so the overflow "+N" count no longer overlaps the last event bar lane Co-Authored-By: Claude Sonnet 4.6 --- Calendarr iOS/Views/Calendar/MonthView.swift | 85 +++++++++++--------- 1 file changed, 48 insertions(+), 37 deletions(-) diff --git a/Calendarr iOS/Views/Calendar/MonthView.swift b/Calendarr iOS/Views/Calendar/MonthView.swift index 77dab66..24ba3b7 100644 --- a/Calendarr iOS/Views/Calendar/MonthView.swift +++ b/Calendarr iOS/Views/Calendar/MonthView.swift @@ -209,7 +209,7 @@ private struct WeekRow: 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) + 4 + 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) @@ -446,46 +446,57 @@ private struct DayPreviewAllDayBar: View { private var cal: Calendar { .current } var body: some View { - let color = Color(hex: event.effectiveColor) + let color = Color(hex: event.effectiveColor) let dayStart = cal.startOfDay(for: date) let dayEnd = cal.date(byAdding: .day, value: 1, to: dayStart)! - // isAllDay endDate is exclusive - let cLeft = event.startDate < dayStart - let cRight = event.endDate > dayEnd + let cLeft = event.startDate < dayStart + let cRight = event.endDate > dayEnd // endDate is exclusive for allDay + Text(event.title) + .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)) + } +} - HStack(spacing: 0) { - if cLeft { - Image(systemName: "chevron.left") - .font(.system(size: 8, weight: .semibold)) - .foregroundStyle(.white) - .padding(.leading, 3) - } - Text(event.title) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(.white) - .lineLimit(1) - .padding(.horizontal, 6) - Spacer(minLength: 0) - if cRight { - Image(systemName: "chevron.right") - .font(.system(size: 8, weight: .semibold)) - .foregroundStyle(.white) - .padding(.trailing, 3) - } +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) } - .frame(maxWidth: .infinity) - .padding(.vertical, 3) - .background(color) - .clipShape( - UnevenRoundedRectangle( - topLeadingRadius: cLeft ? 0 : 4, - bottomLeadingRadius: cLeft ? 0 : 4, - bottomTrailingRadius: cRight ? 0 : 4, - topTrailingRadius: cRight ? 0 : 4 - ) - ) - .padding(.leading, cLeft ? 0 : 0) - .padding(.trailing, cRight ? 0 : 0) + 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 } } From 544e0d92656981de2dc3ed2fd8b0312e4c4acd06 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 11 Jun 2026 20:52:04 +0200 Subject: [PATCH 37/90] iOS: bump marketing version to 2.7 Co-Authored-By: Claude Sonnet 4.6 --- Calendarr iOS.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index ac395ba..d208594 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -511,7 +511,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.6; + MARKETING_VERSION = 2.7; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -555,7 +555,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.6; + MARKETING_VERSION = 2.7; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; From cc3d16ddce5cafaad9f3ec0d8291a31f6c4cacdf Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 15 Jun 2026 10:03:24 +0200 Subject: [PATCH 38/90] feat: custom reminder picker, muted-calendar hint, synced default duration - Reminder editor: presets + custom number+unit (minutes/hours/days/weeks) - Grey out + footer hint when the selected calendar's reminders are muted; reminders are kept, scheduler already skips them - New synced setting defaultEventDurationMinutes for new events Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/AppSettings.swift | 3 + Calendarr iOS/Models/Localization.swift | 2 + Calendarr iOS/Models/ReminderOptions.swift | 57 ++++++++++++ Calendarr iOS/Services/SettingsSync.swift | 4 + .../Views/Calendar/EventEditorSheet.swift | 88 ++++++++++++++++--- Calendarr iOS/Views/SettingsView.swift | 7 ++ 6 files changed, 148 insertions(+), 13 deletions(-) diff --git a/Calendarr iOS/Models/AppSettings.swift b/Calendarr iOS/Models/AppSettings.swift index d392685..a90560f 100644 --- a/Calendarr iOS/Models/AppSettings.swift +++ b/Calendarr iOS/Models/AppSettings.swift @@ -19,6 +19,7 @@ struct AppSettings: Codable { 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 enum CodingKeys: String, CodingKey { case defaultView = "default_view" @@ -39,6 +40,7 @@ struct AppSettings: Codable { case privateEventVisibility = "private_event_visibility" case groupVisibleCalendarId = "group_visible_calendar_id" case defaultReminderMinutes = "default_reminder_minutes" + case defaultEventDurationMinutes = "default_event_duration_minutes" } init() {} @@ -69,6 +71,7 @@ struct AppSettings: Codable { 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 } } diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index 1f16d87..d488375 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -127,6 +127,7 @@ private let strings: [String: [String: String]] = [ "settings.monday": "Montag", "settings.sunday": "Sonntag", "settings.dimpast": "Vergangene Termine ausgrauen", + "settings.default_duration": "Standard-Termindauer", "settings.nav.profile": "Profil", "settings.privacy": "Privatsphäre", "settings.private_visibility": "Private Termine für Gruppen", @@ -455,6 +456,7 @@ private let strings: [String: [String: String]] = [ "settings.monday": "Monday", "settings.sunday": "Sunday", "settings.dimpast": "Dim past events", + "settings.default_duration": "Default event duration", "settings.nav.profile": "Profile", "settings.privacy": "Privacy", "settings.private_visibility": "Private events for groups", diff --git a/Calendarr iOS/Models/ReminderOptions.swift b/Calendarr iOS/Models/ReminderOptions.swift index 38cb32f..fed8370 100644 --- a/Calendarr iOS/Models/ReminderOptions.swift +++ b/Calendarr iOS/Models/ReminderOptions.swift @@ -7,6 +7,63 @@ 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 } diff --git a/Calendarr iOS/Services/SettingsSync.swift b/Calendarr iOS/Services/SettingsSync.swift index 223b119..e324a6b 100644 --- a/Calendarr iOS/Services/SettingsSync.swift +++ b/Calendarr iOS/Services/SettingsSync.swift @@ -38,6 +38,7 @@ enum SettingsSync { static let weekStartDay = "weekStartDay" static let dimPastEvents = "dimPastEvents" static let defaultReminder = "defaultReminderMinutes" // Int, -1 = off + static let defaultEventDuration = "defaultEventDurationMinutes" // Int minutes // master switch static let enabled = "settingsSync" } @@ -74,6 +75,7 @@ enum SettingsSync { s.dimPastEvents = UserDefaults.standard.bool(forKey: Key.dimPastEvents) let rem = int(Key.defaultReminder, -1) s.defaultReminderMinutes = rem < 0 ? nil : rem + s.defaultEventDurationMinutes = int(Key.defaultEventDuration, 60) return s } @@ -88,6 +90,7 @@ enum SettingsSync { d.set(s.weekStartDay, forKey: Key.weekStartDay) d.set(s.dimPastEvents, forKey: Key.dimPastEvents) d.set(s.defaultReminderMinutes ?? -1, forKey: Key.defaultReminder) + d.set(s.defaultEventDurationMinutes, forKey: Key.defaultEventDuration) guard includeOptional else { return } // NOTE: textColor / backgroundColor / lineColor are intentionally NOT // synced – the server has no columns for them (iOS-only). Writing the @@ -139,6 +142,7 @@ enum SettingsSync { merged.weekStartDay = local.weekStartDay merged.dimPastEvents = local.dimPastEvents merged.defaultReminderMinutes = local.defaultReminderMinutes + merged.defaultEventDurationMinutes = local.defaultEventDurationMinutes if isEnabled { merged.primaryColor = local.primaryColor merged.accentColor = local.accentColor diff --git a/Calendarr iOS/Views/Calendar/EventEditorSheet.swift b/Calendarr iOS/Views/Calendar/EventEditorSheet.swift index 87d7f28..e067a83 100644 --- a/Calendarr iOS/Views/Calendar/EventEditorSheet.swift +++ b/Calendarr iOS/Views/Calendar/EventEditorSheet.swift @@ -11,6 +11,7 @@ struct EventEditorSheet: View { @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() @@ -31,6 +32,15 @@ struct EventEditorSheet: View { 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 { @@ -84,26 +94,26 @@ struct EventEditorSheet: View { .tint(Color.accentColor) } - Section(ReminderOptions.sectionTitle(appLang)) { - ForEach(Array(reminders.enumerated()), id: \.offset) { idx, _ in - Picker(ReminderOptions.sectionTitle(appLang), selection: Binding( - get: { reminders.indices.contains(idx) ? reminders[idx] : 0 }, - set: { if reminders.indices.contains(idx) { reminders[idx] = $0 } } - )) { - ForEach(ReminderOptions.all, id: \.self) { opt in - Text(ReminderOptions.label(opt, appLang)).tag(opt) - } - } - .labelsHidden() + Section { + ForEach(reminders.indices, id: \.self) { idx in + ReminderEditRow(minutes: $reminders[idx], appLang: appLang) } .onDelete { reminders.remove(atOffsets: $0) } Button { - let next = ReminderOptions.all.first { !reminders.contains($0) } ?? 15 + 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)) { @@ -197,7 +207,8 @@ struct EventEditorSheet: View { let cal = Calendar.current startDate = cal.date(bySettingHour: cal.component(.hour, from: initialDate), minute: 0, second: 0, of: initialDate) ?? initialDate - endDate = startDate.addingTimeInterval(3600) + let durMin = defaultEventDurationMinutes > 0 ? defaultEventDurationMinutes : 60 + endDate = startDate.addingTimeInterval(Double(durMin) * 60) selectedCalendarId = store.writableCalendars.first?.id ?? "" // New events inherit the user's default reminder (editable). if defaultReminderMinutes >= 0 { reminders = [defaultReminderMinutes] } @@ -263,3 +274,54 @@ struct EventEditorSheet: View { } } } + +/// One reminder row: a preset picker plus, when "Custom" is chosen, a number +/// stepper and a unit picker. The value is always stored as minutes-before-start. +private struct ReminderEditRow: View { + @Binding var minutes: Int + let appLang: String + private let customTag = -1 + + private var isPreset: Bool { ReminderOptions.presets.contains(minutes) } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Picker("", selection: Binding( + get: { isPreset ? minutes : customTag }, + set: { newVal in + if newVal == customTag { + if ReminderOptions.presets.contains(minutes) { minutes = ReminderOptions.customDefault } + } else { + minutes = newVal + } + } + )) { + ForEach(ReminderOptions.presets, id: \.self) { Text(ReminderOptions.label($0, appLang)).tag($0) } + Text(ReminderOptions.customLabel(appLang)).tag(customTag) + } + .labelsHidden() + + if !isPreset { + HStack { + Stepper(value: Binding( + get: { ReminderOptions.split(minutes).value }, + set: { minutes = max(1, $0) * ReminderOptions.split(minutes).unit.mult } + ), in: 1...999) { + Text("\(ReminderOptions.split(minutes).value)") + .monospacedDigit() + } + Picker("", selection: Binding( + get: { ReminderOptions.split(minutes).unit }, + set: { newUnit in minutes = ReminderOptions.split(minutes).value * newUnit.mult } + )) { + ForEach(ReminderOptions.Unit.allCases) { u in + Text(ReminderOptions.unitLabel(u, appLang)).tag(u) + } + } + .labelsHidden() + Text(ReminderOptions.beforeLabel(appLang)).foregroundStyle(.secondary) + } + } + } + } +} diff --git a/Calendarr iOS/Views/SettingsView.swift b/Calendarr iOS/Views/SettingsView.swift index f098bde..c6c1605 100644 --- a/Calendarr iOS/Views/SettingsView.swift +++ b/Calendarr iOS/Views/SettingsView.swift @@ -23,6 +23,7 @@ struct SettingsView: View { @AppStorage("weekStartDay") private var weekStartDay = "monday" @AppStorage("dimPastEvents") private var dimPastEvents = false @AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1 + @AppStorage("defaultEventDurationMinutes") private var defaultEventDurationMinutes = 60 // Profile chapter (server-backed; loaded on appear). @State private var displayName = "" @@ -360,6 +361,12 @@ struct SettingsView: View { } Toggle(L10n.t("settings.dimpast", appLang), isOn: $dimPastEvents) .tint(Color.accentColor) + Picker(L10n.t("settings.default_duration", appLang), selection: $defaultEventDurationMinutes) { + ForEach([15, 30, 45, 60, 90, 120, 240], id: \.self) { m in + Text(ReminderOptions.durationLabel(m, appLang)).tag(m) + } + } + .onChange(of: defaultEventDurationMinutes) { _, _ in SettingsSync.push(api: api) } } } From 52040fff5327d51d5001ccfa6c0b3be3591041d7 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 15 Jun 2026 19:38:20 +0200 Subject: [PATCH 39/90] feat: show relative time in notifications + move default duration to profile settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notifications now show "in 30 Min. · 10:00" instead of just the time, giving users immediate context about how soon the event is. Default event duration picker moved from the View section to right below the privacy setting, grouped under the calendar/profile settings area. Co-Authored-By: Claude Sonnet 4.6 --- .../Services/NotificationScheduler.swift | 13 ++++++++++- Calendarr iOS/Views/SettingsView.swift | 22 ++++++++++++++----- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/Calendarr iOS/Services/NotificationScheduler.swift b/Calendarr iOS/Services/NotificationScheduler.swift index 0a402b9..ab4810a 100644 --- a/Calendarr iOS/Services/NotificationScheduler.swift +++ b/Calendarr iOS/Services/NotificationScheduler.swift @@ -50,7 +50,10 @@ enum NotificationScheduler { for item in limited { let content = UNMutableNotificationContent() content.title = item.event.title - content.body = bodyText(item.event) + let minutes = Int(item.event.startDate.timeIntervalSince(item.fire) / 60) + let rel = relativeText(minutes) + let detail = bodyText(item.event) + content.body = detail.isEmpty ? rel : "\(rel) · \(detail)" content.sound = .default let comps = Calendar.current.dateComponents( [.year, .month, .day, .hour, .minute, .second], from: item.fire) @@ -60,6 +63,14 @@ enum NotificationScheduler { } } + private static func relativeText(_ minutes: Int) -> String { + if minutes < 60 { return "in \(minutes) Min." } + if minutes == 60 { return "in 1 Std." } + if minutes < 1440 { return "in \(minutes / 60) Std." } + if minutes == 1440 { return "morgen" } + return "in \(minutes / 1440) Tagen" + } + private static func bodyText(_ ev: CalEvent) -> String { var parts: [String] = [] if !ev.isAllDay { diff --git a/Calendarr iOS/Views/SettingsView.swift b/Calendarr iOS/Views/SettingsView.swift index c6c1605..97caa2b 100644 --- a/Calendarr iOS/Views/SettingsView.swift +++ b/Calendarr iOS/Views/SettingsView.swift @@ -39,6 +39,7 @@ struct SettingsView: View { Form { profilSection privatsphaereSection + termindauerSection benachrichtigungenSection geteilterKalenderSection liquidGlassSection @@ -147,6 +148,21 @@ struct SettingsView: View { } } + // MARK: – Standardtermindauer + + var termindauerSection: some View { + Section { + Picker(L10n.t("settings.default_duration", appLang), selection: $defaultEventDurationMinutes) { + ForEach([15, 30, 45, 60, 90, 120, 240], id: \.self) { m in + Text(ReminderOptions.durationLabel(m, appLang)).tag(m) + } + } + .onChange(of: defaultEventDurationMinutes) { _, _ in SettingsSync.push(api: api) } + } header: { + Text(L10n.t("settings.calview", appLang)) + } + } + // MARK: – Geteilter Kalender var geteilterKalenderSection: some View { @@ -361,12 +377,6 @@ struct SettingsView: View { } Toggle(L10n.t("settings.dimpast", appLang), isOn: $dimPastEvents) .tint(Color.accentColor) - Picker(L10n.t("settings.default_duration", appLang), selection: $defaultEventDurationMinutes) { - ForEach([15, 30, 45, 60, 90, 120, 240], id: \.self) { m in - Text(ReminderOptions.durationLabel(m, appLang)).tag(m) - } - } - .onChange(of: defaultEventDurationMinutes) { _, _ in SettingsSync.push(api: api) } } } From 4e9ae832993d9212347402a2a4e31bf5fdcf30f7 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 17 Jun 2026 18:41:43 +0200 Subject: [PATCH 40/90] feat(widget): calendar filter config + fix group-view data leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fix: publishWidgetSnapshot() now guards against activeGroup != nil, so group view events/colors never contaminate the widget cache. Feature: widgets can now be configured via long-press → Edit Widget. - WidgetCalendar struct + writeCalendars/readCalendars in WidgetData.swift - calendarKey added to WidgetEvent (backward-compatible decoder) - CalendarIntent.swift: CalendarAppEntity + CalendarEntityQuery + CalendarSelectionIntent - CalendarrTimelineProvider migrated from TimelineProvider to AppIntentTimelineProvider - All 13 StaticConfiguration widgets changed to AppIntentConfiguration - publishWidgetSnapshot() builds + writes the calendar list for the intent An empty selection (default) shows all calendars; selecting specific calendars filters the widget events accordingly. Co-Authored-By: Claude Sonnet 4.6 --- Calendarr iOS.xcodeproj/project.pbxproj | 4 +- Calendarr iOS/Models/CalendarStore.swift | 24 ++++++++- CalendarrWidgets/CalendarIntent.swift | 42 +++++++++++++++ .../CalendarrTimelineProvider.swift | 44 ++++++++++++---- CalendarrWidgets/CalendarrWidgets.swift | 26 +++++----- Shared/WidgetData.swift | 51 ++++++++++++++++++- 6 files changed, 163 insertions(+), 28 deletions(-) create mode 100644 CalendarrWidgets/CalendarIntent.swift diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index d208594..64b476b 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -511,7 +511,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.7; + MARKETING_VERSION = 2.8; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -555,7 +555,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.7; + MARKETING_VERSION = 2.8; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index 95c7ed2..ed2592d 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -438,12 +438,31 @@ class CalendarStore { /// 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. let from = cal.date(byAdding: .day, value: -7, to: cal.startOfDay(for: now)) ?? now - let to = cal.date(byAdding: .day, value: 42, to: cal.startOfDay(for: now)) ?? from + let to = cal.date(byAdding: .day, value: 42, to: cal.startOfDay(for: now)) ?? from + + // Build the calendar list from all cached events (not just the window) + // so every calendar appears in the widget configuration picker. + var calendarMap: [String: WidgetCalendar] = [:] + for ev in allCachedEvents { + let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId) + guard !banishedCalendarKeys.contains(key) else { continue } + if calendarMap[key] == nil { + calendarMap[key] = WidgetCalendar(id: key, + name: ev.calendarName.isEmpty ? ev.calendarId : ev.calendarName, + colorHex: ev.calendarColor) + } + } + WidgetStore.writeCalendars(Array(calendarMap.values).sorted { $0.name < $1.name }) + let visible = allCachedEvents .filter { ev in let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId) @@ -460,7 +479,8 @@ class CalendarStore { end: ev.endDate, isAllDay: ev.isAllDay, colorHex: ev.effectiveColor, - location: ev.location) + location: ev.location, + calendarKey: Self.calendarKey(source: ev.source, calendarId: ev.calendarId)) } let defaults = UserDefaults.standard let snap = WidgetSnapshot( diff --git a/CalendarrWidgets/CalendarIntent.swift b/CalendarrWidgets/CalendarIntent.swift new file mode 100644 index 0000000..6c27d8a --- /dev/null +++ b/CalendarrWidgets/CalendarIntent.swift @@ -0,0 +1,42 @@ +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. +/// An empty selection means "show all calendars" (the default). +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] +} diff --git a/CalendarrWidgets/CalendarrTimelineProvider.swift b/CalendarrWidgets/CalendarrTimelineProvider.swift index a3e52a2..fcb6902 100644 --- a/CalendarrWidgets/CalendarrTimelineProvider.swift +++ b/CalendarrWidgets/CalendarrTimelineProvider.swift @@ -1,33 +1,57 @@ import WidgetKit +import AppIntents struct CalendarrEntry: TimelineEntry { let date: Date let snapshot: WidgetSnapshot? } -struct CalendarrTimelineProvider: TimelineProvider { +struct CalendarrTimelineProvider: AppIntentTimelineProvider { + typealias Entry = CalendarrEntry + typealias Intent = CalendarSelectionIntent + func placeholder(in context: Context) -> CalendarrEntry { CalendarrEntry(date: .now, snapshot: WidgetStore.read()) } - func getSnapshot(in context: Context, completion: @escaping (CalendarrEntry) -> Void) { - completion(CalendarrEntry(date: .now, snapshot: WidgetStore.read())) + func snapshot(for configuration: CalendarSelectionIntent, in context: Context) async -> CalendarrEntry { + let raw = WidgetStore.read() + return CalendarrEntry(date: .now, snapshot: filtered(raw, by: configuration)) } - func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { - let snapshot = WidgetStore.read() + func timeline(for configuration: CalendarSelectionIntent, in context: Context) async -> Timeline { + let raw = WidgetStore.read() + let snap = filtered(raw, by: configuration) let now = Date() - // Provide one entry per hour for the next 24h so the widget keeps - // re-rendering as time progresses (past events drop off, "now" advances). + // 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: snapshot)) + entries.append(CalendarrEntry(date: date, snapshot: snap)) } - // Ask iOS to refresh in 30 min to pick up any new data the app wrote. let refreshAt = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now - completion(Timeline(entries: entries, policy: .after(refreshAt))) + return Timeline(entries: entries, policy: .after(refreshAt)) + } + + // MARK: – Filtering + + private func filtered(_ snapshot: WidgetSnapshot?, by config: CalendarSelectionIntent) -> WidgetSnapshot? { + guard let snapshot else { return nil } + let ids = config.selectedCalendars.map { $0.id } + guard !ids.isEmpty else { return snapshot } // empty = show all + let keep = Set(ids) + return WidgetSnapshot( + writtenAt: snapshot.writtenAt, + events: snapshot.events.filter { keep.contains($0.calendarKey) }, + todayColorHex: snapshot.todayColorHex, + textColorHex: snapshot.textColorHex, + backgroundColorHex: snapshot.backgroundColorHex, + lineColorHex: snapshot.lineColorHex, + primaryColorHex: snapshot.primaryColorHex, + accentColorHex: snapshot.accentColorHex, + language: snapshot.language + ) } } diff --git a/CalendarrWidgets/CalendarrWidgets.swift b/CalendarrWidgets/CalendarrWidgets.swift index 8cb6cb9..6cfed64 100644 --- a/CalendarrWidgets/CalendarrWidgets.swift +++ b/CalendarrWidgets/CalendarrWidgets.swift @@ -47,7 +47,7 @@ struct TodayWidget: Widget { let kind: String = "TodayWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in TodayWidgetView(entry: entry).calendarrChrome(entry.snapshot) } .configurationDisplayName(WidgetL10n.t("widget.display.today_title", "system")) @@ -62,7 +62,7 @@ struct TwoDaysWidget: Widget { let kind: String = "TwoDaysWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in TwoDaysWidgetView(entry: entry).calendarrChrome(entry.snapshot) } .configurationDisplayName(WidgetL10n.t("widget.display.days_title", "system")) @@ -77,7 +77,7 @@ struct ThreeDaysWidget: Widget { let kind: String = "ThreeDaysWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in ThreeDaysWidgetView(entry: entry).calendarrChrome(entry.snapshot) } .configurationDisplayName(WidgetL10n.t("widget.display.threedays_title", "system")) @@ -92,7 +92,7 @@ struct ThisWeekWidget: Widget { let kind: String = "ThisWeekWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in ThisWeekWidgetView(entry: entry).calendarrChrome(entry.snapshot) } .configurationDisplayName(WidgetL10n.t("widget.display.thisweek_title", "system")) @@ -107,7 +107,7 @@ struct TwoWeeksWidget: Widget { let kind: String = "TwoWeeksWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in TwoWeeksWidgetView(entry: entry).calendarrChrome(entry.snapshot) } .configurationDisplayName(WidgetL10n.t("widget.display.twoweeks_title", "system")) @@ -122,7 +122,7 @@ struct UpcomingWidget: Widget { let kind: String = "UpcomingWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in UpcomingWidgetView(entry: entry).calendarrChrome(entry.snapshot) } .configurationDisplayName(WidgetL10n.t("widget.display.upcoming_title", "system")) @@ -137,7 +137,7 @@ struct UpNextWidget: Widget { let kind: String = "UpNextWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in UpNextWidgetView(entry: entry).calendarrChrome(entry.snapshot) } .configurationDisplayName(WidgetL10n.t("widget.display.upnext_title", "system")) @@ -152,7 +152,7 @@ struct CalendarDayWidget: Widget { let kind: String = "CalendarDayWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in CalendarDayWidgetView(entry: entry).calendarrChrome(entry.snapshot) } .configurationDisplayName(WidgetL10n.t("widget.display.calday_title", "system")) @@ -167,7 +167,7 @@ struct TwoMonthWidget: Widget { let kind: String = "TwoMonthWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in TwoMonthWidgetView(entry: entry).calendarrChrome(entry.snapshot) } .configurationDisplayName(WidgetL10n.t("widget.display.twomonth_title", "system")) @@ -182,7 +182,7 @@ struct NowNextEventsWidget: Widget { let kind: String = "NowNextEventsWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in NowNextWidgetView(entry: entry).calendarrChrome(entry.snapshot) } .configurationDisplayName(WidgetL10n.t("widget.display.nownext_title", "system")) @@ -197,7 +197,7 @@ struct LockScreenWidget: Widget { let kind: String = "LockScreenWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + 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")) @@ -214,7 +214,7 @@ struct LockScreenCountWidget: Widget { let kind: String = "LockScreenCountWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + 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")) @@ -231,7 +231,7 @@ struct LockScreenCountdownWidget: Widget { let kind: String = "LockScreenCountdownWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + 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")) diff --git a/Shared/WidgetData.swift b/Shared/WidgetData.swift index 7fba69b..a623aed 100644 --- a/Shared/WidgetData.swift +++ b/Shared/WidgetData.swift @@ -8,6 +8,14 @@ import WidgetKit /// and the App Group ID registered in the Apple Developer Portal. let widgetAppGroupID = "group.com.scarriffleservices.calendarr" +/// Lightweight calendar descriptor stored alongside the event cache so the +/// widget configuration intent can offer calendar options without a network call. +struct WidgetCalendar: Codable, Identifiable, Hashable { + let id: String // calendarKey ("source-calendarId") + let name: String + let colorHex: String +} + /// Lightweight event representation that lives inside the widget cache. /// We strip everything the widget doesn't need (notes, calendar IDs, URLs). struct WidgetEvent: Codable, Hashable, Identifiable { @@ -18,6 +26,27 @@ struct WidgetEvent: Codable, Hashable, Identifiable { let isAllDay: Bool let colorHex: String let location: String + let calendarKey: String // used for per-calendar filtering in widget config + + init(id: String, title: String, start: Date, end: Date, + isAllDay: Bool, colorHex: String, location: String, calendarKey: String) { + self.id = id; self.title = title; self.start = start; self.end = end + self.isAllDay = isAllDay; self.colorHex = colorHex + self.location = location; self.calendarKey = calendarKey + } + + // Backward-compatible decoder: old caches have no calendarKey field. + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + title = try c.decode(String.self, forKey: .title) + start = try c.decode(Date.self, forKey: .start) + end = try c.decode(Date.self, forKey: .end) + isAllDay = try c.decode(Bool.self, forKey: .isAllDay) + colorHex = try c.decode(String.self, forKey: .colorHex) + location = try c.decode(String.self, forKey: .location) + calendarKey = try c.decodeIfPresent(String.self, forKey: .calendarKey) ?? "" + } } /// Snapshot blob the app writes to the App-Group container and the widget reads. @@ -75,7 +104,8 @@ struct WidgetSnapshot: Codable { } enum WidgetStore { - private static let cacheFilename = "widget-cache.json" + private static let cacheFilename = "widget-cache.json" + private static let calendarsFilename = "widget-calendars.json" private static var containerURL: URL? { FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: widgetAppGroupID) @@ -85,6 +115,10 @@ enum WidgetStore { containerURL?.appendingPathComponent(cacheFilename) } + private static var calendarsURL: URL? { + containerURL?.appendingPathComponent(calendarsFilename) + } + /// Called by the app whenever the event cache changes. static func write(_ snapshot: WidgetSnapshot) { guard let url = cacheURL else { return } @@ -103,6 +137,21 @@ enum WidgetStore { return try? decoder.decode(WidgetSnapshot.self, from: data) } + /// Write the list of available calendars so the widget intent can offer + /// calendar options for filtering without a network call. + static func writeCalendars(_ calendars: [WidgetCalendar]) { + guard let url = calendarsURL else { return } + if let data = try? JSONEncoder().encode(calendars) { + try? data.write(to: url, options: .atomic) + } + } + + /// Read the calendar list written by the main app. + static func readCalendars() -> [WidgetCalendar] { + guard let url = calendarsURL, let data = try? Data(contentsOf: url) else { return [] } + return (try? JSONDecoder().decode([WidgetCalendar].self, from: data)) ?? [] + } + /// Rewrite the existing snapshot with the latest colour / language values /// from UserDefaults. Used when the user tweaks an appearance setting and /// we want the widgets to refresh immediately, without needing a new event From 8ed474663e4d3b35a208f59d9e4bd6b443b0ce0c Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 2 Jul 2026 18:25:47 +0200 Subject: [PATCH 41/90] feat: surface per-calendar sync errors from /api/caldav/events The server now reports per-calendar sync failures (e.g. expired credentials) alongside an otherwise-successful events response, via an "errors" array. Previously such failures were silently swallowed, so a calendar could appear enabled while showing zero events with no explanation. - Decode the new "errors" field into SyncError (source/name/message), following the existing JSONSerialization + from(json:) convention. - CalendarStore.syncErrors captures these on successful personal fetches (loadEvents, prefetchBackground); left untouched on a hard fetch failure so it isn't conflated with lastError. Group overlay fetches never populate it (the endpoint doesn't apply there). - CalendarHostView shows a second red warning banner alongside the existing lastError banner, reusing the same visual style. --- Calendarr iOS/Models/CalEvent.swift | 20 ++++++++++++++++ Calendarr iOS/Models/CalendarStore.swift | 22 ++++++++++++++---- Calendarr iOS/Services/CalendarrAPI.swift | 11 +++++++-- .../Views/Calendar/CalendarHostView.swift | 23 ++++++++++++++++++- 4 files changed, 69 insertions(+), 7 deletions(-) diff --git a/Calendarr iOS/Models/CalEvent.swift b/Calendarr iOS/Models/CalEvent.swift index 3120d11..b3669d8 100644 --- a/Calendarr iOS/Models/CalEvent.swift +++ b/Calendarr iOS/Models/CalEvent.swift @@ -18,6 +18,26 @@ struct EventPerson: Hashable { } } +/// A partial-sync failure reported alongside an otherwise-successful +/// `/api/caldav/events` response: one specific calendar didn't sync (e.g. +/// expired credentials) even though it's still enabled. Distinct from a +/// hard fetch failure (`CalendarStore.lastError`) — the request itself +/// succeeded, just not every source within it. +struct SyncError: Hashable { + let source: String + let 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 } + return SyncError(source: source, name: name, message: message) + } +} + struct CalEvent: Identifiable, Hashable { let id: String let url: String diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index ed2592d..a9e7468 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -58,6 +58,13 @@ class CalendarStore { 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 @@ -340,10 +347,14 @@ class CalendarStore { lastError = nil defer { isLoading = false } do { - let fetched = try await fetchForMode(api: api, start: start, end: end) + let (fetched, errors) = try await fetchForMode(api: api, start: start, end: end) + syncErrors = errors mergeIntoCache(fetched, rangeStart: start, rangeEnd: end) 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 } } @@ -351,10 +362,12 @@ class CalendarStore { /// Fetch events for the current mode (personal vs. group overlay). Group /// events go through the same cache/prefetch/refresh path as personal ones, /// so the whole visible grid is covered (no "only the middle weeks" gaps). - private func fetchForMode(api: CalendarrAPI, start: Date, end: Date) async throws -> [CalEvent] { + /// 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 (combined.map { decorateGroupEvent($0) }, []) } return try await api.fetchEvents(start: start, end: end) } @@ -394,7 +407,8 @@ class CalendarStore { isCachingBackground = true defer { isCachingBackground = false } do { - let fetched = try await fetchForMode(api: api, start: start, end: end) + let (fetched, errors) = try await fetchForMode(api: api, start: start, end: end) + syncErrors = errors mergeIntoCache(fetched, rangeStart: start, rangeEnd: end) // Refresh visible range from newly expanded cache let (vs, ve) = rangeForCurrentView() diff --git a/Calendarr iOS/Services/CalendarrAPI.swift b/Calendarr iOS/Services/CalendarrAPI.swift index 8ffdc22..6f82672 100644 --- a/Calendarr iOS/Services/CalendarrAPI.swift +++ b/Calendarr iOS/Services/CalendarrAPI.swift @@ -206,7 +206,12 @@ class CalendarrAPI { // MARK: – Events - func fetchEvents(start: Date, end: Date) async throws -> [CalEvent] { + /// Fetches events for the personal calendar view. The server also reports + /// per-calendar sync failures (e.g. expired credentials) alongside an + /// otherwise-successful response, via the `errors` array — surfaced + /// separately from a hard fetch failure so the UI can distinguish + /// "everything failed" from "fetched fine, but calendar X didn't sync". + func fetchEvents(start: Date, end: Date) async throws -> (events: [CalEvent], errors: [SyncError]) { // Use UTC with Z suffix – avoids '+' character which breaks URL query params let iso = ISO8601DateFormatter() iso.formatOptions = [.withInternetDateTime] @@ -224,7 +229,9 @@ class CalendarrAPI { let preview = String(data: data, encoding: .utf8).map { String($0.prefix(200)) } ?? "no data" throw APIError.serverError("Unerwartete Antwort: \(preview)") } - return arr.compactMap { CalEvent.from(json: $0) } + let events = arr.compactMap { CalEvent.from(json: $0) } + let errors = (root["errors"] as? [[String: Any]])?.compactMap { SyncError.from(json: $0) } ?? [] + return (events, errors) } func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date, diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index f6401e2..36915d0 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -284,10 +284,14 @@ struct CalendarHostView: View { .accessibilityLabel(L10n.t("nav.menu", appLang)) } - // MARK: – Error banner + // MARK: – Error banners + // `lastError` (whole fetch failed) and `syncErrors` (fetch succeeded, but + // individual calendars didn't sync) are independent conditions and can + // both be shown at once. @ViewBuilder private var errorBanner: some View { if let err = store.lastError { errorBannerView(err) } + if !store.syncErrors.isEmpty { syncErrorBannerView(store.syncErrors) } } private func errorBannerView(_ err: String) -> some View { @@ -303,6 +307,23 @@ struct CalendarHostView: View { .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 From 1cbab3f3ec8dbcf797057cf2f422fead67b65f92 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 3 Jul 2026 20:06:11 +0200 Subject: [PATCH 42/90] fix: preserve cached events when a calendar has a per-source sync error Root cause: mergeIntoCache() unconditionally evicted every event in the fetched date range, even for calendars whose sync failed. Those calendars' events were removed and never restored, so the calendar appeared to vanish. Fix: - SyncError now decodes calendarId from the server's "calendar_id" field (added server-side in e0ea16f but not yet read by iOS). - mergeIntoCache() gains a keepKeysInRange parameter: events from failed calendars are retained (not evicted) even within the fetch window. - loadEvents() and prefetchBackground() compute the failed keys from the syncErrors list and pass them to mergeIntoCache. Result: when CalDAV / Google / HA sync fails for a specific calendar, the user sees stale-but-correct events alongside the existing error banner, instead of a completely empty calendar with no explanation. Co-Authored-By: Claude Sonnet 4.6 --- Calendarr iOS.xcodeproj/project.pbxproj | 4 +-- Calendarr iOS/Models/CalEvent.swift | 8 +++--- Calendarr iOS/Models/CalendarStore.swift | 31 ++++++++++++++++++++---- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index 64b476b..ef2eb75 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -511,7 +511,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.8; + MARKETING_VERSION = 2.9; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -555,7 +555,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.8; + MARKETING_VERSION = 2.9; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/Calendarr iOS/Models/CalEvent.swift b/Calendarr iOS/Models/CalEvent.swift index b3669d8..fb820ea 100644 --- a/Calendarr iOS/Models/CalEvent.swift +++ b/Calendarr iOS/Models/CalEvent.swift @@ -25,16 +25,18 @@ struct EventPerson: Hashable { /// 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 source = json["source"] as? String, + let name = json["name"] as? String, let message = json["message"] as? String else { return nil } - return SyncError(source: source, name: name, message: message) + let calendarId = json["calendar_id"].map { "\($0)" } + return SyncError(source: source, calendarId: calendarId, name: name, message: message) } } diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index a9e7468..6edde83 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -349,7 +349,8 @@ class CalendarStore { do { let (fetched, errors) = try await fetchForMode(api: api, start: start, end: end) syncErrors = errors - mergeIntoCache(fetched, rangeStart: start, rangeEnd: end) + mergeIntoCache(fetched, rangeStart: start, rangeEnd: end, + keepKeysInRange: failedCalendarKeys(from: errors)) refreshFromCache(start: start, end: end) } catch { // Hard failure – leave `syncErrors` as-is; it reflects the last @@ -409,7 +410,8 @@ class CalendarStore { do { let (fetched, errors) = try await fetchForMode(api: api, start: start, end: end) syncErrors = errors - mergeIntoCache(fetched, rangeStart: start, rangeEnd: end) + mergeIntoCache(fetched, rangeStart: start, rangeEnd: end, + keepKeysInRange: failedCalendarKeys(from: errors)) // Refresh visible range from newly expanded cache let (vs, ve) = rangeForCurrentView() refreshFromCache(start: vs, end: ve) @@ -428,10 +430,29 @@ class CalendarStore { allCachedEvents = [] } - private func mergeIntoCache(_ newEvents: [CalEvent], rangeStart: Date, rangeEnd: Date) { - // Remove old events that overlap with the newly fetched range (avoid duplicates) + /// Calendar keys (source:id) for calendars that reported a per-source sync + /// error. Events from these calendars must NOT be evicted from the cache on + /// a partial sync — stale data is better than a completely empty calendar. + private func failedCalendarKeys(from errors: [SyncError]) -> Set { + var keys = Set() + for err in errors { + guard let cid = err.calendarId else { continue } + keys.insert(Self.calendarKey(source: err.source, calendarId: cid)) + } + return keys + } + + private func mergeIntoCache(_ newEvents: [CalEvent], rangeStart: Date, rangeEnd: Date, + keepKeysInRange: Set = []) { + // Remove old events in the fetched range to avoid duplicates — but + // PRESERVE events from calendars that had sync errors so that a + // transient CalDAV / Google failure doesn't wipe the visible calendar. let retained = allCachedEvents.filter { ev in - ev.startDate >= rangeEnd || ev.endDate <= rangeStart + let outsideRange = ev.startDate >= rangeEnd || ev.endDate <= rangeStart + if outsideRange { 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 From fb22aeb090a2f7963564028e8af804739a182686 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 3 Jul 2026 20:55:57 +0200 Subject: [PATCH 43/90] fix(cache): retry failed-calendar ranges instead of marking them as cached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mergeIntoCache() previously extended cachedStart/End even when sync errors occurred — so isCached() returned true on the next call and the failed calendar was silently left empty (especially bad on first launch or after forceReload when there's nothing to preserve). Now the cached range is only extended on a fully clean fetch. When keepKeysInRange is non-empty (sync errors present), we leave cachedStart/End unchanged, forcing isCached() to return false and triggering an automatic retry on the next loadEvents call. Co-Authored-By: Claude Sonnet 4.6 --- Calendarr iOS/Models/CalendarStore.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index 6edde83..26a68d0 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -456,7 +456,13 @@ class CalendarStore { } allCachedEvents = retained + newEvents - // Extend cached range + // Only extend the cached range when the fetch was completely clean. + // When some calendars had sync errors (keepKeysInRange non-empty), 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 else { return } if let cs = cachedStart, let ce = cachedEnd { cachedStart = min(cs, rangeStart) cachedEnd = max(ce, rangeEnd) From 61237134f04e0760cc618e49a40561ace93dc589 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 3 Jul 2026 21:31:05 +0200 Subject: [PATCH 44/90] fix(cache): preserve whole source on account-level sync errors (HA/Google tokens) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-calendar preservation added in the previous fix only kicks in when a sync error carries a calendar_id. But account-level failures — most commonly a Home Assistant or Google token-refresh failure — abort the entire account fetch and arrive WITHOUT a calendar_id. Those events were still being evicted, so HA/Google calendars kept vanishing on a transient token hiccup. failedCalendarKeys() now returns both per-calendar keys and whole sources: an error without a calendar_id protects every cached calendar of that source. mergeIntoCache() keeps events whose source is in keepSourcesInRange, and the cached range is only extended when neither set is populated (auto-retry). Co-Authored-By: Claude Sonnet 4.6 --- Calendarr iOS/Models/CalendarStore.swift | 53 ++++++++++++++++-------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index 26a68d0..af3b5de 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -349,8 +349,9 @@ class CalendarStore { 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: failedCalendarKeys(from: errors)) + keepKeysInRange: failed.keys, keepSourcesInRange: failed.sources) refreshFromCache(start: start, end: end) } catch { // Hard failure – leave `syncErrors` as-is; it reflects the last @@ -410,8 +411,9 @@ class CalendarStore { 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: failedCalendarKeys(from: errors)) + keepKeysInRange: failed.keys, keepSourcesInRange: failed.sources) // Refresh visible range from newly expanded cache let (vs, ve) = rangeForCurrentView() refreshFromCache(start: vs, end: ve) @@ -430,26 +432,41 @@ class CalendarStore { allCachedEvents = [] } - /// Calendar keys (source:id) for calendars that reported a per-source sync - /// error. Events from these calendars must NOT be evicted from the cache on - /// a partial sync — stale data is better than a completely empty calendar. - private func failedCalendarKeys(from errors: [SyncError]) -> Set { + /// 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, sources: Set) { var keys = Set() + var sources = Set() for err in errors { - guard let cid = err.calendarId else { continue } - keys.insert(Self.calendarKey(source: err.source, calendarId: cid)) + if let cid = err.calendarId { + keys.insert(Self.calendarKey(source: err.source, calendarId: cid)) + } else { + sources.insert(err.source) + } } - return keys + return (keys, sources) } private func mergeIntoCache(_ newEvents: [CalEvent], rangeStart: Date, rangeEnd: Date, - keepKeysInRange: Set = []) { + keepKeysInRange: Set = [], + keepSourcesInRange: Set = []) { // Remove old events in the fetched range to avoid duplicates — but - // PRESERVE events from calendars that had sync errors so that a - // transient CalDAV / Google failure doesn't wipe the visible calendar. + // 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) @@ -457,12 +474,12 @@ class CalendarStore { allCachedEvents = retained + newEvents // Only extend the cached range when the fetch was completely clean. - // When some calendars had sync errors (keepKeysInRange non-empty), 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 else { return } + // 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) From 325f35735747986d54535e9ffbd7985b8849146b Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 3 Jul 2026 22:04:58 +0200 Subject: [PATCH 45/90] fix(visibility): auto-reconcile server sidebar_hidden on launch/resume/sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hiding or showing a calendar on the web sets enabled=false + sidebar_hidden server-side, so the server stops returning that calendar's events entirely. The app only reconciled these per-calendar flags when the filter sheet or the accounts screen happened to be opened — never on launch, resume or the periodic pull. So a calendar re-enabled on the web stayed invisible (still banished locally, and its events weren't in the cache) until a manual sync or relaunch — the "hide it on the web and the app won't bring it back" bug. Add CalendarStore.reconcileCalendarVisibility(api:) which pulls the account lists, updates the banished set from the server's sidebar_hidden flags, and returns whether anything changed. CalendarHostView now runs it (via syncFromServer) on launch, on scenePhase .active, in the periodic loop, and on manual sync — forcing a refetch when the set changed so a re-enabled calendar's events actually reappear without user intervention. Co-Authored-By: Claude Sonnet 4.6 --- Calendarr iOS/Models/CalendarStore.swift | 32 +++++++++++++++++++ .../Views/Calendar/CalendarHostView.swift | 25 ++++++++++++--- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index af3b5de..f13fd2c 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -225,6 +225,38 @@ class CalendarStore { 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" diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index 36915d0..d674889 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -85,7 +85,7 @@ struct CalendarHostView: View { .onChange(of: store.viewType) { _, _ in Task { await onNavigate() } } .onChange(of: cacheMonths) { _, _ in Task { await recache() } } .onChange(of: store.visibleMonth) { _, new in Task { await ensureLoaded(around: new) } } - .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await SettingsSync.pull(api: api) } } } + .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await syncFromServer() } } } .onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in store.syncBanishedFromDefaults() } @@ -152,7 +152,7 @@ struct CalendarHostView: View { .onChange(of: store.viewType) { _, _ in Task { await onNavigate() } } .onChange(of: cacheMonths) { _, _ in Task { await recache() } } .onChange(of: store.visibleMonth) { _, new in Task { await ensureLoaded(around: new) } } - .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await SettingsSync.pull(api: api) } } } + .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await syncFromServer() } } } .onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in store.syncBanishedFromDefaults() } @@ -267,7 +267,7 @@ struct CalendarHostView: View { } } // Sync - Button { Task { await SettingsSync.pull(api: api); await forceReload() } } label: { + Button { Task { await syncFromServer(force: true) } } label: { Label(L10n.t("menu.sync", appLang), systemImage: "arrow.triangle.2.circlepath") } Divider() @@ -437,6 +437,10 @@ struct CalendarHostView: View { 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() @@ -445,11 +449,11 @@ struct CalendarHostView: View { Task(priority: .background) { await store.prefetchBackground(api: api, months: cacheMonths) } - // 3. Periodic settings pull (tied to this .task's lifetime). + // 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 SettingsSync.pull(api: api) + await syncFromServer() } } @@ -497,6 +501,17 @@ struct CalendarHostView: View { } } + /// Pull server-driven settings AND reconcile per-calendar visibility in one + /// step (used on launch, resume and the periodic loop). If the server + /// changed a calendar's `sidebar_hidden` — e.g. hidden/shown on the web or + /// another device — or `force` is set (manual sync), refetch so the change + /// shows up without the user opening the filter sheet. + private func syncFromServer(force: Bool = false) async { + await SettingsSync.pull(api: api) + 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 { From 9db04361cca7d02e737f3b613514e14cd6f0e685 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sat, 4 Jul 2026 18:56:32 +0200 Subject: [PATCH 46/90] feat(sharing): show shared calendars read-only (parity with server/Android) - Parse the server's new read_only flag on events. - Filter sheet: a calendar shared with me is listed under the owner's name (sharedBy) with a lock icon when I only have read access. - loadWritableCalendars: exclude read-only shared calendars so they no longer appear in the event editor (a save would 403). The group-title prefix removal is server-side and needs no iOS change. Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/CalEvent.swift | 5 ++++- Calendarr iOS/Models/CalendarStore.swift | 4 +++- Calendarr iOS/Views/CalendarFilterSheet.swift | 15 ++++++++++++--- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/Calendarr iOS/Models/CalEvent.swift b/Calendarr iOS/Models/CalEvent.swift index fb820ea..e53f30a 100644 --- a/Calendarr iOS/Models/CalEvent.swift +++ b/Calendarr iOS/Models/CalEvent.swift @@ -65,6 +65,8 @@ struct CalEvent: Identifiable, Hashable { 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 // Group view supplies a server-resolved colour; otherwise per-event then calendar colour. var effectiveColor: String { displayColor ?? color ?? calendarColor } @@ -107,7 +109,8 @@ struct CalEvent: Identifiable, Hashable { 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)") } ?? [] + reminders: (json["reminders"] as? [Int]) ?? (json["reminders"] as? [Any])?.compactMap { ($0 as? Int) ?? Int("\($0)") } ?? [], + readOnly: json["read_only"] as? Bool ?? false ) } } diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index f13fd2c..6c63a61 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -597,7 +597,9 @@ class CalendarStore { async let haCals = (try? await api.getHACalendars()) ?? [] var result: [WritableCalendar] = [] - for cal in await localCals { + // Skip read-only shared calendars — offering them in the event editor + // only leads to a 403 on save. Own + read_write (incl. group) stay. + for cal in await localCals where cal.owned || cal.permission == "read_write" { result.append(WritableCalendar(id: "local-\(cal.id)", name: cal.name, color: cal.color, source: "local", numericId: cal.id)) } for acc in await caldavAccs where acc.enabled { diff --git a/Calendarr iOS/Views/CalendarFilterSheet.swift b/Calendarr iOS/Views/CalendarFilterSheet.swift index ccd79dc..63f8efb 100644 --- a/Calendarr iOS/Views/CalendarFilterSheet.swift +++ b/Calendarr iOS/Views/CalendarFilterSheet.swift @@ -45,8 +45,12 @@ struct CalendarFilterSheet: View { if !visibleLocals.isEmpty { Section(L10n.t("accounts.local.header", appLang)) { ForEach(visibleLocals) { cal in - row(name: cal.name, colorHex: cal.color, - key: CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)")) + // A calendar shared with me is shown under the owner's + // name and flagged read-only when I can't write to it. + row(name: cal.owned ? cal.name : (cal.sharedBy ?? cal.name), + colorHex: cal.color, + key: CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)"), + readOnly: !cal.owned && cal.permission != "read_write") } } } @@ -140,7 +144,7 @@ struct CalendarFilterSheet: View { } @ViewBuilder - private func row(name: String, colorHex: String, key: String) -> some View { + private func row(name: String, colorHex: String, key: String, readOnly: Bool = false) -> some View { let isVisible = !hidden.contains(key) Button { if isVisible { hidden.insert(key) } else { hidden.remove(key) } @@ -156,6 +160,11 @@ struct CalendarFilterSheet: View { Text(name) .foregroundStyle(isVisible ? .primary : .secondary) .strikethrough(!isVisible, color: .secondary) + if readOnly { + Image(systemName: "lock.fill") + .font(.caption2) + .foregroundStyle(.secondary) + } Spacer() if reminderDisabled.contains(key) { Image(systemName: "bell.slash") From 3f5de5cdfab852f77e6e717ec4d24d6ac803b9b6 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 6 Jul 2026 14:32:01 +0200 Subject: [PATCH 47/90] fix(security): store auth token in Keychain and restrict ATS to local networking - Move the bearer token from UserDefaults to the Keychain (accessible after first unlock), with a one-time migration so existing logins survive. - Replace NSAllowsArbitraryLoads=YES with NSAllowsLocalNetworking=YES so ATS still permits cleartext to LAN/self-hosted servers but enforces TLS for public hosts (no arbitrary cleartext/MITM). Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS.xcodeproj/project.pbxproj | 4 +-- Calendarr iOS/CalendarrApp.swift | 48 +++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index ef2eb75..7b0fb67 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -499,7 +499,7 @@ INFOPLIST_KEY_CFBundleName = Calendarr; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES; + INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -543,7 +543,7 @@ INFOPLIST_KEY_CFBundleName = Calendarr; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; - INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES; + INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/Calendarr iOS/CalendarrApp.swift b/Calendarr iOS/CalendarrApp.swift index e8c9a6b..fe39d08 100644 --- a/Calendarr iOS/CalendarrApp.swift +++ b/Calendarr iOS/CalendarrApp.swift @@ -1,4 +1,5 @@ import SwiftUI +import Security @main struct CalendarrApp: App { @@ -24,7 +25,13 @@ class AppState { init() { serverURL = UserDefaults.standard.string(forKey: "serverURL") ?? "" - authToken = UserDefaults.standard.string(forKey: "authToken") ?? "" + // Migrate a token previously kept in UserDefaults into the Keychain once, + // so existing logins survive the change without re-authenticating. + if let legacy = UserDefaults.standard.string(forKey: "authToken"), !legacy.isEmpty { + Keychain.set(legacy, for: "authToken") + UserDefaults.standard.removeObject(forKey: "authToken") + } + authToken = Keychain.get("authToken") ?? "" username = UserDefaults.standard.string(forKey: "username") ?? "" isAdmin = UserDefaults.standard.bool(forKey: "isAdmin") } @@ -39,7 +46,7 @@ class AppState { authToken = token username = user isAdmin = admin - UserDefaults.standard.set(token, forKey: "authToken") + Keychain.set(token, for: "authToken") // secret → Keychain, not UserDefaults UserDefaults.standard.set(user, forKey: "username") UserDefaults.standard.set(admin, forKey: "isAdmin") } @@ -48,7 +55,8 @@ class AppState { authToken = "" username = "" isAdmin = false - UserDefaults.standard.removeObject(forKey: "authToken") + Keychain.set(nil, for: "authToken") + UserDefaults.standard.removeObject(forKey: "authToken") // clear any legacy copy UserDefaults.standard.removeObject(forKey: "username") UserDefaults.standard.removeObject(forKey: "isAdmin") } @@ -59,3 +67,37 @@ class AppState { UserDefaults.standard.removeObject(forKey: "serverURL") } } + +/// Minimal Keychain wrapper for secrets (the auth bearer token). Values are +/// stored as generic passwords, accessible after first unlock. +enum Keychain { + private static let service = "Calendarr" + + static func set(_ value: String?, for key: String) { + let base: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + SecItemDelete(base as CFDictionary) + guard let value, let data = value.data(using: .utf8) else { return } + var add = base + add[kSecValueData as String] = data + add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + SecItemAdd(add as CFDictionary, nil) + } + + static func get(_ key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var out: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &out) == errSecSuccess, + let data = out as? Data else { return nil } + return String(data: data, encoding: .utf8) + } +} From 32cb8e16895893b8c8004b38196928ddbe877950 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 6 Jul 2026 18:45:49 +0200 Subject: [PATCH 48/90] feat(sharing): hidden-profile toggle + hide non-sharing group members - UserProfile decodes directory_hidden; updateProfile() sends it; Settings has a "hide my profile" toggle (excludes you from share/group pickers). - GroupMember decodes shares_calendar; the group filter list now only shows members who actually share a calendar (no phantom empty rows). - Robust custom decoders (decodeIfPresent) for the new fields. Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/AppSettings.swift | 25 +++++++++++++++++++ Calendarr iOS/Models/Localization.swift | 4 +++ Calendarr iOS/Services/CalendarrAPI.swift | 4 ++- Calendarr iOS/Views/CalendarFilterSheet.swift | 4 ++- Calendarr iOS/Views/SettingsView.swift | 12 ++++++++- 5 files changed, 46 insertions(+), 3 deletions(-) diff --git a/Calendarr iOS/Models/AppSettings.swift b/Calendarr iOS/Models/AppSettings.swift index a90560f..2f3e7c3 100644 --- a/Calendarr iOS/Models/AppSettings.swift +++ b/Calendarr iOS/Models/AppSettings.swift @@ -223,6 +223,7 @@ struct UserProfile: Codable { let isAdmin: Bool let hasAvatar: Bool let totpEnabled: Bool + var directoryHidden: Bool = false enum CodingKeys: String, CodingKey { case id, username, email @@ -230,6 +231,19 @@ struct UserProfile: Codable { 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 } } @@ -258,9 +272,20 @@ struct GroupMember: Codable, Identifiable { 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 } } diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index d488375..bcabbae 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -132,6 +132,8 @@ private let strings: [String: [String: String]] = [ "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", @@ -461,6 +463,8 @@ private let strings: [String: [String: String]] = [ "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", diff --git a/Calendarr iOS/Services/CalendarrAPI.swift b/Calendarr iOS/Services/CalendarrAPI.swift index 6f82672..7b8d2a3 100644 --- a/Calendarr iOS/Services/CalendarrAPI.swift +++ b/Calendarr iOS/Services/CalendarrAPI.swift @@ -450,11 +450,13 @@ class CalendarrAPI { /// Update profile fields. A login-name change returns a fresh token (the old /// one becomes invalid) — the caller must store the returned token. - func updateProfile(displayName: String?, username: String?, email: String?) async throws -> String? { + 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 diff --git a/Calendarr iOS/Views/CalendarFilterSheet.swift b/Calendarr iOS/Views/CalendarFilterSheet.swift index 63f8efb..bd79e95 100644 --- a/Calendarr iOS/Views/CalendarFilterSheet.swift +++ b/Calendarr iOS/Views/CalendarFilterSheet.swift @@ -217,7 +217,9 @@ struct CalendarFilterSheet: View { if let g = groupDetail { List { Section(header: Label(g.name, systemImage: GroupIcons.symbol(g.icon))) { - ForEach(g.members ?? []) { m in + // Only members who actually share a calendar into the group — + // avoids phantom empty rows for members who share nothing. + ForEach((g.members ?? []).filter { $0.sharesCalendar }) { m in groupRow(name: m.displayName ?? "—", colorHex: m.color ?? "#4285f4", key: CalendarStore.groupMemberKey(m.id)) diff --git a/Calendarr iOS/Views/SettingsView.swift b/Calendarr iOS/Views/SettingsView.swift index 97caa2b..3cdde7a 100644 --- a/Calendarr iOS/Views/SettingsView.swift +++ b/Calendarr iOS/Views/SettingsView.swift @@ -32,6 +32,7 @@ struct SettingsView: View { @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 = "" var body: some View { @@ -102,6 +103,13 @@ struct SettingsView: View { .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) @@ -188,6 +196,7 @@ struct SettingsView: View { displayName = p.displayName ?? p.username loginName = p.username email = p.email ?? "" + directoryHidden = p.directoryHidden } if let s = try? await api.getSettings() { privateVisibility = s.privateEventVisibility @@ -202,7 +211,8 @@ struct SettingsView: View { do { _ = try await api.updateProfile(displayName: displayName.isEmpty ? nil : displayName, username: nil, - email: email.isEmpty ? "" : email) + email: email.isEmpty ? "" : email, + directoryHidden: directoryHidden) UserDefaults.standard.set(displayName, forKey: "displayName") profileMsg = L10n.t("settings.saved", appLang) } catch { From c4195243c001fa1981957d0bec59cb9a478e19de Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 6 Jul 2026 20:57:53 +0200 Subject: [PATCH 49/90] feat(sharing): let share recipients recolour a shared calendar The colour dot for a shared (non-owned) local calendar was disabled and the colour write hit the owner-only PUT /calendars/{id} (404 for recipients). The dot is now always editable and colour changes go through the dedicated PUT /calendars/{id}/color endpoint, storing a recipient's own per-user colour server-side (synced across devices) without granting write or rename. Renaming stays owner-only. Parity with Android + server. Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Services/CalendarrAPI.swift | 5 ++++- Calendarr iOS/Views/AccountsView.swift | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Calendarr iOS/Services/CalendarrAPI.swift b/Calendarr iOS/Services/CalendarrAPI.swift index 7b8d2a3..444f6b5 100644 --- a/Calendarr iOS/Services/CalendarrAPI.swift +++ b/Calendarr iOS/Services/CalendarrAPI.swift @@ -426,8 +426,11 @@ class CalendarrAPI { // 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)", method: "PUT", body: ["color": color]) + _ = try await request("/api/local/calendars/\(id)/color", method: "PUT", body: ["color": color]) } func updateICalColor(id: Int, color: String) async throws { diff --git a/Calendarr iOS/Views/AccountsView.swift b/Calendarr iOS/Views/AccountsView.swift index 06beb11..99b005c 100644 --- a/Calendarr iOS/Views/AccountsView.swift +++ b/Calendarr iOS/Views/AccountsView.swift @@ -169,7 +169,9 @@ struct AccountsView: View { } else { ForEach(localCalendars) { cal in HStack { - CalendarColorDot(hex: cal.color, editable: cal.owned) { hex in + // Recipients of a shared calendar may recolour it (their + // own per-user colour); renaming stays owner-only. + CalendarColorDot(hex: cal.color, editable: true) { hex in try? await api.updateLocalCalendarColor(id: cal.id, color: hex) } Text(cal.name) From e55e6c7c9246afdcecf7c14abf76304191a7ba53 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 8 Jul 2026 05:15:58 +0200 Subject: [PATCH 50/90] feat(ios): toggle month view between scroll feed and paged swipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New device-local setting "Monatsansicht seitenweise wischen" (@AppStorage, no server sync). Off keeps the continuous vertical scroll feed; on switches the month view to a paged TabView — one month per screen, six height-filling week rows, swipe left/right to change month (bounded ±monthsBack/Ahead range to keep the TabView light). WeekRow gains a fillHeight flag so the same row renders at a fixed height (scroll) or fills the page evenly (paged); its inner cells/divider now size to the actual geometry. Paged selection is two-way bound to the store's current date so prev/next/today and the title stay in sync. Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/Localization.swift | 2 + Calendarr iOS/Views/Calendar/MonthView.swift | 146 ++++++++++++++----- Calendarr iOS/Views/SettingsView.swift | 4 + 3 files changed, 113 insertions(+), 39 deletions(-) diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index bcabbae..629601f 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -127,6 +127,7 @@ private let strings: [String: [String: String]] = [ "settings.monday": "Montag", "settings.sunday": "Sonntag", "settings.dimpast": "Vergangene Termine ausgrauen", + "settings.month_paged": "Monatsansicht seitenweise wischen", "settings.default_duration": "Standard-Termindauer", "settings.nav.profile": "Profil", "settings.privacy": "Privatsphäre", @@ -458,6 +459,7 @@ private let strings: [String: [String: String]] = [ "settings.monday": "Monday", "settings.sunday": "Sunday", "settings.dimpast": "Dim past events", + "settings.month_paged": "Swipe month view as pages", "settings.default_duration": "Default event duration", "settings.nav.profile": "Profile", "settings.privacy": "Privacy", diff --git a/Calendarr iOS/Views/Calendar/MonthView.swift b/Calendarr iOS/Views/Calendar/MonthView.swift index 24ba3b7..ba764be 100644 --- a/Calendarr iOS/Views/Calendar/MonthView.swift +++ b/Calendarr iOS/Views/Calendar/MonthView.swift @@ -4,6 +4,9 @@ import SwiftUI // 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 @@ -26,12 +29,28 @@ struct MonthView: View { @AppStorage("textColor") private var textHex = "#FFFFFF" @AppStorage("lineColor") private var lineHex = "#3A3A3C" @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))! @@ -51,46 +70,92 @@ struct MonthView: View { VStack(spacing: 0) { headerRow Divider() - ScrollView { - LazyVStack(spacing: 0) { ForEach(weekStarts, id: \.self) { ws in - WeekRow(weekStart: ws, - store: store, - dividerColor: Color(hex: dividerHex), - labelColor: Color(hex: labelHex), - textColor: Color(hex: textHex), - lineColor: Color(hex: lineHex), - language: appLang, - onDayTap: onDayTap, - onEventTap: onEventTap, - onCreateEvent: onCreateEvent, - onShowWeek: onShowWeek, - onShowDay: onShowDay) - .id(ws) - } - } - .scrollTargetLayout() - } - .scrollIndicators(.hidden) - .scrollPosition(id: $scrolledWeek, anchor: .top) - .onAppear { - if !didInitialScroll { - didInitialScroll = true - scrolledWeek = weekStart(for: store.currentDate) - publishVisibleMonth(from: scrolledWeek) + 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) } } - .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) + .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 { @@ -126,6 +191,8 @@ struct MonthView: View { 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 @@ -252,7 +319,7 @@ private struct WeekRow: View { onCreateEvent: { onCreateEvent(day) }, onShowWeek: { onShowWeek(day) }, onShowDay: { onShowDay(day) }) - .frame(width: cellW, height: rowHeight) + .frame(width: cellW, height: geo.size.height) } } @@ -271,12 +338,13 @@ private struct WeekRow: View { if let b = midRowBoundaryCol { Rectangle() .fill(dividerColor) - .frame(width: 1.5, height: rowHeight) + .frame(width: 1.5, height: geo.size.height) .offset(x: CGFloat(b) * cellW - 0.75, y: 0) } } } - .frame(height: rowHeight) + .frame(height: fillHeight ? nil : rowHeight) + .frame(maxHeight: fillHeight ? .infinity : nil) } } diff --git a/Calendarr iOS/Views/SettingsView.swift b/Calendarr iOS/Views/SettingsView.swift index 3cdde7a..22be943 100644 --- a/Calendarr iOS/Views/SettingsView.swift +++ b/Calendarr iOS/Views/SettingsView.swift @@ -22,6 +22,8 @@ struct SettingsView: View { @AppStorage("defaultView") private var defaultView = "month" @AppStorage("weekStartDay") private var weekStartDay = "monday" @AppStorage("dimPastEvents") private var dimPastEvents = false + // Device-local: month view as horizontal paged (swipe) vs. scroll feed. + @AppStorage("monthViewPaged") private var monthViewPaged = false @AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1 @AppStorage("defaultEventDurationMinutes") private var defaultEventDurationMinutes = 60 @@ -387,6 +389,8 @@ struct SettingsView: View { } Toggle(L10n.t("settings.dimpast", appLang), isOn: $dimPastEvents) .tint(Color.accentColor) + Toggle(L10n.t("settings.month_paged", appLang), isOn: $monthViewPaged) + .tint(Color.accentColor) } } From cf990e42798c52e1b90bb5d53f3c6481b8bf65c2 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 12 Jul 2026 21:58:31 +0200 Subject: [PATCH 51/90] Add birthday feature (iOS) - LocalCalendar/CalEvent gain is_birthday + birthday fields; API sends rrule/external_uid/birth_year and can create/update birthday calendars - BirthdaysImporter: mirrors Contacts birthdays into a chosen birthday calendar, reconciling by external_uid (leaves manual entries untouched) - AccountsView: "birthday calendar" toggle + notify-days when creating a calendar, and a "Birthdays from Contacts" section (enable + target + sync now) - FAB long-press menu -> "New birthday" opens a minimal name+date mask (BirthdayEditorSheet) targeting birthday calendars, with year-unknown option - Event bars render display_title (age) and a cake icon via EventLabel - NSContactsUsageDescription added to both app build configs Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS.xcodeproj/project.pbxproj | 10 +- Calendarr iOS/Models/AppSettings.swift | 8 + Calendarr iOS/Models/CalEvent.swift | 28 ++- Calendarr iOS/Models/Localization.swift | 46 +++++ .../Services/BirthdaysImporter.swift | 168 ++++++++++++++++++ Calendarr iOS/Services/CalendarrAPI.swift | 41 ++++- Calendarr iOS/Views/AccountsView.swift | 92 +++++++++- Calendarr iOS/Views/Calendar/AgendaView.swift | 2 +- .../Views/Calendar/BirthdayEditorSheet.swift | 104 +++++++++++ .../Views/Calendar/CalendarHostView.swift | 31 ++++ Calendarr iOS/Views/Calendar/DayView.swift | 2 +- .../Views/Calendar/EventDetailSheet.swift | 4 +- Calendarr iOS/Views/Calendar/MonthView.swift | 6 +- .../Views/Calendar/TimeGridView.swift | 2 +- Calendarr iOS/Views/Calendar/WeekView.swift | 2 +- 15 files changed, 527 insertions(+), 19 deletions(-) create mode 100644 Calendarr iOS/Services/BirthdaysImporter.swift create mode 100644 Calendarr iOS/Views/Calendar/BirthdayEditorSheet.swift diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index 7b0fb67..4f81040 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -491,7 +491,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = PP34X97WS3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -500,6 +500,7 @@ INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES; + INFOPLIST_KEY_NSContactsUsageDescription = "Calendarr liest Geburtstage aus deinen Kontakten, um sie in deinen Geburtstagskalender zu übernehmen."; INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -511,7 +512,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.9; + MARKETING_VERSION = 3.0; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -535,7 +536,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = PP34X97WS3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -544,6 +545,7 @@ INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsLocalNetworking = YES; + INFOPLIST_KEY_NSContactsUsageDescription = "Calendarr liest Geburtstage aus deinen Kontakten, um sie in deinen Geburtstagskalender zu übernehmen."; INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -555,7 +557,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.9; + MARKETING_VERSION = 3.0; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/Calendarr iOS/Models/AppSettings.swift b/Calendarr iOS/Models/AppSettings.swift index 2f3e7c3..3e57518 100644 --- a/Calendarr iOS/Models/AppSettings.swift +++ b/Calendarr iOS/Models/AppSettings.swift @@ -114,11 +114,17 @@ struct LocalCalendar: Codable, Identifiable { 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 { @@ -132,6 +138,8 @@ struct LocalCalendar: Codable, Identifiable { 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) } } diff --git a/Calendarr iOS/Models/CalEvent.swift b/Calendarr iOS/Models/CalEvent.swift index e53f30a..94ae950 100644 --- a/Calendarr iOS/Models/CalEvent.swift +++ b/Calendarr iOS/Models/CalEvent.swift @@ -67,10 +67,17 @@ struct CalEvent: Identifiable, Hashable { 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, @@ -110,7 +117,8 @@ struct CalEvent: Identifiable, Hashable { 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 + readOnly: json["read_only"] as? Bool ?? false, + isBirthday: json["is_birthday"] as? Bool ?? false ) } } @@ -174,3 +182,21 @@ func formatISO(_ date: Date, allDay: Bool) -> String { } 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) + } + } +} diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index 629601f..811fdff 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -345,6 +345,29 @@ private let strings: [String: [String: String]] = [ "local.color": "Farbe", "local.create": "Erstellen", + // Birthdays + "birthday.new": "Neuer Geburtstag", + "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", @@ -677,6 +700,29 @@ private let strings: [String: [String: String]] = [ "local.color": "Color", "local.create": "Create", + // Birthdays + "birthday.new": "New birthday", + "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", diff --git a/Calendarr iOS/Services/BirthdaysImporter.swift b/Calendarr iOS/Services/BirthdaysImporter.swift new file mode 100644 index 0000000..8458c5c --- /dev/null +++ b/Calendarr iOS/Services/BirthdaysImporter.swift @@ -0,0 +1,168 @@ +import Foundation +import Contacts + +/// 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 a chosen +/// birthday `LocalCalendar` on the backend, so they show on every client. +/// +/// The sync is a **mirror**: it reconciles contact-sourced rows by +/// `external_uid` (adds new, updates changed, deletes removed) and never touches +/// manually added birthdays (which have no `external_uid`). The heavy lifting — +/// age suffix, cake icon, "notify N days before" reminder — is done server-side; +/// this only uploads name + date + birth year. +enum BirthdaysImporter { + + // MARK: – Persisted binding (which calendar receives Contacts birthdays) + + enum Key { + static let enabled = "birthdaysSyncEnabled" // Bool + static let calendarId = "birthdaysSyncCalendarId" // Int (0 = none) + } + + static var isEnabled: Bool { UserDefaults.standard.bool(forKey: Key.enabled) } + static var targetCalendarId: Int? { + let v = UserDefaults.standard.object(forKey: Key.calendarId) as? Int ?? 0 + return v > 0 ? v : nil + } + static func setEnabled(_ on: Bool) { UserDefaults.standard.set(on, forKey: Key.enabled) } + static func setTargetCalendarId(_ id: Int?) { + UserDefaults.standard.set(id ?? 0, forKey: Key.calendarId) + } + + // 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 + } + + /// Request Contacts access once. Returns true if usable for enumeration. + @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 externalUid: String // "contact:" + let name: String + let month: Int + let day: Int + let year: Int? // nil = year unknown + } + + 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( + externalUid: "contact:\(contact.identifier)", + name: name, month: m, day: d, year: year + )) + } + return out + } + + // MARK: – Sync + + /// Mirror the address book into the bound birthday calendar. No-op unless the + /// sync is enabled, a target calendar is set, and access is granted. + static func sync(api: CalendarrAPI) async { + guard isEnabled, let calId = targetCalendarId else { return } + guard await requestAccess() else { return } + guard let contacts = try? readContactBirthdays() else { return } + guard let existing = try? await api.getBirthdayEntries(calendarId: calId) else { return } + + // Only reconcile contact-sourced rows; leave manual entries untouched. + var byExt: [String: BirthdayEntry] = [:] + for e in existing { if let ext = e.externalUid { byExt[ext] = e } } + + var seen = Set() + for c in contacts { + seen.insert(c.externalUid) + let (start, end) = allDayRange(month: c.month, day: c.day, year: c.year) + if let match = byExt[c.externalUid] { + 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: c.externalUid, + birthYear: c.year ?? -1 // -1 clears birth_year server-side + ) + } + } else { + _ = try? await api.createLocalEvent( + calendarId: calId, title: c.name, start: start, end: end, + isAllDay: true, location: "", description: "", color: nil, + rrule: "FREQ=YEARLY", externalUid: c.externalUid, birthYear: c.year + ) + } + } + // Remove contact-sourced rows whose contact no longer has a birthday. + for (ext, entry) in byExt where !seen.contains(ext) { + try? await api.deleteLocalEvent(uid: entry.uid) + } + } + + /// 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) + } +} diff --git a/Calendarr iOS/Services/CalendarrAPI.swift b/Calendarr iOS/Services/CalendarrAPI.swift index 444f6b5..a52d95c 100644 --- a/Calendarr iOS/Services/CalendarrAPI.swift +++ b/Calendarr iOS/Services/CalendarrAPI.swift @@ -135,8 +135,13 @@ class CalendarrAPI { return (try? JSONDecoder().decode([LocalCalendar].self, from: data)) ?? [] } - func addLocalCalendar(name: String, color: String) async throws -> LocalCalendar { - let data = try await request("/api/local/calendars", method: "POST", body: ["name": name, "color": color]) + 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 } @@ -145,6 +150,24 @@ class CalendarrAPI { _ = 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?, notifyDaysBefore: Int??) 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 } + 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)) ?? [] + } + func getICalSubscriptions() async throws -> [ICalSubscription] { let data = try await request("/api/ical/subscriptions") return (try? JSONDecoder().decode([ICalSubscription].self, from: data)) ?? [] @@ -236,7 +259,9 @@ class CalendarrAPI { func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date, isAllDay: Bool, location: String, description: String, color: String?, - isPrivate: Bool = false, reminders: [Int]? = nil) async throws -> CalEvent { + isPrivate: Bool = false, reminders: [Int]? = nil, + rrule: String? = nil, externalUid: String? = nil, + birthYear: Int? = nil) async throws -> CalEvent { var body: [String: Any] = [ "calendar_id": calendarId, "title": title, @@ -249,6 +274,9 @@ class CalendarrAPI { ] 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 } @@ -257,7 +285,9 @@ class CalendarrAPI { func updateLocalEvent(uid: String, title: String, start: Date, end: Date, isAllDay: Bool, location: String, description: String, color: String?, - isPrivate: Bool = false, reminders: [Int]? = nil) async throws { + isPrivate: Bool = false, reminders: [Int]? = nil, + rrule: String? = nil, externalUid: String? = nil, + birthYear: Int? = nil) async throws { var body: [String: Any] = [ "title": title, "start": formatISO(start, allDay: isAllDay), @@ -269,6 +299,9 @@ class CalendarrAPI { ] 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) } diff --git a/Calendarr iOS/Views/AccountsView.swift b/Calendarr iOS/Views/AccountsView.swift index 99b005c..2aafd83 100644 --- a/Calendarr iOS/Views/AccountsView.swift +++ b/Calendarr iOS/Views/AccountsView.swift @@ -24,6 +24,11 @@ struct AccountsView: View { @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 + @AppStorage("birthdaysSyncCalendarId") private var birthdaysSyncCalendarId = 0 + @State private var isSyncingBirthdays = false + @AppStorage("appLanguage") private var appLang = "system" var body: some View { @@ -36,10 +41,14 @@ struct AccountsView: View { if !banishedKeys.isEmpty { banishedSection } caldavSection localSection + birthdayContactsSection icalSection googleSection haSection } + .onChange(of: birthdaysSyncEnabled) { _, on in + if on { Task { _ = await BirthdaysImporter.requestAccess() } } + } } } .navigationTitle(L10n.t("accounts.title", appLang)) @@ -213,6 +222,52 @@ struct AccountsView: View { } } + private var birthdayCalendars: [LocalCalendar] { + localCalendars.filter { $0.isBirthday && ($0.owned || $0.permission == "read_write") } + } + + @ViewBuilder var birthdayContactsSection: some View { + Section { + Toggle(L10n.t("birthday.contacts.sync", appLang), isOn: $birthdaysSyncEnabled) + if birthdaysSyncEnabled { + if birthdayCalendars.isEmpty { + Text(L10n.t("birthday.contacts.need_calendar", appLang)) + .font(.caption).foregroundStyle(.secondary) + } else { + Picker(L10n.t("birthday.contacts.target", appLang), selection: $birthdaysSyncCalendarId) { + ForEach(birthdayCalendars) { c in Text(c.name).tag(c.id) } + } + Button { + Task { await syncBirthdays() } + } label: { + HStack { + Text(L10n.t("birthday.contacts.sync_now", appLang)) + if isSyncingBirthdays { Spacer(); ProgressView() } + } + } + .disabled(isSyncingBirthdays || birthdaysSyncCalendarId == 0) + } + } + } header: { + Text(L10n.t("birthday.contacts.header", appLang)) + } footer: { + Text(L10n.t("birthday.contacts.hint", appLang)) + } + } + + 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) + 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 { @@ -419,6 +474,10 @@ struct AccountsView: View { CalendarStore.saveBanishedKeys(b) NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil) } + // Default the Contacts-sync target to the first birthday calendar. + if birthdaysSyncCalendarId == 0, let first = birthdayCalendars.first?.id { + birthdaysSyncCalendarId = first + } isLoading = false } @@ -536,6 +595,8 @@ struct AddLocalCalSheet: View { @State private var name = "" @State private var color = Color(hex: "#34a853") + @State private var isBirthday = false + @State private var notifyDays = -1 // -1 = off, 0 = on the day, N = days before @State private var isLoading = false @State private var error = "" @@ -546,6 +607,14 @@ struct AddLocalCalSheet: View { TextField(L10n.t("local.name", appLang), text: $name) ColorPicker(L10n.t("local.color", appLang), selection: $color, supportsOpacity: false) } + Section { + Toggle(L10n.t("birthday.is_calendar", appLang), isOn: $isBirthday) + if isBirthday { + BirthdayNotifyPicker(days: $notifyDays, appLang: appLang) + } + } footer: { + if isBirthday { Text(L10n.t("birthday.is_calendar.desc", appLang)) } + } if !error.isEmpty { Section { Text(error).foregroundStyle(.red) } } @@ -568,7 +637,10 @@ struct AddLocalCalSheet: View { private func save() async { isLoading = true do { - _ = try await api.addLocalCalendar(name: name, color: color.toHex()) + _ = try await api.addLocalCalendar( + name: name, color: color.toHex(), + isBirthday: isBirthday, + birthdayNotifyDaysBefore: (isBirthday && notifyDays >= 0) ? notifyDays : nil) await onDone() dismiss() } catch { self.error = error.localizedDescription } @@ -576,6 +648,24 @@ struct AddLocalCalSheet: View { } } +/// Reusable "notify N days before" picker for birthday calendars. +/// -1 = off, 0 = on the day, N = N days before. +struct BirthdayNotifyPicker: View { + @Binding var days: Int + let appLang: String + + var body: some View { + Picker(L10n.t("birthday.notify", appLang), selection: $days) { + Text(L10n.t("birthday.notify.off", appLang)).tag(-1) + Text(L10n.t("birthday.notify.same_day", appLang)).tag(0) + Text(L10n.t("birthday.notify.one_day", appLang)).tag(1) + ForEach([2, 3, 7], id: \.self) { d in + Text(String(format: L10n.t("birthday.notify.days", appLang), d)).tag(d) + } + } + } +} + struct AddICalSheet: View { let api: CalendarrAPI let onDone: () async -> Void diff --git a/Calendarr iOS/Views/Calendar/AgendaView.swift b/Calendarr iOS/Views/Calendar/AgendaView.swift index 081650d..fe57864 100644 --- a/Calendarr iOS/Views/Calendar/AgendaView.swift +++ b/Calendarr iOS/Views/Calendar/AgendaView.swift @@ -79,7 +79,7 @@ private struct AgendaEventRow: View { .frame(width: 4, height: 40) VStack(alignment: .leading, spacing: 3) { - Text(event.title) + EventLabel(event: event) .font(.body.weight(.medium)) .foregroundStyle(.primary) HStack(spacing: 6) { diff --git a/Calendarr iOS/Views/Calendar/BirthdayEditorSheet.swift b/Calendarr iOS/Views/Calendar/BirthdayEditorSheet.swift new file mode 100644 index 0000000..c54293b --- /dev/null +++ b/Calendarr iOS/Views/Calendar/BirthdayEditorSheet.swift @@ -0,0 +1,104 @@ +import SwiftUI + +/// Minimal "new birthday" mask: pick a birthday calendar, enter a name and a +/// date (day + month, optionally a year). Saves an all-day, yearly-recurring +/// local event; the server adds the age suffix and cake icon on read. +struct BirthdayEditorSheet: View { + let api: CalendarrAPI + var onDone: () async -> Void + + @AppStorage("appLanguage") private var appLang = "system" + @Environment(\.dismiss) private var dismiss + + @State private var calendars: [LocalCalendar] = [] + @State private var selectedCalId: Int? = 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 + + private var birthdayCalendars: [LocalCalendar] { + calendars.filter { $0.isBirthday && ($0.owned || $0.permission == "read_write") } + } + + private var canSave: Bool { + !saving && selectedCalId != nil + && !name.trimmingCharacters(in: .whitespaces).isEmpty + } + + var body: some View { + NavigationStack { + Form { + if loading { + HStack { Spacer(); ProgressView(); Spacer() } + } else if birthdayCalendars.isEmpty { + Text(L10n.t("birthday.no_calendars", appLang)) + .foregroundStyle(.secondary) + } 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) + } + if birthdayCalendars.count > 1 { + Section(L10n.t("birthday.contacts.target", appLang)) { + Picker(L10n.t("birthday.contacts.target", appLang), selection: $selectedCalId) { + ForEach(birthdayCalendars) { c in + Text(c.name).tag(Optional(c.id)) + } + } + } + } + } + } + .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 { + calendars = (try? await api.getLocalCalendars()) ?? [] + if selectedCalId == nil { selectedCalId = birthdayCalendars.first?.id } + loading = false + } + + private func save() async { + guard let calId = selectedCalId else { return } + saving = true + let cal = Calendar.current + let comps = cal.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 = cal.date(from: anchor) ?? date + let end = cal.date(byAdding: .day, value: 1, to: start) ?? start + _ = try? await api.createLocalEvent( + calendarId: calId, title: name.trimmingCharacters(in: .whitespaces), + start: start, end: end, isAllDay: true, location: "", description: "", + color: nil, rrule: "FREQ=YEARLY", birthYear: year + ) + await onDone() + dismiss() + } +} diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index d674889..9e1b645 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -30,6 +30,7 @@ struct CalendarHostView: View { @State private var showFilter = false @State private var didApplyDefaultView = false @State private var groups: [CalGroup] = [] + @State private var showNewBirthday = false private var titleString: String { if store.viewType == .month { @@ -380,6 +381,21 @@ struct CalendarHostView: View { // 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 { @@ -393,6 +409,7 @@ struct CalendarHostView: View { .clipShape(Circle()) .shadow(radius: 4, y: 2) } + .contextMenu { fabMenu } .padding(.trailing, 20).padding(.bottom, 20) } @@ -410,6 +427,7 @@ struct CalendarHostView: View { } .buttonStyle(.plain) .glassEffect(in: Circle()) + .contextMenu { fabMenu } .padding(.trailing, 20).padding(.bottom, 20) } else { solidFAB @@ -421,6 +439,7 @@ struct CalendarHostView: View { 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) }) @@ -449,6 +468,14 @@ struct CalendarHostView: View { 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)) @@ -538,12 +565,16 @@ private struct CalendarSheets: ViewModifier { @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. diff --git a/Calendarr iOS/Views/Calendar/DayView.swift b/Calendarr iOS/Views/Calendar/DayView.swift index d72664a..e78064c 100644 --- a/Calendarr iOS/Views/Calendar/DayView.swift +++ b/Calendarr iOS/Views/Calendar/DayView.swift @@ -81,7 +81,7 @@ struct DayView: View { HStack(spacing: 6) { ForEach(allDayEvents) { ev in Button(action: { onEventTap(ev) }) { - Text(ev.title) + EventLabel(event: ev) .font(.caption.weight(.medium)) .foregroundStyle(.white) .padding(.horizontal, 8).padding(.vertical, 4) diff --git a/Calendarr iOS/Views/Calendar/EventDetailSheet.swift b/Calendarr iOS/Views/Calendar/EventDetailSheet.swift index 9816188..7e80c0f 100644 --- a/Calendarr iOS/Views/Calendar/EventDetailSheet.swift +++ b/Calendarr iOS/Views/Calendar/EventDetailSheet.swift @@ -62,7 +62,7 @@ struct EventDetailSheet: View { .fill(Color(hex: event.effectiveColor)) .frame(width: 6, height: 44) VStack(alignment: .leading, spacing: 4) { - Text(event.title) + Text(event.renderTitle) .font(.title3.bold()) Text(event.calendarName) .font(.caption) @@ -156,7 +156,7 @@ struct EventDetailSheet: View { } Button(L10n.t("common.cancel", appLang), role: .cancel) {} } message: { - Text("\"\(event.title)\" \(L10n.t("detail.delete_msg_suffix", appLang))") + Text("\"\(event.renderTitle)\" \(L10n.t("detail.delete_msg_suffix", appLang))") } .sheet(isPresented: $showCopySheet) { EventEditorSheet( diff --git a/Calendarr iOS/Views/Calendar/MonthView.swift b/Calendarr iOS/Views/Calendar/MonthView.swift index ba764be..c662b97 100644 --- a/Calendarr iOS/Views/Calendar/MonthView.swift +++ b/Calendarr iOS/Views/Calendar/MonthView.swift @@ -519,7 +519,7 @@ private struct DayPreviewAllDayBar: View { 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 - Text(event.title) + EventLabel(event: event) .font(.system(size: 11, weight: .medium)) .foregroundStyle(.white) .lineLimit(1) @@ -580,7 +580,7 @@ private struct DayPreviewTimedRow: View { .font(.system(size: 11)) .foregroundStyle(.secondary) .frame(width: 44, alignment: .leading) - Text(event.title) + EventLabel(event: event) .font(.system(size: 11, weight: .medium)) .lineLimit(1) Spacer(minLength: 0) @@ -604,7 +604,7 @@ private struct EventBar: View { var body: some View { HStack(spacing: 3) { - Text(event.title) + EventLabel(event: event) .font(.system(size: 10, weight: .medium)) .lineLimit(1) .foregroundStyle(.white) diff --git a/Calendarr iOS/Views/Calendar/TimeGridView.swift b/Calendarr iOS/Views/Calendar/TimeGridView.swift index 7cbafc3..b3242ce 100644 --- a/Calendarr iOS/Views/Calendar/TimeGridView.swift +++ b/Calendarr iOS/Views/Calendar/TimeGridView.swift @@ -74,7 +74,7 @@ struct EventBlock: View { .fill(Color(hex: event.effectiveColor).opacity(0.85)) .overlay(alignment: .topLeading) { VStack(alignment: .leading, spacing: 1) { - Text(event.title) + EventLabel(event: event) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.white) .lineLimit(2) diff --git a/Calendarr iOS/Views/Calendar/WeekView.swift b/Calendarr iOS/Views/Calendar/WeekView.swift index 19c53e5..8fd8a98 100644 --- a/Calendarr iOS/Views/Calendar/WeekView.swift +++ b/Calendarr iOS/Views/Calendar/WeekView.swift @@ -84,7 +84,7 @@ struct WeekView: View { VStack(spacing: 1) { ForEach(dayEvs.prefix(2)) { ev in Button { onEventTap(ev) } label: { - Text(ev.title) + EventLabel(event: ev) .font(.system(size: 9, weight: .medium)) .foregroundStyle(.white) .lineLimit(1) From 871fb833ba8cbfb6c77d75aa6660a55984804cdd Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 13 Jul 2026 17:18:34 +0200 Subject: [PATCH 52/90] chore: sync Xcode project structure (Shared group + widget embedding) Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index 4f81040..d6dac41 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -512,7 +512,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.0; + MARKETING_VERSION = 3.5; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -557,7 +557,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.0; + MARKETING_VERSION = 3.5; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; From 69ed8bf67ddcf358ff029648c43289bf869f5fa4 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 13 Jul 2026 19:07:46 +0200 Subject: [PATCH 53/90] Birthdays: single calendar model + sync on server-sync (iOS) - one birthday calendar per user; BirthdaysImporter auto-creates/targets it (no picker) and reconciles per-device (external_uid contact::) so multiple devices don't clash - Contacts birthday sync now runs on every "Sync with server" (syncFromServer), not just app launch and the manual button - report the device after each sync (POST /api/birthdays/sync-report) for the web device list - Accounts: Birthdays-from-Contacts section drops the target picker, keeps the reminder picker; removed the birthday toggle from the generic new-calendar sheet - New-birthday sheet targets the single calendar, offers activation if none - exclude the birthday calendar from the group-visible ("shared calendar") picker Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/Localization.swift | 6 ++ .../Services/BirthdaysImporter.swift | 101 ++++++++++++------ Calendarr iOS/Services/CalendarrAPI.swift | 12 ++- Calendarr iOS/Views/AccountsView.swift | 65 +++++------ .../Views/Calendar/BirthdayEditorSheet.swift | 57 +++++----- .../Views/Calendar/CalendarHostView.swift | 3 + Calendarr iOS/Views/SettingsView.swift | 4 +- 7 files changed, 144 insertions(+), 104 deletions(-) diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index 811fdff..6bd6176 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -347,6 +347,9 @@ private let strings: [String: [String: String]] = [ // 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", @@ -702,6 +705,9 @@ private let strings: [String: [String: String]] = [ // 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", diff --git a/Calendarr iOS/Services/BirthdaysImporter.swift b/Calendarr iOS/Services/BirthdaysImporter.swift index 8458c5c..471ca81 100644 --- a/Calendarr iOS/Services/BirthdaysImporter.swift +++ b/Calendarr iOS/Services/BirthdaysImporter.swift @@ -1,5 +1,6 @@ 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`. @@ -19,31 +20,41 @@ struct BirthdayEntry: Codable, Identifiable { } } -/// Reads birthdays from the system Contacts and mirrors them into a chosen -/// birthday `LocalCalendar` on the backend, so they show on every client. +/// 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**: it reconciles contact-sourced rows by -/// `external_uid` (adds new, updates changed, deletes removed) and never touches -/// manually added birthdays (which have no `external_uid`). The heavy lifting — -/// age suffix, cake icon, "notify N days before" reminder — is done server-side; -/// this only uploads name + date + birth year. +/// The sync is a **mirror**, scoped to THIS device: it reconciles rows whose +/// `external_uid` starts with `contact::` (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 binding (which calendar receives Contacts birthdays) + // MARK: – Persisted state enum Key { - static let enabled = "birthdaysSyncEnabled" // Bool - static let calendarId = "birthdaysSyncCalendarId" // Int (0 = none) + static let enabled = "birthdaysSyncEnabled" // Bool + static let deviceId = "birthdaysDeviceId" // stable per-install UUID } static var isEnabled: Bool { UserDefaults.standard.bool(forKey: Key.enabled) } - static var targetCalendarId: Int? { - let v = UserDefaults.standard.object(forKey: Key.calendarId) as? Int ?? 0 - return v > 0 ? v : nil - } static func setEnabled(_ on: Bool) { UserDefaults.standard.set(on, forKey: Key.enabled) } - static func setTargetCalendarId(_ id: Int?) { - UserDefaults.standard.set(id ?? 0, forKey: Key.calendarId) + + 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 @@ -55,7 +66,6 @@ enum BirthdaysImporter { return false } - /// Request Contacts access once. Returns true if usable for enumeration. @discardableResult static func requestAccess() async -> Bool { let status = CNContactStore.authorizationStatus(for: .contacts) @@ -72,11 +82,11 @@ enum BirthdaysImporter { // MARK: – Reading contact birthdays struct ContactBirthday { - let externalUid: String // "contact:" + let contactId: String let name: String let month: Int let day: Int - let year: Int? // nil = year unknown + let year: Int? } static func readContactBirthdays() throws -> [ContactBirthday] { @@ -101,54 +111,77 @@ enum BirthdaysImporter { guard !name.isEmpty else { return } let year = bday.year.flatMap { $0 > 0 ? $0 : nil } out.append(ContactBirthday( - externalUid: "contact:\(contact.identifier)", - name: name, month: m, day: d, year: year + 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 bound birthday calendar. No-op unless the - /// sync is enabled, a target calendar is set, and access is granted. + /// 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, let calId = targetCalendarId else { return } + guard isEnabled else { return } guard await requestAccess() else { return } guard let contacts = try? readContactBirthdays() else { return } - guard let existing = try? await api.getBirthdayEntries(calendarId: calId) else { return } + guard let cal = await ensureBirthdayCalendar(api: api) else { return } + guard let existing = try? await api.getBirthdayEntries(calendarId: cal.id) else { return } - // Only reconcile contact-sourced rows; leave manual entries untouched. + // 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 { byExt[ext] = e } } + for e in existing { + if let ext = e.externalUid, ext.hasPrefix(prefix) { byExt[ext] = e } + } var seen = Set() for c in contacts { - seen.insert(c.externalUid) + 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[c.externalUid] { + 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: c.externalUid, - birthYear: c.year ?? -1 // -1 clears birth_year server-side + rrule: "FREQ=YEARLY", externalUid: ext, birthYear: c.year ?? -1 ) } } else { _ = try? await api.createLocalEvent( - calendarId: calId, title: c.name, start: start, end: end, + calendarId: cal.id, title: c.name, start: start, end: end, isAllDay: true, location: "", description: "", color: nil, - rrule: "FREQ=YEARLY", externalUid: c.externalUid, birthYear: c.year + rrule: "FREQ=YEARLY", externalUid: ext, birthYear: c.year ) } } - // Remove contact-sourced rows whose contact no longer has a birthday. 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, diff --git a/Calendarr iOS/Services/CalendarrAPI.swift b/Calendarr iOS/Services/CalendarrAPI.swift index a52d95c..3d4dd03 100644 --- a/Calendarr iOS/Services/CalendarrAPI.swift +++ b/Calendarr iOS/Services/CalendarrAPI.swift @@ -153,10 +153,10 @@ class CalendarrAPI { /// 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?, notifyDaysBefore: Int??) async throws { + 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 } + 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) } @@ -168,6 +168,14 @@ class CalendarrAPI { 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)) ?? [] diff --git a/Calendarr iOS/Views/AccountsView.swift b/Calendarr iOS/Views/AccountsView.swift index 2aafd83..5ef84d1 100644 --- a/Calendarr iOS/Views/AccountsView.swift +++ b/Calendarr iOS/Views/AccountsView.swift @@ -26,8 +26,8 @@ struct AccountsView: View { // Contacts → birthday-calendar sync (opt-in, bound to one birthday calendar). @AppStorage("birthdaysSyncEnabled") private var birthdaysSyncEnabled = false - @AppStorage("birthdaysSyncCalendarId") private var birthdaysSyncCalendarId = 0 @State private var isSyncingBirthdays = false + @State private var birthdayNotify = -1 @AppStorage("appLanguage") private var appLang = "system" @@ -47,7 +47,13 @@ struct AccountsView: View { haSection } .onChange(of: birthdaysSyncEnabled) { _, on in - if on { Task { _ = await BirthdaysImporter.requestAccess() } } + if on { + Task { + _ = await BirthdaysImporter.requestAccess() + _ = await BirthdaysImporter.ensureBirthdayCalendar(api: api) + await load() + } + } } } } @@ -222,31 +228,30 @@ struct AccountsView: View { } } - private var birthdayCalendars: [LocalCalendar] { - localCalendars.filter { $0.isBirthday && ($0.owned || $0.permission == "read_write") } + /// 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 { Toggle(L10n.t("birthday.contacts.sync", appLang), isOn: $birthdaysSyncEnabled) if birthdaysSyncEnabled { - if birthdayCalendars.isEmpty { - Text(L10n.t("birthday.contacts.need_calendar", appLang)) - .font(.caption).foregroundStyle(.secondary) - } else { - Picker(L10n.t("birthday.contacts.target", appLang), selection: $birthdaysSyncCalendarId) { - ForEach(birthdayCalendars) { c in Text(c.name).tag(c.id) } - } - Button { - Task { await syncBirthdays() } - } label: { - HStack { - Text(L10n.t("birthday.contacts.sync_now", appLang)) - if isSyncingBirthdays { Spacer(); ProgressView() } + if let cal = birthdayCalendar { + BirthdayNotifyPicker(days: $birthdayNotify, appLang: appLang) + .onChange(of: birthdayNotify) { _, v in + Task { try? await api.updateLocalCalendarBirthday(id: cal.id, notifyDaysBefore: v) } } - } - .disabled(isSyncingBirthdays || birthdaysSyncCalendarId == 0) } + Button { + Task { await syncBirthdays() } + } label: { + HStack { + Text(L10n.t("birthday.contacts.sync_now", appLang)) + if isSyncingBirthdays { Spacer(); ProgressView() } + } + } + .disabled(isSyncingBirthdays) } } header: { Text(L10n.t("birthday.contacts.header", appLang)) @@ -263,6 +268,7 @@ struct AccountsView: View { 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) @@ -474,10 +480,8 @@ struct AccountsView: View { CalendarStore.saveBanishedKeys(b) NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil) } - // Default the Contacts-sync target to the first birthday calendar. - if birthdaysSyncCalendarId == 0, let first = birthdayCalendars.first?.id { - birthdaysSyncCalendarId = first - } + // Reflect the birthday calendar's current reminder setting in the picker. + birthdayNotify = birthdayCalendar?.birthdayNotifyDaysBefore ?? -1 isLoading = false } @@ -595,8 +599,6 @@ struct AddLocalCalSheet: View { @State private var name = "" @State private var color = Color(hex: "#34a853") - @State private var isBirthday = false - @State private var notifyDays = -1 // -1 = off, 0 = on the day, N = days before @State private var isLoading = false @State private var error = "" @@ -607,14 +609,6 @@ struct AddLocalCalSheet: View { TextField(L10n.t("local.name", appLang), text: $name) ColorPicker(L10n.t("local.color", appLang), selection: $color, supportsOpacity: false) } - Section { - Toggle(L10n.t("birthday.is_calendar", appLang), isOn: $isBirthday) - if isBirthday { - BirthdayNotifyPicker(days: $notifyDays, appLang: appLang) - } - } footer: { - if isBirthday { Text(L10n.t("birthday.is_calendar.desc", appLang)) } - } if !error.isEmpty { Section { Text(error).foregroundStyle(.red) } } @@ -637,10 +631,7 @@ struct AddLocalCalSheet: View { private func save() async { isLoading = true do { - _ = try await api.addLocalCalendar( - name: name, color: color.toHex(), - isBirthday: isBirthday, - birthdayNotifyDaysBefore: (isBirthday && notifyDays >= 0) ? notifyDays : nil) + _ = try await api.addLocalCalendar(name: name, color: color.toHex()) await onDone() dismiss() } catch { self.error = error.localizedDescription } diff --git a/Calendarr iOS/Views/Calendar/BirthdayEditorSheet.swift b/Calendarr iOS/Views/Calendar/BirthdayEditorSheet.swift index c54293b..dc7784b 100644 --- a/Calendarr iOS/Views/Calendar/BirthdayEditorSheet.swift +++ b/Calendarr iOS/Views/Calendar/BirthdayEditorSheet.swift @@ -1,8 +1,9 @@ import SwiftUI -/// Minimal "new birthday" mask: pick a birthday calendar, enter a name and a -/// date (day + month, optionally a year). Saves an all-day, yearly-recurring -/// local event; the server adds the age suffix and cake icon on read. +/// 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 @@ -10,20 +11,16 @@ struct BirthdayEditorSheet: View { @AppStorage("appLanguage") private var appLang = "system" @Environment(\.dismiss) private var dismiss - @State private var calendars: [LocalCalendar] = [] - @State private var selectedCalId: Int? = nil + @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 - - private var birthdayCalendars: [LocalCalendar] { - calendars.filter { $0.isBirthday && ($0.owned || $0.permission == "read_write") } - } + @State private var activating = false private var canSave: Bool { - !saving && selectedCalId != nil + !saving && calendar != nil && !name.trimmingCharacters(in: .whitespaces).isEmpty } @@ -32,9 +29,13 @@ struct BirthdayEditorSheet: View { Form { if loading { HStack { Spacer(); ProgressView(); Spacer() } - } else if birthdayCalendars.isEmpty { - Text(L10n.t("birthday.no_calendars", appLang)) - .foregroundStyle(.secondary) + } 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) @@ -44,15 +45,6 @@ struct BirthdayEditorSheet: View { displayedComponents: [.date]) Toggle(L10n.t("birthday.year_unknown", appLang), isOn: $yearUnknown) } - if birthdayCalendars.count > 1 { - Section(L10n.t("birthday.contacts.target", appLang)) { - Picker(L10n.t("birthday.contacts.target", appLang), selection: $selectedCalId) { - ForEach(birthdayCalendars) { c in - Text(c.name).tag(Optional(c.id)) - } - } - } - } } } .navigationTitle(L10n.t("birthday.new", appLang)) @@ -71,16 +63,21 @@ struct BirthdayEditorSheet: View { } private func load() async { - calendars = (try? await api.getLocalCalendars()) ?? [] - if selectedCalId == nil { selectedCalId = birthdayCalendars.first?.id } + 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 calId = selectedCalId else { return } + guard let cal = calendar else { return } saving = true - let cal = Calendar.current - let comps = cal.dateComponents([.year, .month, .day], from: date) + 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 @@ -91,10 +88,10 @@ struct BirthdayEditorSheet: View { anchor.month = month anchor.day = day anchor.hour = 12 - let start = cal.date(from: anchor) ?? date - let end = cal.date(byAdding: .day, value: 1, to: start) ?? start + let start = calc.date(from: anchor) ?? date + let end = calc.date(byAdding: .day, value: 1, to: start) ?? start _ = try? await api.createLocalEvent( - calendarId: calId, title: name.trimmingCharacters(in: .whitespaces), + calendarId: cal.id, title: name.trimmingCharacters(in: .whitespaces), start: start, end: end, isAllDay: true, location: "", description: "", color: nil, rrule: "FREQ=YEARLY", birthYear: year ) diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index 9e1b645..5412e5b 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -535,6 +535,9 @@ struct CalendarHostView: View { /// 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() } } diff --git a/Calendarr iOS/Views/SettingsView.swift b/Calendarr iOS/Views/SettingsView.swift index 22be943..74c1a79 100644 --- a/Calendarr iOS/Views/SettingsView.swift +++ b/Calendarr iOS/Views/SettingsView.swift @@ -205,7 +205,9 @@ struct SettingsView: View { groupVisibleId = s.groupVisibleCalendarId ?? 0 } if let cals = try? await api.getLocalCalendars() { - ownLocalCals = cals.filter { $0.owned && !$0.group } + // A birthday calendar may be shared directly, but never stand in as + // the group-visible personal calendar. + ownLocalCals = cals.filter { $0.owned && !$0.group && !$0.isBirthday } } } From d4fd7beaf7670664bebf187382c7a67bff8e3343 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 13 Jul 2026 21:25:06 +0200 Subject: [PATCH 54/90] Birthdays: explicit calendar creation, sync never auto-creates (iOS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Contacts sync fills only an EXISTING birthday calendar (birthdayCalendar), never auto-creates it — deleting it no longer resurrects it on sync - Accounts + menu gains "Geburtstagskalender" (only when none exists) to create the single birthday calendar explicitly - the Birthdays-from-Contacts section shows a "create one first" hint when there is no birthday calendar; enabling sync only requests Contacts access Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/Localization.swift | 2 + .../Services/BirthdaysImporter.swift | 5 +- Calendarr iOS/Views/AccountsView.swift | 49 ++++++++++++------- 3 files changed, 37 insertions(+), 19 deletions(-) diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index 6bd6176..c8ce99f 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -292,6 +292,7 @@ private let strings: [String: [String: String]] = [ "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", @@ -650,6 +651,7 @@ private let strings: [String: [String: String]] = [ "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", diff --git a/Calendarr iOS/Services/BirthdaysImporter.swift b/Calendarr iOS/Services/BirthdaysImporter.swift index 471ca81..79fcf28 100644 --- a/Calendarr iOS/Services/BirthdaysImporter.swift +++ b/Calendarr iOS/Services/BirthdaysImporter.swift @@ -141,7 +141,10 @@ enum BirthdaysImporter { guard isEnabled else { return } guard await requestAccess() else { return } guard let contacts = try? readContactBirthdays() else { return } - guard let cal = await ensureBirthdayCalendar(api: api) 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' diff --git a/Calendarr iOS/Views/AccountsView.swift b/Calendarr iOS/Views/AccountsView.swift index 5ef84d1..e66eba8 100644 --- a/Calendarr iOS/Views/AccountsView.swift +++ b/Calendarr iOS/Views/AccountsView.swift @@ -47,13 +47,9 @@ struct AccountsView: View { haSection } .onChange(of: birthdaysSyncEnabled) { _, on in - if on { - Task { - _ = await BirthdaysImporter.requestAccess() - _ = await BirthdaysImporter.ensureBirthdayCalendar(api: api) - await load() - } - } + // Enabling only asks for Contacts access — it must NOT create + // the birthday calendar. That's an explicit action. + if on { Task { _ = await BirthdaysImporter.requestAccess() } } } } } @@ -64,6 +60,12 @@ struct AccountsView: View { 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: { @@ -235,23 +237,28 @@ struct AccountsView: View { @ViewBuilder var birthdayContactsSection: some View { Section { - Toggle(L10n.t("birthday.contacts.sync", appLang), isOn: $birthdaysSyncEnabled) - if birthdaysSyncEnabled { - if let cal = birthdayCalendar { + 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() } + Button { + Task { await syncBirthdays() } + } label: { + HStack { + Text(L10n.t("birthday.contacts.sync_now", appLang)) + if isSyncingBirthdays { Spacer(); ProgressView() } + } } + .disabled(isSyncingBirthdays) } - .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)) @@ -260,6 +267,12 @@ struct AccountsView: View { } } + /// 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 } From 9a50f25f57c72286765abcca42af6133939c109f Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 14 Jul 2026 21:31:16 +0200 Subject: [PATCH 55/90] iOS: per-setting sync table with sync icons, dropdowns, colour resets Generalise the two-tier SettingsSync into a server-driven flag map: each syncable key has an account-wide flag (fetched from the server); pull applies only on-flagged keys, push (read-modify-write) sends only on-flagged keys plus the flag map. Per-row link icon toggles a setting's sync; a global switch flips all. Enabling a flag adopts this device's current value as the shared one. - AppSettings: fix bg_color mapping (was background_color), add cache_months, month_view_paged, sync_flags - SettingsView: uniform rows (sync icon | name | value), ContrastSelector and the button pickers replaced by menu dropdowns, every colour gets a reset to a canonical default, global "sync all" toggle; account settings (privacy, group calendar, hide profile) kept in their own block; language and text/line contrast kept device-local (iOS-specific) - unify default line colour to #3A3A52 across views Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/AppSettings.swift | 23 +- Calendarr iOS/Models/CalendarStore.swift | 2 +- Calendarr iOS/Models/Localization.swift | 14 + Calendarr iOS/Services/SettingsSync.swift | 260 ++++---- Calendarr iOS/Views/Calendar/DayView.swift | 4 +- Calendarr iOS/Views/Calendar/MonthView.swift | 2 +- Calendarr iOS/Views/Calendar/WeekView.swift | 4 +- Calendarr iOS/Views/SettingsView.swift | 590 +++++++++---------- 8 files changed, 468 insertions(+), 431 deletions(-) diff --git a/Calendarr iOS/Models/AppSettings.swift b/Calendarr iOS/Models/AppSettings.swift index 3e57518..caa7587 100644 --- a/Calendarr iOS/Models/AppSettings.swift +++ b/Calendarr iOS/Models/AppSettings.swift @@ -15,11 +15,14 @@ struct AppSettings: Codable { var monthLabelColor: String = "#7090c0" var textColor: String = "#FFFFFF" var backgroundColor: String = "#000000" - var lineColor: String = "#3A3A3C" + 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" @@ -35,21 +38,24 @@ struct AppSettings: Codable { case monthDividerColor = "month_divider_color" case monthLabelColor = "month_label_color" case textColor = "text_color" - case backgroundColor = "background_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: the server only stores a subset of these fields - /// (e.g. it has no `text_color`/`background_color`/`line_color`, which are - /// iOS-only). Using `decodeIfPresent` with the property defaults means a - /// missing key no longer aborts the whole decode — otherwise the entire - /// settings sync silently breaks. + /// 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() @@ -72,6 +78,9 @@ struct AppSettings: Codable { 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) } } diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index 6c63a61..41004b2 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -579,7 +579,7 @@ class CalendarStore { todayColorHex: defaults.string(forKey: "todayColor") ?? "#4285f4", textColorHex: defaults.string(forKey: "textColor") ?? "#FFFFFF", backgroundColorHex: defaults.string(forKey: "backgroundColor") ?? "#000000", - lineColorHex: defaults.string(forKey: "lineColor") ?? "#3A3A3C", + lineColorHex: defaults.string(forKey: "lineColor") ?? "#3A3A52", primaryColorHex: defaults.string(forKey: "primaryColor") ?? "#4285f4", accentColorHex: defaults.string(forKey: "accentColor") ?? "#ea4335", language: defaults.string(forKey: "appLanguage") ?? "system" diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index c8ce99f..4562157 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -81,6 +81,13 @@ private let strings: [String: [String: String]] = [ "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.device": "Nur auf diesem Gerät", + "settings.device.footer": "Diese Einstellungen gelten nur auf diesem Gerät und werden nicht synchronisiert.", "settings.cache.header": "Vorladen", "settings.cache.title": "Vorladen", @@ -440,6 +447,13 @@ private let strings: [String: [String: String]] = [ "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.device": "This device only", + "settings.device.footer": "These settings apply to this device only and are not synced.", "settings.cache.header": "Preloading", "settings.cache.title": "Preloading", diff --git a/Calendarr iOS/Services/SettingsSync.swift b/Calendarr iOS/Services/SettingsSync.swift index e324a6b..33027a9 100644 --- a/Calendarr iOS/Services/SettingsSync.swift +++ b/Calendarr iOS/Services/SettingsSync.swift @@ -6,111 +6,185 @@ extension Notification.Name { static let settingsDidChange = Notification.Name("settingsDidChange") } -/// Two-way synchronisation of appearance/behaviour settings between the app and -/// the Calendarr server. The server is treated as the source of truth on pull; -/// local edits are pushed immediately so the server then holds the newest value. +/// 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. /// -/// Two groups: -/// - **optional** (colors, contrasts, hour height) only sync when the user has -/// enabled the `settingsSync` toggle. -/// - **always** (default view, week start, dim past events) sync regardless of -/// the toggle, because they describe how the user expects the calendar to be -/// computed/presented everywhere. +/// - 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 { - // MARK: – UserDefaults keys + /// Server keys this iOS client can sync, mapped to their UserDefaults key. + /// Intentionally excluded: `language` (iOS "system" has no server value), + /// `share_calendar_icon` (no iOS UI), text/line contrast (iOS-only opacity + /// controls kept device-local), `liquid_glass` (iOS-only). + static let keyToDefaults: [String: String] = [ + "default_view": "defaultView", + "week_start_day": "weekStartDay", + "dim_past_events": "dimPastEvents", + "hour_height": "hourHeight", + "default_event_duration_minutes": "defaultEventDurationMinutes", + "default_reminder_minutes": "defaultReminderMinutes", + "primary_color": "primaryColor", + "accent_color": "accentColor", + "today_color": "todayColor", + "text_color": "textColor", + "bg_color": "backgroundColor", + "line_color": "lineColor", + "month_divider_color": "monthDividerColor", + "month_label_color": "monthLabelColor", + "cache_months": "cacheMonths", + "month_view_paged": "monthViewPaged", + ] - enum Key { - // optional group - static let primaryColor = "primaryColor" - static let accentColor = "accentColor" - static let todayColor = "todayColor" - static let textColor = "textColor" - static let backgroundColor = "backgroundColor" - static let lineColor = "lineColor" - static let monthDividerColor = "monthDividerColor" - static let monthLabelColor = "monthLabelColor" - static let textContrast = "textContrast" - static let lineContrast = "lineContrast" - static let hourHeight = "hourHeight" - // always group - static let defaultView = "defaultView" - static let weekStartDay = "weekStartDay" - static let dimPastEvents = "dimPastEvents" - static let defaultReminder = "defaultReminderMinutes" // Int, -1 = off - static let defaultEventDuration = "defaultEventDurationMinutes" // Int minutes - // master switch - static let enabled = "settingsSync" + 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 var isEnabled: Bool { UserDefaults.standard.bool(forKey: Key.enabled) } + static func isSynced(_ key: String) -> Bool { flags()[key] ?? false } - // MARK: – Defaults (mirror the historical hard-coded values) - - private static func int(_ key: String, _ fallback: Int) -> Int { - let v = UserDefaults.standard.object(forKey: key) as? Int - return v ?? fallback + 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 + } - // MARK: – Build AppSettings from local UserDefaults - + /// Build an AppSettings snapshot from local UserDefaults. static func currentSettings() -> AppSettings { var s = AppSettings() - s.primaryColor = str(Key.primaryColor, "#4285f4") - s.accentColor = str(Key.accentColor, "#ea4335") - s.todayColor = str(Key.todayColor, "#4285f4") - s.textColor = str(Key.textColor, "#FFFFFF") - s.backgroundColor = str(Key.backgroundColor, "#000000") - s.lineColor = str(Key.lineColor, "#3A3A3C") - s.monthDividerColor = str(Key.monthDividerColor, "#7090c0") - s.monthLabelColor = str(Key.monthLabelColor, "#7090c0") - s.textContrast = int(Key.textContrast, 3) - s.lineContrast = int(Key.lineContrast, 3) - s.hourHeight = int(Key.hourHeight, 60) - s.defaultView = str(Key.defaultView, "month") - s.weekStartDay = str(Key.weekStartDay, "monday") - s.dimPastEvents = UserDefaults.standard.bool(forKey: Key.dimPastEvents) - let rem = int(Key.defaultReminder, -1) + s.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(Key.defaultEventDuration, 60) + s.defaultEventDurationMinutes = int("defaultEventDurationMinutes", 60) return s } - // MARK: – Apply a server snapshot to local UserDefaults + /// Copy one synced field from a source snapshot into a destination snapshot. + private static func copyField(_ key: String, from src: AppSettings, into dst: inout AppSettings) { + switch key { + case "default_view": dst.defaultView = src.defaultView + case "week_start_day": dst.weekStartDay = src.weekStartDay + case "dim_past_events": dst.dimPastEvents = src.dimPastEvents + case "hour_height": dst.hourHeight = src.hourHeight + case "default_event_duration_minutes": dst.defaultEventDurationMinutes = src.defaultEventDurationMinutes + case "default_reminder_minutes": dst.defaultReminderMinutes = src.defaultReminderMinutes + case "primary_color": dst.primaryColor = src.primaryColor + case "accent_color": dst.accentColor = src.accentColor + case "today_color": dst.todayColor = src.todayColor + case "text_color": dst.textColor = src.textColor + case "bg_color": dst.backgroundColor = src.backgroundColor + case "line_color": dst.lineColor = src.lineColor + case "month_divider_color": dst.monthDividerColor = src.monthDividerColor + case "month_label_color": dst.monthLabelColor = src.monthLabelColor + case "cache_months": dst.cacheMonths = src.cacheMonths + case "month_view_paged": dst.monthViewPaged = src.monthViewPaged + default: break + } + } - /// Always writes the "always" trio. Writes the optional group only when - /// `includeOptional` is true. - static func apply(_ s: AppSettings, includeOptional: Bool) { + /// Write one synced field from a server snapshot into local UserDefaults. + private static func applyField(_ key: String, from s: AppSettings) { let d = UserDefaults.standard - // always group - d.set(s.defaultView, forKey: Key.defaultView) - d.set(s.weekStartDay, forKey: Key.weekStartDay) - d.set(s.dimPastEvents, forKey: Key.dimPastEvents) - d.set(s.defaultReminderMinutes ?? -1, forKey: Key.defaultReminder) - d.set(s.defaultEventDurationMinutes, forKey: Key.defaultEventDuration) - guard includeOptional else { return } - // NOTE: textColor / backgroundColor / lineColor are intentionally NOT - // synced – the server has no columns for them (iOS-only). Writing the - // resilient-decoded defaults here would wipe the user's local choices. - d.set(s.primaryColor, forKey: Key.primaryColor) - d.set(s.accentColor, forKey: Key.accentColor) - d.set(s.todayColor, forKey: Key.todayColor) - d.set(s.monthDividerColor, forKey: Key.monthDividerColor) - d.set(s.monthLabelColor, forKey: Key.monthLabelColor) - d.set(s.textContrast, forKey: Key.textContrast) - d.set(s.lineContrast, forKey: Key.lineContrast) - d.set(s.hourHeight, forKey: Key.hourHeight) + 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 and apply them locally (server wins). + /// Fetch the server's settings, refresh the flag cache, and apply the value of + /// every on-flagged key locally (server wins). static func pull(api: CalendarrAPI) async { guard let server = try? await api.getSettings() else { return } - apply(server, includeOptional: isEnabled) + storeServerFlags(server.syncFlags) + let f = flags() + for key in syncableKeys where f[key] == true { + applyField(key, from: server) + } await MainActor.run { NotificationCenter.default.post(name: .settingsDidChange, object: nil) } @@ -131,31 +205,17 @@ enum SettingsSync { } } - /// Read-modify-write: start from the server's current settings so that, - /// when the optional group is NOT being synced, the server's colours stay - /// intact. Overwrite the trio always, the optional group only if enabled. + /// 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() - // always group - merged.defaultView = local.defaultView - merged.weekStartDay = local.weekStartDay - merged.dimPastEvents = local.dimPastEvents - merged.defaultReminderMinutes = local.defaultReminderMinutes - merged.defaultEventDurationMinutes = local.defaultEventDurationMinutes - if isEnabled { - merged.primaryColor = local.primaryColor - merged.accentColor = local.accentColor - merged.todayColor = local.todayColor - merged.textColor = local.textColor - merged.backgroundColor = local.backgroundColor - merged.lineColor = local.lineColor - merged.monthDividerColor = local.monthDividerColor - merged.monthLabelColor = local.monthLabelColor - merged.textContrast = local.textContrast - merged.lineContrast = local.lineContrast - merged.hourHeight = local.hourHeight + 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) } } diff --git a/Calendarr iOS/Views/Calendar/DayView.swift b/Calendarr iOS/Views/Calendar/DayView.swift index e78064c..06a8be0 100644 --- a/Calendarr iOS/Views/Calendar/DayView.swift +++ b/Calendarr iOS/Views/Calendar/DayView.swift @@ -8,7 +8,7 @@ struct DayView: View { @AppStorage("appLanguage") private var appLang = "system" @AppStorage("todayColor") private var todayHex = "#4285f4" @AppStorage("textColor") private var textHex = "#FFFFFF" - @AppStorage("lineColor") private var lineHex = "#3A3A3C" + @AppStorage("lineColor") private var lineHex = "#3A3A52" @AppStorage("textContrast") private var textContrast = 3 @AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout @@ -137,7 +137,7 @@ private struct DayHourSlot: View { let language: String let onCreateEvent: (Date) -> Void - @AppStorage("lineColor") private var lineHex = "#3A3A3C" + @AppStorage("lineColor") private var lineHex = "#3A3A52" @AppStorage("lineContrast") private var lineContrast = 3 private var date: Date { diff --git a/Calendarr iOS/Views/Calendar/MonthView.swift b/Calendarr iOS/Views/Calendar/MonthView.swift index c662b97..f1d6f70 100644 --- a/Calendarr iOS/Views/Calendar/MonthView.swift +++ b/Calendarr iOS/Views/Calendar/MonthView.swift @@ -27,7 +27,7 @@ struct MonthView: View { @AppStorage("monthDividerColor") private var dividerHex = "#7090c0" @AppStorage("monthLabelColor") private var labelHex = "#7090c0" @AppStorage("textColor") private var textHex = "#FFFFFF" - @AppStorage("lineColor") private var lineHex = "#3A3A3C" + @AppStorage("lineColor") private var lineHex = "#3A3A52" @AppStorage("textContrast") private var textContrast = 3 @AppStorage("monthViewPaged") private var monthPaged = false diff --git a/Calendarr iOS/Views/Calendar/WeekView.swift b/Calendarr iOS/Views/Calendar/WeekView.swift index 8fd8a98..cf42141 100644 --- a/Calendarr iOS/Views/Calendar/WeekView.swift +++ b/Calendarr iOS/Views/Calendar/WeekView.swift @@ -10,7 +10,7 @@ struct WeekView: View { @AppStorage("appLanguage") private var appLang = "system" @AppStorage("todayColor") private var todayHex = "#4285f4" @AppStorage("textColor") private var textHex = "#FFFFFF" - @AppStorage("lineColor") private var lineHex = "#3A3A3C" + @AppStorage("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 @@ -213,7 +213,7 @@ struct HourSlot: View { let onShowMonth: (Date) -> Void let onShowDay: (Date) -> Void - @AppStorage("lineColor") private var lineHex = "#3A3A3C" + @AppStorage("lineColor") private var lineHex = "#3A3A52" @AppStorage("lineContrast") private var lineContrast = 3 private var date: Date { diff --git a/Calendarr iOS/Views/SettingsView.swift b/Calendarr iOS/Views/SettingsView.swift index 74c1a79..e09efbc 100644 --- a/Calendarr iOS/Views/SettingsView.swift +++ b/Calendarr iOS/Views/SettingsView.swift @@ -3,31 +3,30 @@ import SwiftUI struct SettingsView: View { let api: CalendarrAPI @AppStorage("liquidGlass") private var liquidGlass = false - @AppStorage("settingsSync") private var settingsSync = false @AppStorage("cacheMonths") private var cacheMonths = 3 @AppStorage("appLanguage") private var appLang = "system" - @AppStorage("monthDividerColor") private var dividerHex = "#7090c0" - @AppStorage("monthLabelColor") private var labelHex = "#7090c0" - @AppStorage("todayColor") private var todayHex = "#4285f4" + @AppStorage("monthDividerColor") private var dividerHex = "#7090C0" + @AppStorage("monthLabelColor") private var labelHex = "#7090C0" + @AppStorage("todayColor") private var todayHex = "#4285F4" @AppStorage("textColor") private var textHex = "#FFFFFF" @AppStorage("backgroundColor") private var bgHex = "#000000" - @AppStorage("lineColor") private var lineHex = "#3A3A3C" - @AppStorage("primaryColor") private var primaryHex = "#4285f4" - @AppStorage("accentColor") private var accentHex = "#ea4335" - // Previously server-only; now AppStorage-backed so they persist and the - // calendar views actually apply them. + @AppStorage("lineColor") private var lineHex = "#3A3A52" + @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 - // Device-local: month view as horizontal paged (swipe) vs. scroll feed. @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). + // 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 = "" @@ -37,52 +36,127 @@ struct SettingsView: View { @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 - termindauerSection - benachrichtigungenSection geteilterKalenderSection - liquidGlassSection - cacheSection - spracheSection - farbenSection - schriftSection - linienSection + termineSection ansichtSection - stundenSection + farbenSection + cacheSection + geraetSection } .navigationTitle(L10n.t("settings.title", appLang)) .navigationBarTitleDisplayMode(.large) } - // Reflect the latest server values when opening the screen. - .task { await SettingsSync.pull(api: api) } + // Reflect the latest server values (and flag map) when opening the screen. + .task { await SettingsSync.pull(api: api); syncFlags = SettingsSync.flags() } .task { await loadProfile() } - // Appearance changes update widgets live; synced values are also pushed - // to the server (debounced). `push` itself decides what actually gets - // sent based on the sync toggle, so every change can simply call it. + // 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 SettingsSync.push(api: api) } - .onChange(of: labelHex) { _, _ in SettingsSync.push(api: api) } - .onChange(of: textContrast) { _, _ in SettingsSync.push(api: api) } - .onChange(of: lineContrast) { _, _ in SettingsSync.push(api: api) } + .onChange(of: 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() } - // Enabling sync adopts the server's appearance (server wins). - .onChange(of: settingsSync) { _, on in if on { Task { await SettingsSync.pull(api: api) } } } } - // MARK: – Profil + // MARK: – Reusable row builders + + /// Leading per-row sync toggle: linked (accent) = synced across devices. + @ViewBuilder + private func syncIcon(_ key: String) -> some View { + let on = syncFlags[key] == true + Button { + SettingsSync.setSynced(key, !on, api: api) + syncFlags = SettingsSync.flags() + } label: { + Image(systemName: on ? "link.circle.fill" : "link.circle") + .imageScale(.large) + .foregroundStyle(on ? Color.accentColor : Color.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel(L10n.t("settings.sync_this", appLang)) + } + + private func colorBinding(_ hex: Binding) -> Binding { + Binding(get: { Color(hex: hex.wrappedValue) }, set: { hex.wrappedValue = $0.toHex() }) + } + + @ViewBuilder + private func colorRow(_ syncKey: String, _ defaultsKey: String, _ label: String, _ hex: Binding) -> 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 { + 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)) { @@ -119,28 +193,7 @@ struct SettingsView: View { } } - // MARK: – Benachrichtigungen - - var benachrichtigungenSection: some View { - Section { - Picker(ReminderOptions.defaultTitle(appLang), selection: $defaultReminderMinutes) { - Text(ReminderOptions.off(appLang)).tag(-1) - ForEach(ReminderOptions.all, id: \.self) { m in - Text(ReminderOptions.label(m, appLang)).tag(m) - } - } - .onChange(of: defaultReminderMinutes) { _, _ in - SettingsSync.push(api: api) - NotificationCenter.default.post(name: .rescheduleReminders, object: nil) - } - } header: { - Text(ReminderOptions.sectionTitle(appLang)) - } footer: { - Text(ReminderOptions.defaultFooter(appLang)).font(.caption) - } - } - - // MARK: – Privatsphäre + // MARK: – Privatsphäre (account — not synced) var privatsphaereSection: some View { Section { @@ -158,22 +211,7 @@ struct SettingsView: View { } } - // MARK: – Standardtermindauer - - var termindauerSection: some View { - Section { - Picker(L10n.t("settings.default_duration", appLang), selection: $defaultEventDurationMinutes) { - ForEach([15, 30, 45, 60, 90, 120, 240], id: \.self) { m in - Text(ReminderOptions.durationLabel(m, appLang)).tag(m) - } - } - .onChange(of: defaultEventDurationMinutes) { _, _ in SettingsSync.push(api: api) } - } header: { - Text(L10n.t("settings.calview", appLang)) - } - } - - // MARK: – Geteilter Kalender + // MARK: – Geteilter Kalender (account — not synced) var geteilterKalenderSection: some View { Section { @@ -193,6 +231,170 @@ struct SettingsView: View { } } + // MARK: – Termine (synced) + + var termineSection: some View { + Section(L10n.t("settings.calview", appLang)) { + HStack(spacing: 12) { + syncIcon("default_event_duration_minutes") + Text(L10n.t("settings.default_duration", appLang)) + Spacer() + Picker("", selection: $defaultEventDurationMinutes) { + ForEach([15, 30, 45, 60, 90, 120, 240], id: \.self) { m in + Text(ReminderOptions.durationLabel(m, appLang)).tag(m) + } + } + .pickerStyle(.menu).labelsHidden() + } + HStack(spacing: 12) { + syncIcon("default_reminder_minutes") + Text(ReminderOptions.defaultTitle(appLang)) + Spacer() + Picker("", selection: $defaultReminderMinutes) { + Text(ReminderOptions.off(appLang)).tag(-1) + ForEach(ReminderOptions.all, id: \.self) { m in + Text(ReminderOptions.label(m, appLang)).tag(m) + } + } + .pickerStyle(.menu).labelsHidden() + .onChange(of: defaultReminderMinutes) { _, _ in + SettingsSync.push(api: api) + NotificationCenter.default.post(name: .rescheduleReminders, object: nil) + } + } + } + } + + // MARK: – Ansicht (synced) + + var ansichtSection: some View { + Section(L10n.t("settings.appearance", appLang)) { + HStack(spacing: 12) { + syncIcon("default_view") + Text(L10n.t("settings.defaultview", appLang)) + Spacer() + Picker("", selection: $defaultView) { + Text(L10n.t("view.month", appLang)).tag("month") + Text(L10n.t("view.week", appLang)).tag("week") + Text(L10n.t("view.day", appLang)).tag("day") + Text(L10n.t("view.quarter", appLang)).tag("quarter") + Text(L10n.t("view.agenda", appLang)).tag("agenda") + } + .pickerStyle(.menu).labelsHidden() + } + HStack(spacing: 12) { + syncIcon("week_start_day") + Text(L10n.t("settings.firstweekday", appLang)) + Spacer() + Picker("", selection: $weekStartDay) { + Text(L10n.t("settings.monday", appLang)).tag("monday") + Text(L10n.t("settings.sunday", appLang)).tag("sunday") + } + .pickerStyle(.menu).labelsHidden() + } + HStack(spacing: 12) { + syncIcon("dim_past_events") + Toggle(L10n.t("settings.dimpast", appLang), isOn: $dimPastEvents).tint(Color.accentColor) + } + HStack(spacing: 12) { + syncIcon("month_view_paged") + Toggle(L10n.t("settings.month_paged", appLang), isOn: $monthViewPaged).tint(Color.accentColor) + } + 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) + } 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 @@ -223,252 +425,4 @@ struct SettingsView: View { profileMsg = error.localizedDescription } } - - // MARK: – Liquid Glass - - var liquidGlassSection: some View { - Section { - Toggle(isOn: $liquidGlass) { - Label { - VStack(alignment: .leading, spacing: 2) { - Text(L10n.t("settings.liquidglass", appLang)) - Text(L10n.t("settings.liquidglass.desc", appLang)) - .font(.caption) - .foregroundStyle(.secondary) - } - } icon: { - Image(systemName: "sparkles") - .foregroundStyle(.blue) - } - } - .tint(Color.accentColor) - - Toggle(isOn: $settingsSync) { - Label { - VStack(alignment: .leading, spacing: 2) { - Text(L10n.t("settings.sync", appLang)) - Text(L10n.t("settings.sync.desc", appLang)) - .font(.caption) - .foregroundStyle(.secondary) - } - } icon: { - Image(systemName: "arrow.triangle.2.circlepath") - .foregroundStyle(.teal) - } - } - .tint(Color.accentColor) - } header: { - Text(L10n.t("settings.appdesign", appLang)) - } footer: { - Text(L10n.t("settings.sync.footer", appLang)) - .font(.caption) - } - } - - // MARK: – Cache - - var cacheSection: some View { - Section { - VStack(alignment: .leading, spacing: 10) { - Label { - VStack(alignment: .leading, spacing: 2) { - Text(L10n.t("settings.cache.title", appLang)) - Text(L10n.t("settings.cache.desc", appLang)) - .font(.caption) - .foregroundStyle(.secondary) - } - } icon: { - Image(systemName: "arrow.down.circle") - .foregroundStyle(.green) - } - - Picker(L10n.t("settings.cache.range", appLang), selection: $cacheMonths) { - Text(L10n.t("settings.cache.1m", appLang)).tag(1) - Text(L10n.t("settings.cache.3m", appLang)).tag(3) - Text(L10n.t("settings.cache.6m", appLang)).tag(6) - Text(L10n.t("settings.cache.1y", appLang)).tag(12) - } - .pickerStyle(.segmented) - } - .padding(.vertical, 4) - } header: { - Text(L10n.t("settings.cache.header", appLang)) - } footer: { - Text(L10n.t("settings.cache.footer", appLang)) - .font(.caption) - } - } - - // MARK: – Sprache - - var spracheSection: some View { - Section(L10n.t("settings.language", appLang)) { - Picker(L10n.t("settings.language", appLang), selection: $appLang) { - Text(L10n.t("lang.system", appLang)).tag("system") - Text(L10n.t("lang.german", appLang)).tag("de") - Text(L10n.t("lang.english", appLang)).tag("en") - } - } - } - - // MARK: – Farben - - var farbenSection: some View { - Section(L10n.t("settings.colors", appLang)) { - ColorPickerRow(label: L10n.t("settings.color.primary", appLang), hex: $primaryHex) - ColorPickerRow(label: L10n.t("settings.color.accent", appLang), hex: $accentHex) - ColorPickerRow(label: L10n.t("settings.color.today", appLang), hex: $todayHex) - ColorPickerRow(label: L10n.t("settings.color.text", appLang), hex: $textHex) - ColorPickerRow(label: L10n.t("settings.color.background", appLang), hex: $bgHex) - ColorPickerRow(label: L10n.t("settings.color.line", appLang), hex: $lineHex) - ColorPickerRow(label: L10n.t("settings.color.divider", appLang), hex: $dividerHex) - ColorPickerRow(label: L10n.t("settings.color.label", appLang), hex: $labelHex) - } - } - - // MARK: – Schriftkontrast - - var schriftSection: some View { - Section { - VStack(alignment: .leading, spacing: 10) { - Text(L10n.t("settings.textcontrast", appLang)) - .font(.headline) - Text(L10n.t("settings.textcontrast.desc", appLang)) - .font(.caption) - .foregroundStyle(.secondary) - ContrastSelector( - value: $textContrast, - options: [ - (1, L10n.t("settings.contrast.dark", appLang)), - (2, L10n.t("settings.contrast.medium", appLang)), - (3, L10n.t("settings.contrast.bright", appLang)), - (4, L10n.t("settings.contrast.max", appLang)) - ] - ) - } - .padding(.vertical, 4) - } - } - - // MARK: – Linienkontrast - - var linienSection: some View { - Section { - VStack(alignment: .leading, spacing: 10) { - Text(L10n.t("settings.linecontrast", appLang)) - .font(.headline) - Text(L10n.t("settings.linecontrast.desc", appLang)) - .font(.caption) - .foregroundStyle(.secondary) - ContrastSelector( - value: $lineContrast, - options: [ - (1, L10n.t("settings.linecontrast.barely", appLang)), - (2, L10n.t("settings.linecontrast.subtle", appLang)), - (3, L10n.t("settings.linecontrast.normal", appLang)), - (4, L10n.t("settings.linecontrast.strong", appLang)) - ] - ) - } - .padding(.vertical, 4) - } - } - - // MARK: – Ansicht - - var ansichtSection: some View { - Section(L10n.t("settings.calview", appLang)) { - Picker(L10n.t("settings.defaultview", appLang), selection: $defaultView) { - Text(L10n.t("view.month", appLang)).tag("month") - Text(L10n.t("view.week", appLang)).tag("week") - Text(L10n.t("view.day", appLang)).tag("day") - Text(L10n.t("view.quarter", appLang)).tag("quarter") - Text(L10n.t("view.agenda", appLang)).tag("agenda") - } - Picker(L10n.t("settings.firstweekday", appLang), selection: $weekStartDay) { - Text(L10n.t("settings.monday", appLang)).tag("monday") - Text(L10n.t("settings.sunday", appLang)).tag("sunday") - } - Toggle(L10n.t("settings.dimpast", appLang), isOn: $dimPastEvents) - .tint(Color.accentColor) - Toggle(L10n.t("settings.month_paged", appLang), isOn: $monthViewPaged) - .tint(Color.accentColor) - } - } - - // MARK: – Stundenhöhe - - var stundenSection: some View { - Section { - VStack(alignment: .leading, spacing: 10) { - Text(L10n.t("settings.hourheight", appLang)) - .font(.headline) - Text(L10n.t("settings.hourheight.desc", appLang)) - .font(.caption) - .foregroundStyle(.secondary) - ContrastSelector( - value: $hourHeight, - options: [ - (28, L10n.t("settings.hourheight.compact", appLang)), - (44, L10n.t("settings.hourheight.normal", appLang)), - (60, L10n.t("settings.hourheight.comfort", appLang)), - (80, L10n.t("settings.hourheight.large", appLang)) - ] - ) - } - .padding(.vertical, 4) - } - } - -} - -// MARK: – Reusable Components - -struct ColorPickerRow: View { - let label: String - @Binding var hex: String - - var color: Binding { - Binding( - get: { Color(hex: hex) }, - set: { hex = $0.toHex() } - ) - } - - var body: some View { - HStack { - Text(label) - Spacer() - ColorPicker("", selection: color, supportsOpacity: false) - .labelsHidden() - Text(hex.uppercased()) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 68, alignment: .trailing) - } - } -} - -struct ContrastSelector: View { - @Binding var value: T - let options: [(T, String)] - - var body: some View { - HStack(spacing: 8) { - ForEach(Array(options.enumerated()), id: \.offset) { _, opt in - Button { - value = opt.0 - } label: { - Text(opt.1) - .font(.caption.weight(.medium)) - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .background(value == opt.0 ? Color.accentColor : Color(.systemGray5)) - .foregroundStyle(value == opt.0 ? .white : .primary) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - .buttonStyle(.plain) - } - } - } } From d90a1a552c502c24d02d1ff886fa428623c535ca Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 14 Jul 2026 22:24:24 +0200 Subject: [PATCH 56/90] iOS: left side drawer (calendars + groups + nav) and opt-in top-bar colour Replace the top-bar menu popup with an off-canvas left drawer, opened by the hamburger or a narrow left-edge swipe (kept off the content area so month/week swipe stay free). The drawer hosts the calendar visibility list, a view switcher, group switching, and quick access to Settings/Accounts/Profile/ Groups/Server plus sync and logout. - extract the filter list into reusable CalendarFilterContent (shared by the drawer and the still-present CalendarFilterSheet); the sheet is now a thin wrapper - new CalendarDrawer view; CalendarHostView hosts it as a ZStack overlay with a scrim and edge-swipe gesture, and presents drawer destinations as sheets - opt-in surface colour: surfaceColor (device-local) tints the flat top bar; empty keeps the translucent .bar material. Settings row with an Auto reset. Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/Localization.swift | 4 + .../Views/Calendar/CalendarDrawer.swift | 151 ++++++++ .../Views/Calendar/CalendarHostView.swift | 119 ++++--- .../Views/CalendarFilterContent.swift | 280 +++++++++++++++ Calendarr iOS/Views/CalendarFilterSheet.swift | 330 +----------------- Calendarr iOS/Views/SettingsView.swift | 24 ++ 6 files changed, 537 insertions(+), 371 deletions(-) create mode 100644 Calendarr iOS/Views/Calendar/CalendarDrawer.swift create mode 100644 Calendarr iOS/Views/CalendarFilterContent.swift diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index 4562157..a74a724 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -86,6 +86,8 @@ private let strings: [String: [String: String]] = [ "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.device.footer": "Diese Einstellungen gelten nur auf diesem Gerät und werden nicht synchronisiert.", @@ -452,6 +454,8 @@ private let strings: [String: [String: String]] = [ "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.device.footer": "These settings apply to this device only and are not synced.", diff --git a/Calendarr iOS/Views/Calendar/CalendarDrawer.swift b/Calendarr iOS/Views/Calendar/CalendarDrawer.swift new file mode 100644 index 0000000..65eed21 --- /dev/null +++ b/Calendarr iOS/Views/Calendar/CalendarDrawer.swift @@ -0,0 +1,151 @@ +import SwiftUI + +/// Destinations the drawer can open (presented as sheets by the host). +enum DrawerDestination: Int, Identifiable { + case profile, settings, accounts, groups, server + var id: Int { rawValue } +} + +/// The left side drawer: central navigation + calendar visibility + group +/// switching. Replaces the old menu popup and filter sheet. +struct CalendarDrawer: View { + let api: CalendarrAPI + let store: CalendarStore + let groups: [CalGroup] + let onSwitchGroup: (CalGroup?) -> Void + let onSelectView: (CalViewType) -> Void + let onOpenDestination: (DrawerDestination) -> 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) + Divider() + navFooter + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(Color(.systemBackground)) + } + + // MARK: – Header + + private var header: some View { + HStack(spacing: 12) { + Circle() + .fill(Color.accentColor) + .frame(width: 40, height: 40) + .overlay { + Text(appState.username.prefix(1).uppercased()) + .font(.headline).foregroundStyle(.white) + } + VStack(alignment: .leading, spacing: 2) { + Text(appState.username).font(.headline).lineLimit(1) + Text(appState.serverURL + .replacingOccurrences(of: "https://", with: "") + .replacingOccurrences(of: "http://", with: "")) + .font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + Spacer() + Button { onClose() } label: { + Image(systemName: "xmark").font(.system(size: 15, weight: .semibold)) + } + .buttonStyle(.plain).foregroundStyle(.secondary) + } + .padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 10) + } + + // 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, 8) + } + } + + // 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, 8) + } + } + + 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) + } + + // MARK: – Nav footer (quick access) + + private var navFooter: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + navButton(L10n.t("menu.appearance", appLang), "paintpalette") { onOpenDestination(.settings) } + navButton(L10n.t("menu.accounts", appLang), "tray.2") { onOpenDestination(.accounts) } + navButton(L10n.t("menu.profile", appLang), "person.circle") { onOpenDestination(.profile) } + navButton(L10n.t("groups.title", appLang), "person.2") { onOpenDestination(.groups) } + navButton(L10n.t("menu.server", appLang), "server.rack") { onOpenDestination(.server) } + navButton(L10n.t("menu.sync", appLang), "arrow.triangle.2.circlepath") { onSync() } + navButton(L10n.t("menu.logout", appLang), "rectangle.portrait.and.arrow.right", role: .destructive) { + appState.logout() + } + } + .padding(.horizontal, 12).padding(.vertical, 10) + } + } + + private func navButton(_ label: String, _ systemImage: String, role: ButtonRole? = nil, action: @escaping () -> Void) -> some View { + Button(role: role, action: action) { + VStack(spacing: 4) { + Image(systemName: systemImage).font(.system(size: 18)) + Text(label).font(.caption2).lineLimit(1) + } + .frame(width: 64) + .foregroundStyle(role == .destructive ? Color.red : Color.accentColor) + } + .buttonStyle(.plain) + } +} diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index 5412e5b..24dd1a3 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -21,6 +21,8 @@ struct CalendarHostView: View { @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 = "" @Environment(\.scenePhase) private var scenePhase @@ -31,6 +33,8 @@ struct CalendarHostView: View { @State private var didApplyDefaultView = false @State private var groups: [CalGroup] = [] @State private var showNewBirthday = false + @State private var showDrawer = false + @State private var drawerDestination: DrawerDestination? = nil private var titleString: String { if store.viewType == .month { @@ -43,11 +47,66 @@ struct CalendarHostView: View { } var body: some View { - if liquidGlass { - glassVariant - } else { - flatVariant + 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() }, + onOpenDestination: { dest in closeDrawer(); drawerDestination = dest }, + 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) } + // 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 } + } + } + ) + } + } + .sheet(item: $drawerDestination) { dest in + switch dest { + case .profile: ProfileView(api: api) + case .settings: SettingsView(api: api) + case .accounts: AccountsView(api: api) + case .groups: GroupsView(api: api) + case .server: ServerView() + } + } + } + + private var drawerWidth: CGFloat { min(UIScreen.main.bounds.width - 40, 360) } + + private func closeDrawer() { + withAnimation(.easeInOut(duration: 0.25)) { showDrawer = false } } // MARK: – Loading indicator @@ -205,7 +264,9 @@ struct CalendarHostView: View { } private var topBar: some View { - barContents.background(.bar) + barContents.background( + surfaceHex.isEmpty ? AnyShapeStyle(Material.bar) : AnyShapeStyle(Color(hex: surfaceHex)) + ) } @ViewBuilder private var groupBanner: some View { @@ -231,51 +292,11 @@ struct CalendarHostView: View { Task { await forceReload() } } - /// The single top-bar action: a compact popup holding view / filter / - /// groups / sync, plus an "Einstellungen" entry that opens the full menu. - /// (Replaces the separate view / filter / group icons in the bar.) + /// 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 { - Menu { - // View (fixed icon, not per-view) - Menu { - ForEach(CalViewType.allCases, id: \.self) { vt in - Button { store.viewType = vt } label: { - Label(vt.label(appLang), systemImage: store.viewType == vt ? "checkmark" : vt.systemImage) - } - } - } label: { - Label(L10n.t("view.change", appLang), systemImage: "rectangle.3.group") - } - // Filter - Button { showFilter = true } label: { - Label(L10n.t("filter.button", appLang), systemImage: "line.3.horizontal.decrease.circle") - } - // Groups - if !groups.isEmpty { - Menu { - Button { switchGroup(nil) } label: { - Label(L10n.t("groups.personal", appLang), - systemImage: store.activeGroup == nil ? "checkmark" : "person") - } - ForEach(groups) { g in - Button { switchGroup(g) } label: { - Label(g.name, - systemImage: store.activeGroup?.id == g.id ? "checkmark" : GroupIcons.symbol(g.icon)) - } - } - } label: { - Label(L10n.t("groups.title", appLang), systemImage: "person.2") - } - } - // Sync - Button { Task { await syncFromServer(force: true) } } label: { - Label(L10n.t("menu.sync", appLang), systemImage: "arrow.triangle.2.circlepath") - } - Divider() - // Full settings menu - Button { showMenu = true } label: { - Label(L10n.t("menu.section.settings", appLang), systemImage: "gearshape") - } + Button { + withAnimation(.easeInOut(duration: 0.25)) { showDrawer = true } } label: { Image(systemName: "line.3.horizontal") .font(.system(size: 18, weight: .medium)) diff --git a/Calendarr iOS/Views/CalendarFilterContent.swift b/Calendarr iOS/Views/CalendarFilterContent.swift new file mode 100644 index 0000000..b534b79 --- /dev/null +++ b/Calendarr iOS/Views/CalendarFilterContent.swift @@ -0,0 +1,280 @@ +import SwiftUI + +/// The calendar-visibility list, extracted so both the modal `CalendarFilterSheet` +/// and the side drawer render the exact same rows/logic. Filtering is purely +/// client-side (hidden keys in `CalendarStore`); server-managed sources also +/// reconcile their sidebar_hidden / reminders_enabled flags on load. +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 = [] + @State private var banished: Set = [] + @State private var reminderDisabled: Set = [] + @State private var allKeys: Set = [] + @State private var groupDetail: CalGroup? = nil + @State private var hiddenGroup: Set = [] + + 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 { + List { + Section { + Button(L10n.t("filter.show_all", appLang)) { + hidden = []; store.setHiddenCalendars(hidden) + } + Button(L10n.t("filter.hide_all", appLang)) { + hidden = allKeys; store.setHiddenCalendars(hidden) + } + } + let visibleLocals = localCalendars.filter { + !banished.contains(CalendarStore.calendarKey(source: "local", calendarId: "\($0.id)")) + } + if !visibleLocals.isEmpty { + Section(L10n.t("accounts.local.header", appLang)) { + ForEach(visibleLocals) { cal in + row(name: cal.owned ? cal.name : (cal.sharedBy ?? cal.name), + colorHex: cal.color, + key: CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)"), + readOnly: !cal.owned && cal.permission != "read_write") + } + } + } + ForEach(caldavAccounts) { acc in + let cals = (acc.calendars ?? []).filter { + !banished.contains(CalendarStore.calendarKey(source: "caldav", calendarId: "\($0.id)")) + } + if !cals.isEmpty { + Section(acc.name) { + ForEach(cals) { cal in + row(name: cal.name, + colorHex: cal.color ?? acc.color, + key: CalendarStore.calendarKey(source: "caldav", calendarId: "\(cal.id)")) + } + } + } + } + let visibleSubs = icalSubs.filter { + !banished.contains(CalendarStore.calendarKey(source: "ical", calendarId: "\($0.id)")) + } + if !visibleSubs.isEmpty { + Section(L10n.t("accounts.ical.header", appLang)) { + ForEach(visibleSubs) { sub in + row(name: sub.name, colorHex: sub.color, + key: CalendarStore.calendarKey(source: "ical", calendarId: "\(sub.id)")) + } + } + } + ForEach(googleAccounts) { acc in + let cals = (acc.calendars ?? []).filter { + !banished.contains(CalendarStore.calendarKey(source: "google", calendarId: "\($0.id)")) + } + if !cals.isEmpty { + Section(acc.email) { + ForEach(cals) { cal in + row(name: cal.name, + colorHex: cal.color ?? "#4285f4", + key: CalendarStore.calendarKey(source: "google", calendarId: "\(cal.id)")) + } + } + } + } + ForEach(haAccounts) { acc in + let cals = (acc.calendars ?? []).filter { + !banished.contains(CalendarStore.calendarKey(source: "homeassistant", calendarId: "\($0.id)")) + } + if !cals.isEmpty { + Section(acc.name) { + ForEach(cals) { cal in + row(name: cal.name, + colorHex: cal.color ?? "#46bdc6", + key: CalendarStore.calendarKey(source: "homeassistant", calendarId: "\(cal.id)")) + } + } + } + } + if !banished.isEmpty { + Section { + Text(L10n.t("filter.banished_footer", appLang)) + .font(.caption).foregroundStyle(.secondary) + } + } + } + } + } + .task { await load() } + } + + @ViewBuilder + private func row(name: String, colorHex: String, key: String, readOnly: Bool = false) -> some View { + let isVisible = !hidden.contains(key) + Button { + if isVisible { hidden.insert(key) } else { hidden.remove(key) } + store.setCalendarHidden(key, hidden: isVisible) + } label: { + HStack(spacing: 12) { + Circle() + .fill(Color(hex: colorHex)) + .frame(width: 14, height: 14) + .opacity(isVisible ? 1.0 : 0.35) + Text(name) + .foregroundStyle(isVisible ? .primary : .secondary) + .strikethrough(!isVisible, color: .secondary) + if readOnly { + Image(systemName: "lock.fill").font(.caption2).foregroundStyle(.secondary) + } + Spacer() + if reminderDisabled.contains(key) { + Image(systemName: "bell.slash").font(.caption).foregroundStyle(.secondary) + } + Image(systemName: isVisible ? "eye" : "eye.slash") + .foregroundStyle(isVisible ? Color.accentColor : .secondary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .swipeActions(edge: .leading, allowsFullSwipe: false) { + let disabled = reminderDisabled.contains(key) + Button { + toggleReminders(forKey: key) + } label: { + Label(L10n.t(disabled ? "filter.reminders_on" : "filter.reminders_off", appLang), + systemImage: disabled ? "bell" : "bell.slash") + } + .tint(.orange) + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + hidden.remove(key) + banished.insert(key) + store.setCalendarBanished(key, banished: true) + pushBanishToServer(key: key, hidden: true) + } label: { + Label(L10n.t("filter.banish", appLang), systemImage: "archivebox") + } + } + } + + 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) } + } + } + + @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() + func applyReminders(_ source: String, _ id: Int, _ enabled: Bool) { + if !enabled { rd.insert(CalendarStore.calendarKey(source: source, calendarId: "\(id)")) } + } + for cal in localCalendars { applyReminders("local", cal.id, cal.remindersEnabled) } + for acc in caldavAccounts { for cal in acc.calendars ?? [] { applyReminders("caldav", cal.id, cal.remindersEnabled ?? true) } } + for sub in icalSubs { applyReminders("ical", sub.id, sub.remindersEnabled ?? true) } + for acc in googleAccounts { for cal in acc.calendars ?? [] { applyReminders("google", cal.id, cal.remindersEnabled ?? true) } } + for acc in haAccounts { for cal in acc.calendars ?? [] { applyReminders("homeassistant", cal.id, cal.remindersEnabled) } } + store.setReminderDisabledKeys(rd) + reminderDisabled = rd + + var keys = Set() + for cal in localCalendars { keys.insert(CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)")) } + for acc in caldavAccounts { for cal in acc.calendars ?? [] { keys.insert(CalendarStore.calendarKey(source: "caldav", calendarId: "\(cal.id)")) } } + for sub in icalSubs { keys.insert(CalendarStore.calendarKey(source: "ical", calendarId: "\(sub.id)")) } + for acc in googleAccounts { for cal in acc.calendars ?? [] { keys.insert(CalendarStore.calendarKey(source: "google", calendarId: "\(cal.id)")) } } + for acc in haAccounts { for cal in acc.calendars ?? [] { keys.insert(CalendarStore.calendarKey(source: "homeassistant", calendarId: "\(cal.id)")) } } + allKeys = keys + isLoading = false + } + + 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) } + } +} diff --git a/Calendarr iOS/Views/CalendarFilterSheet.swift b/Calendarr iOS/Views/CalendarFilterSheet.swift index bd79e95..99c652f 100644 --- a/Calendarr iOS/Views/CalendarFilterSheet.swift +++ b/Calendarr iOS/Views/CalendarFilterSheet.swift @@ -1,337 +1,23 @@ import SwiftUI -/// Lets the user toggle which calendars contribute events to the displayed -/// calendar views (and the home-screen widgets). Filtering is purely -/// client-side: hidden keys live in UserDefaults via `CalendarStore`. No -/// server roundtrip is required to toggle visibility. +/// 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" - @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 = [] - @State private var banished: Set = [] - /// Calendars whose events do not generate reminder notifications. - @State private var reminderDisabled: Set = [] - /// All non-banished keys discovered during load — used by bulk show/hide. - @State private var allKeys: Set = [] - /// Group-mode: the active group's full detail (members + colours) and the - /// per-member / group-calendar hidden keys. - @State private var groupDetail: CalGroup? = nil - @State private var hiddenGroup: Set = [] - var body: some View { NavigationStack { - Group { - if isLoading { - ProgressView(L10n.t("filter.loading", appLang)) - } else if store.activeGroup != nil { - groupFilterList - } else if allKeys.isEmpty { - Text(L10n.t("filter.empty", appLang)) - .foregroundStyle(.secondary) - } else { - List { - let visibleLocals = localCalendars.filter { - !banished.contains(CalendarStore.calendarKey(source: "local", calendarId: "\($0.id)")) - } - if !visibleLocals.isEmpty { - Section(L10n.t("accounts.local.header", appLang)) { - ForEach(visibleLocals) { cal in - // A calendar shared with me is shown under the owner's - // name and flagged read-only when I can't write to it. - row(name: cal.owned ? cal.name : (cal.sharedBy ?? cal.name), - colorHex: cal.color, - key: CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)"), - readOnly: !cal.owned && cal.permission != "read_write") - } - } - } - ForEach(caldavAccounts) { acc in - let cals = (acc.calendars ?? []).filter { - !banished.contains(CalendarStore.calendarKey(source: "caldav", calendarId: "\($0.id)")) - } - if !cals.isEmpty { - Section(acc.name) { - ForEach(cals) { cal in - row(name: cal.name, - colorHex: cal.color ?? acc.color, - key: CalendarStore.calendarKey(source: "caldav", calendarId: "\(cal.id)")) - } - } - } - } - let visibleSubs = icalSubs.filter { - !banished.contains(CalendarStore.calendarKey(source: "ical", calendarId: "\($0.id)")) - } - if !visibleSubs.isEmpty { - Section(L10n.t("accounts.ical.header", appLang)) { - ForEach(visibleSubs) { sub in - row(name: sub.name, colorHex: sub.color, - key: CalendarStore.calendarKey(source: "ical", calendarId: "\(sub.id)")) - } - } - } - ForEach(googleAccounts) { acc in - let cals = (acc.calendars ?? []).filter { - !banished.contains(CalendarStore.calendarKey(source: "google", calendarId: "\($0.id)")) - } - if !cals.isEmpty { - Section(acc.email) { - ForEach(cals) { cal in - row(name: cal.name, - colorHex: cal.color ?? "#4285f4", - key: CalendarStore.calendarKey(source: "google", calendarId: "\(cal.id)")) - } - } - } - } - ForEach(haAccounts) { acc in - let cals = (acc.calendars ?? []).filter { - !banished.contains(CalendarStore.calendarKey(source: "homeassistant", calendarId: "\($0.id)")) - } - if !cals.isEmpty { - Section(acc.name) { - ForEach(cals) { cal in - row(name: cal.name, - colorHex: cal.color ?? "#46bdc6", - key: CalendarStore.calendarKey(source: "homeassistant", calendarId: "\(cal.id)")) - } - } - } - } - if !banished.isEmpty { - Section { - Text(L10n.t("filter.banished_footer", appLang)) - .font(.caption) - .foregroundStyle(.secondary) - } - } + CalendarFilterContent(api: api, store: store) + .navigationTitle(L10n.t("filter.title", appLang)) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button(L10n.t("nav.done", appLang)) { dismiss() } } } - } - .navigationTitle(L10n.t("filter.title", appLang)) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Menu { - Button(L10n.t("filter.show_all", appLang)) { - hidden = [] - store.setHiddenCalendars(hidden) - } - Button(L10n.t("filter.hide_all", appLang)) { - hidden = allKeys - store.setHiddenCalendars(hidden) - } - } label: { - Image(systemName: "ellipsis.circle") - } - .disabled(allKeys.isEmpty) - } - ToolbarItem(placement: .primaryAction) { - Button(L10n.t("nav.done", appLang)) { dismiss() } - } - } } - .task { await load() } - } - - @ViewBuilder - private func row(name: String, colorHex: String, key: String, readOnly: Bool = false) -> some View { - let isVisible = !hidden.contains(key) - Button { - if isVisible { hidden.insert(key) } else { hidden.remove(key) } - // New hidden state == was-visible (flip). Previous code passed the - // inverse, which persisted the opposite of what the UI showed. - store.setCalendarHidden(key, hidden: isVisible) - } label: { - HStack(spacing: 12) { - Circle() - .fill(Color(hex: colorHex)) - .frame(width: 14, height: 14) - .opacity(isVisible ? 1.0 : 0.35) - Text(name) - .foregroundStyle(isVisible ? .primary : .secondary) - .strikethrough(!isVisible, color: .secondary) - if readOnly { - Image(systemName: "lock.fill") - .font(.caption2) - .foregroundStyle(.secondary) - } - Spacer() - if reminderDisabled.contains(key) { - Image(systemName: "bell.slash") - .font(.caption) - .foregroundStyle(.secondary) - } - Image(systemName: isVisible ? "eye" : "eye.slash") - .foregroundStyle(isVisible ? Color.accentColor : .secondary) - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .swipeActions(edge: .leading, allowsFullSwipe: false) { - let disabled = reminderDisabled.contains(key) - Button { - toggleReminders(forKey: key) - } label: { - Label(L10n.t(disabled ? "filter.reminders_on" : "filter.reminders_off", appLang), - systemImage: disabled ? "bell" : "bell.slash") - } - .tint(.orange) - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { - hidden.remove(key) - banished.insert(key) - store.setCalendarBanished(key, banished: true) - pushBanishToServer(key: key, hidden: true) - } label: { - Label(L10n.t("filter.banish", appLang), systemImage: "archivebox") - } - } - } - - /// Flip a calendar's reminder mute, persist locally + on the server, reschedule. - private func toggleReminders(forKey key: String) { - let nowDisabled = !reminderDisabled.contains(key) - if nowDisabled { reminderDisabled.insert(key) } else { reminderDisabled.remove(key) } - store.setReminderDisabled(key, disabled: nowDisabled) - if let parsed = CalendarStore.parseCalendarKey(key) { - Task { try? await api.setCalendarRemindersEnabled( - source: parsed.source, calendarId: parsed.id, enabled: !nowDisabled) } - } - } - - // MARK: – Group overlay filter (hide individual members / the group calendar) - - @ViewBuilder - private var groupFilterList: some View { - if let g = groupDetail { - List { - Section(header: Label(g.name, systemImage: GroupIcons.symbol(g.icon))) { - // Only members who actually share a calendar into the group — - // avoids phantom empty rows for members who share nothing. - 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 - // Group overlay: list members (+ the group calendar) to hide individually. - if let g = store.activeGroup { - hiddenGroup = store.hiddenGroupKeys - groupDetail = try? await api.getGroup(id: g.id) - isLoading = false - return - } - hidden = store.hiddenCalendarKeys - banished = store.banishedCalendarKeys - async let c = (try? await api.getCalDAVAccounts()) ?? [] - async let l = (try? await api.getLocalCalendars()) ?? [] - async let i = (try? await api.getICalSubscriptions()) ?? [] - async let g = (try? await api.getGoogleAccounts()) ?? [] - async let h = (try? await api.getHomeAssistantAccounts()) ?? [] - (caldavAccounts, localCalendars, icalSubs, googleAccounts, haAccounts) = await (c, l, i, g, h) - - // Reconcile banished state with the server's sidebar_hidden flags - // (server wins for CalDAV/Google/HA; local/ical keep their local state). - var b = store.banishedCalendarKeys - func applyServerHidden(_ source: String, _ id: Int, _ hidden: Bool) { - let key = CalendarStore.calendarKey(source: source, calendarId: "\(id)") - if hidden { b.insert(key) } else { b.remove(key) } - } - for acc in caldavAccounts { for cal in acc.calendars ?? [] { applyServerHidden("caldav", cal.id, cal.sidebarHidden) } } - for acc in googleAccounts { for cal in acc.calendars ?? [] { applyServerHidden("google", cal.id, cal.sidebarHidden) } } - for acc in haAccounts { for cal in acc.calendars ?? [] { applyServerHidden("homeassistant", cal.id, cal.sidebarHidden) } } - store.setBanishedCalendars(b) - banished = b - - // Reconcile reminder-muted state from the server's reminders_enabled flags. - var rd = Set() - func applyReminders(_ source: String, _ id: Int, _ enabled: Bool) { - if !enabled { rd.insert(CalendarStore.calendarKey(source: source, calendarId: "\(id)")) } - } - for cal in localCalendars { applyReminders("local", cal.id, cal.remindersEnabled) } - for acc in caldavAccounts { for cal in acc.calendars ?? [] { applyReminders("caldav", cal.id, cal.remindersEnabled ?? true) } } - for sub in icalSubs { applyReminders("ical", sub.id, sub.remindersEnabled ?? true) } - for acc in googleAccounts { for cal in acc.calendars ?? [] { applyReminders("google", cal.id, cal.remindersEnabled ?? true) } } - for acc in haAccounts { for cal in acc.calendars ?? [] { applyReminders("homeassistant", cal.id, cal.remindersEnabled) } } - store.setReminderDisabledKeys(rd) - reminderDisabled = rd - - var keys = Set() - for cal in localCalendars { - keys.insert(CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)")) - } - for acc in caldavAccounts { - for cal in acc.calendars ?? [] { - keys.insert(CalendarStore.calendarKey(source: "caldav", calendarId: "\(cal.id)")) - } - } - for sub in icalSubs { - keys.insert(CalendarStore.calendarKey(source: "ical", calendarId: "\(sub.id)")) - } - for acc in googleAccounts { - for cal in acc.calendars ?? [] { - keys.insert(CalendarStore.calendarKey(source: "google", calendarId: "\(cal.id)")) - } - } - for acc in haAccounts { - for cal in acc.calendars ?? [] { - keys.insert(CalendarStore.calendarKey(source: "homeassistant", calendarId: "\(cal.id)")) - } - } - allKeys = keys - isLoading = false - } - - /// For server-backed sources, persist the banish on the server too. - private func pushBanishToServer(key: String, hidden: Bool) { - guard let parsed = CalendarStore.parseCalendarKey(key), - CalendarStore.serverManagedSources.contains(parsed.source) else { return } - Task { try? await api.setCalendarSidebarHidden(source: parsed.source, calendarId: parsed.id, hidden: hidden) } } } diff --git a/Calendarr iOS/Views/SettingsView.swift b/Calendarr iOS/Views/SettingsView.swift index e09efbc..fd1cbc5 100644 --- a/Calendarr iOS/Views/SettingsView.swift +++ b/Calendarr iOS/Views/SettingsView.swift @@ -11,6 +11,8 @@ struct SettingsView: View { @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 @@ -107,6 +109,15 @@ struct SettingsView: View { 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 { + Binding( + get: { Color(hex: surfaceHex.isEmpty ? "#1C1C1E" : surfaceHex) }, + set: { surfaceHex = $0.toHex() } + ) + } + @ViewBuilder private func colorRow(_ syncKey: String, _ defaultsKey: String, _ label: String, _ hex: Binding) -> some View { HStack(spacing: 12) { @@ -386,6 +397,19 @@ struct SettingsView: View { } } .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)) + } } header: { Text(L10n.t("settings.device", appLang)) } footer: { From e008d5d98a2cadd4724fb9b88ce7499c5668b126 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 15 Jul 2026 06:09:38 +0200 Subject: [PATCH 57/90] iOS drawer: compact show/hide-all, single menu button, corner-safe footer Testing feedback: - show-all / hide-all are now side by side (bordered), not two big stacked rows - the busy quick-access strip becomes a single "Einstellungen" button that opens the full menu (which already holds accounts/profile/groups/server/sync/ logout) - extra horizontal + bottom padding so the footer button is not clipped by the iPhone's rounded corners / home indicator Co-Authored-By: Claude Opus 4.8 --- .../Views/Calendar/CalendarDrawer.swift | 49 ++++++------------- .../Views/Calendar/CalendarHostView.swift | 13 +---- .../Views/CalendarFilterContent.swift | 15 ++++-- 3 files changed, 25 insertions(+), 52 deletions(-) diff --git a/Calendarr iOS/Views/Calendar/CalendarDrawer.swift b/Calendarr iOS/Views/Calendar/CalendarDrawer.swift index 65eed21..0fe320e 100644 --- a/Calendarr iOS/Views/Calendar/CalendarDrawer.swift +++ b/Calendarr iOS/Views/Calendar/CalendarDrawer.swift @@ -1,21 +1,14 @@ import SwiftUI -/// Destinations the drawer can open (presented as sheets by the host). -enum DrawerDestination: Int, Identifiable { - case profile, settings, accounts, groups, server - var id: Int { rawValue } -} - -/// The left side drawer: central navigation + calendar visibility + group -/// switching. Replaces the old menu popup and filter sheet. +/// 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 onOpenDestination: (DrawerDestination) -> Void - let onSync: () -> Void + let onOpenMenu: () -> Void let onClose: () -> Void @Environment(AppState.self) private var appState @@ -118,34 +111,20 @@ struct CalendarDrawer: View { .buttonStyle(.plain) } - // MARK: – Nav footer (quick access) + // MARK: – Nav footer (single entry into the full menu) private var navFooter: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 4) { - navButton(L10n.t("menu.appearance", appLang), "paintpalette") { onOpenDestination(.settings) } - navButton(L10n.t("menu.accounts", appLang), "tray.2") { onOpenDestination(.accounts) } - navButton(L10n.t("menu.profile", appLang), "person.circle") { onOpenDestination(.profile) } - navButton(L10n.t("groups.title", appLang), "person.2") { onOpenDestination(.groups) } - navButton(L10n.t("menu.server", appLang), "server.rack") { onOpenDestination(.server) } - navButton(L10n.t("menu.sync", appLang), "arrow.triangle.2.circlepath") { onSync() } - navButton(L10n.t("menu.logout", appLang), "rectangle.portrait.and.arrow.right", role: .destructive) { - appState.logout() - } - } - .padding(.horizontal, 12).padding(.vertical, 10) - } - } - - private func navButton(_ label: String, _ systemImage: String, role: ButtonRole? = nil, action: @escaping () -> Void) -> some View { - Button(role: role, action: action) { - VStack(spacing: 4) { - Image(systemName: systemImage).font(.system(size: 18)) - Text(label).font(.caption2).lineLimit(1) - } - .frame(width: 64) - .foregroundStyle(role == .destructive ? Color.red : Color.accentColor) + Button { onOpenMenu() } label: { + Label(L10n.t("menu.section.settings", appLang), systemImage: "gearshape") + .font(.body.weight(.medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) } .buttonStyle(.plain) + .foregroundStyle(Color.accentColor) + .padding(.horizontal, 16) + // Extra bottom inset so the button clears the home indicator and the + // screen's rounded corners. + .padding(.bottom, 20) } } diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index 24dd1a3..48e4ae0 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -34,7 +34,6 @@ struct CalendarHostView: View { @State private var groups: [CalGroup] = [] @State private var showNewBirthday = false @State private var showDrawer = false - @State private var drawerDestination: DrawerDestination? = nil private var titleString: String { if store.viewType == .month { @@ -63,8 +62,7 @@ struct CalendarHostView: View { api: api, store: store, groups: groups, onSwitchGroup: { g in closeDrawer(); switchGroup(g) }, onSelectView: { vt in store.viewType = vt; closeDrawer() }, - onOpenDestination: { dest in closeDrawer(); drawerDestination = dest }, - onSync: { closeDrawer(); Task { await syncFromServer(force: true) } }, + onOpenMenu: { closeDrawer(); showMenu = true }, onClose: { closeDrawer() } ) .frame(width: drawerWidth) @@ -92,15 +90,6 @@ struct CalendarHostView: View { ) } } - .sheet(item: $drawerDestination) { dest in - switch dest { - case .profile: ProfileView(api: api) - case .settings: SettingsView(api: api) - case .accounts: AccountsView(api: api) - case .groups: GroupsView(api: api) - case .server: ServerView() - } - } } private var drawerWidth: CGFloat { min(UIScreen.main.bounds.width - 40, 360) } diff --git a/Calendarr iOS/Views/CalendarFilterContent.swift b/Calendarr iOS/Views/CalendarFilterContent.swift index b534b79..654e8a2 100644 --- a/Calendarr iOS/Views/CalendarFilterContent.swift +++ b/Calendarr iOS/Views/CalendarFilterContent.swift @@ -33,12 +33,17 @@ struct CalendarFilterContent: View { } else { List { Section { - Button(L10n.t("filter.show_all", appLang)) { - hidden = []; store.setHiddenCalendars(hidden) - } - Button(L10n.t("filter.hide_all", appLang)) { - hidden = allKeys; store.setHiddenCalendars(hidden) + HStack(spacing: 10) { + Button(L10n.t("filter.show_all", appLang)) { + hidden = []; store.setHiddenCalendars(hidden) + } + .buttonStyle(.bordered).frame(maxWidth: .infinity) + Button(L10n.t("filter.hide_all", appLang)) { + hidden = allKeys; store.setHiddenCalendars(hidden) + } + .buttonStyle(.bordered).frame(maxWidth: .infinity) } + .listRowInsets(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12)) } let visibleLocals = localCalendars.filter { !banished.contains(CalendarStore.calendarKey(source: "local", calendarId: "\($0.id)")) From 108503ad532ff0644e11c8f452b6936440ab360e Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 15 Jul 2026 06:45:22 +0200 Subject: [PATCH 58/90] iOS: month-switching setting as a named dropdown (parity with web) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the boolean "month view as pages" toggle with a Monatswechsel dropdown: Continuous scroll vs. Page by page — same names/format as the web client. Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/Localization.swift | 6 ++++++ Calendarr iOS/Views/SettingsView.swift | 8 +++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index a74a724..6b1e67e 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -137,6 +137,9 @@ private let strings: [String: [String: String]] = [ "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", @@ -505,6 +508,9 @@ private let strings: [String: [String: String]] = [ "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", diff --git a/Calendarr iOS/Views/SettingsView.swift b/Calendarr iOS/Views/SettingsView.swift index fd1cbc5..65b654a 100644 --- a/Calendarr iOS/Views/SettingsView.swift +++ b/Calendarr iOS/Views/SettingsView.swift @@ -309,7 +309,13 @@ struct SettingsView: View { } HStack(spacing: 12) { syncIcon("month_view_paged") - Toggle(L10n.t("settings.month_paged", appLang), isOn: $monthViewPaged).tint(Color.accentColor) + 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") From daf03451206eb53f20c0726276b2e44d4b23e253 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 15 Jul 2026 06:46:32 +0200 Subject: [PATCH 59/90] iOS drawer: drop the grouped-section card behind show/hide-all listRowBackground(.clear) so only the two bordered button pills show, without the surrounding grouped-list capsule. Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Views/CalendarFilterContent.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Calendarr iOS/Views/CalendarFilterContent.swift b/Calendarr iOS/Views/CalendarFilterContent.swift index 654e8a2..ff97b50 100644 --- a/Calendarr iOS/Views/CalendarFilterContent.swift +++ b/Calendarr iOS/Views/CalendarFilterContent.swift @@ -44,6 +44,8 @@ struct CalendarFilterContent: View { .buttonStyle(.bordered).frame(maxWidth: .infinity) } .listRowInsets(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12)) + // No surrounding grouped-section card — just the two button pills. + .listRowBackground(Color.clear) } let visibleLocals = localCalendars.filter { !banished.contains(CalendarStore.calendarKey(source: "local", calendarId: "\($0.id)")) From 6881c68935d1b964217edbaf89bd9572765ea273 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 15 Jul 2026 16:19:29 +0200 Subject: [PATCH 60/90] iOS drawer: flat reorderable calendars, long-press menu, left-swipe close, header actions Testing feedback: - calendars are now one flat, drag-reorderable list (no per-source grouping); order is device-local (CalendarStore.calendarOrder, mirrors the web cal_order). Reordering is done via a 'Sort' toggle (native edit mode with drag handles). - banish + reminder-mute moved from row swipe actions to a long-press context menu, which frees the horizontal swipe: swiping the drawer left closes it. - the hamburger moved to the LEFT (drawer opens left); new device-local setting 'Hide menu button' (open via edge-swipe only), not synced. - the settings entry moved from the footer into the header as an icon, next to a restored manual-sync icon and the close button; the footer button is gone. Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Models/CalendarStore.swift | 43 ++++ Calendarr iOS/Models/Localization.swift | 6 + .../Views/Calendar/CalendarDrawer.swift | 37 ++-- .../Views/Calendar/CalendarHostView.swift | 26 ++- .../Views/CalendarFilterContent.swift | 200 ++++++++---------- Calendarr iOS/Views/SettingsView.swift | 3 + 6 files changed, 174 insertions(+), 141 deletions(-) diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index 41004b2..f1a723b 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -165,6 +165,49 @@ class CalendarStore { 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" diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index 6b1e67e..f577319 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -89,6 +89,7 @@ private let strings: [String: [String: String]] = [ "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", @@ -331,6 +332,8 @@ private let strings: [String: [String: String]] = [ "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", @@ -460,6 +463,7 @@ private let strings: [String: [String: String]] = [ "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", @@ -702,6 +706,8 @@ private let strings: [String: [String: String]] = [ "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", diff --git a/Calendarr iOS/Views/Calendar/CalendarDrawer.swift b/Calendarr iOS/Views/Calendar/CalendarDrawer.swift index 0fe320e..e562e50 100644 --- a/Calendarr iOS/Views/Calendar/CalendarDrawer.swift +++ b/Calendarr iOS/Views/Calendar/CalendarDrawer.swift @@ -9,6 +9,7 @@ struct CalendarDrawer: View { let onSwitchGroup: (CalGroup?) -> Void let onSelectView: (CalViewType) -> Void let onOpenMenu: () -> Void + let onSync: () -> Void let onClose: () -> Void @Environment(AppState.self) private var appState @@ -25,8 +26,6 @@ struct CalendarDrawer: View { } Divider() CalendarFilterContent(api: api, store: store) - Divider() - navFooter } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(Color(.systemBackground)) @@ -51,10 +50,22 @@ struct CalendarDrawer: View { .font(.caption).foregroundStyle(.secondary).lineLimit(1) } Spacer() - Button { onClose() } label: { - Image(systemName: "xmark").font(.system(size: 15, weight: .semibold)) + HStack(spacing: 18) { + Button { onSync() } label: { + Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 16, weight: .medium)) + } + .buttonStyle(.plain).foregroundStyle(Color.accentColor) + .accessibilityLabel(L10n.t("menu.sync", appLang)) + Button { onOpenMenu() } label: { + Image(systemName: "gearshape").font(.system(size: 16, weight: .medium)) + } + .buttonStyle(.plain).foregroundStyle(Color.accentColor) + .accessibilityLabel(L10n.t("menu.section.settings", appLang)) + Button { onClose() } label: { + Image(systemName: "xmark").font(.system(size: 15, weight: .semibold)) + } + .buttonStyle(.plain).foregroundStyle(.secondary) } - .buttonStyle(.plain).foregroundStyle(.secondary) } .padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 10) } @@ -111,20 +122,4 @@ struct CalendarDrawer: View { .buttonStyle(.plain) } - // MARK: – Nav footer (single entry into the full menu) - - private var navFooter: some View { - Button { onOpenMenu() } label: { - Label(L10n.t("menu.section.settings", appLang), systemImage: "gearshape") - .font(.body.weight(.medium)) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - } - .buttonStyle(.plain) - .foregroundStyle(Color.accentColor) - .padding(.horizontal, 16) - // Extra bottom inset so the button clears the home indicator and the - // screen's rounded corners. - .padding(.bottom, 20) - } } diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index 48e4ae0..6b11164 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -23,6 +23,8 @@ struct CalendarHostView: View { @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 @@ -63,6 +65,7 @@ struct CalendarHostView: View { 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) @@ -70,6 +73,16 @@ struct CalendarHostView: View { .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). @@ -169,15 +182,13 @@ struct CalendarHostView: View { .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) { - HStack(spacing: 8) { - Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout) - menuButton - } + Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout) } } .safeAreaInset(edge: .top, spacing: 0) { @@ -223,6 +234,8 @@ struct CalendarHostView: View { /// 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") @@ -235,7 +248,7 @@ struct CalendarHostView: View { .frame(width: 36, height: 36) } } - .padding(.leading, 6) + .padding(.leading, hideMenuButton ? 6 : 0) Spacer(minLength: 6) Text(titleString) .font(.headline) @@ -246,8 +259,7 @@ struct CalendarHostView: View { Button(L10n.t("nav.today", appLang)) { store.moveToToday() } .font(.callout).padding(.horizontal, 6) .lineLimit(1).fixedSize() - menuButton - .padding(.trailing, 2) + .padding(.trailing, 4) } .frame(height: 48) } diff --git a/Calendarr iOS/Views/CalendarFilterContent.swift b/Calendarr iOS/Views/CalendarFilterContent.swift index ff97b50..1838178 100644 --- a/Calendarr iOS/Views/CalendarFilterContent.swift +++ b/Calendarr iOS/Views/CalendarFilterContent.swift @@ -1,9 +1,17 @@ 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 exact same rows/logic. Filtering is purely -/// client-side (hidden keys in `CalendarStore`); server-managed sources also -/// reconcile their sidebar_hidden / reminders_enabled flags on load. +/// 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 @@ -19,6 +27,8 @@ struct CalendarFilterContent: View { @State private var banished: Set = [] @State private var reminderDisabled: Set = [] @State private var allKeys: Set = [] + @State private var rows: [CalRow] = [] + @State private var isSorting = false @State private var groupDetail: CalGroup? = nil @State private var hiddenGroup: Set = [] @@ -31,120 +41,71 @@ struct CalendarFilterContent: View { } else if allKeys.isEmpty { Text(L10n.t("filter.empty", appLang)).foregroundStyle(.secondary) } else { - List { - Section { - HStack(spacing: 10) { - Button(L10n.t("filter.show_all", appLang)) { - hidden = []; store.setHiddenCalendars(hidden) - } - .buttonStyle(.bordered).frame(maxWidth: .infinity) - Button(L10n.t("filter.hide_all", appLang)) { - hidden = allKeys; store.setHiddenCalendars(hidden) - } - .buttonStyle(.bordered).frame(maxWidth: .infinity) - } - .listRowInsets(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12)) - // No surrounding grouped-section card — just the two button pills. - .listRowBackground(Color.clear) - } - let visibleLocals = localCalendars.filter { - !banished.contains(CalendarStore.calendarKey(source: "local", calendarId: "\($0.id)")) - } - if !visibleLocals.isEmpty { - Section(L10n.t("accounts.local.header", appLang)) { - ForEach(visibleLocals) { cal in - row(name: cal.owned ? cal.name : (cal.sharedBy ?? cal.name), - colorHex: cal.color, - key: CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)"), - readOnly: !cal.owned && cal.permission != "read_write") + 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) } } } - ForEach(caldavAccounts) { acc in - let cals = (acc.calendars ?? []).filter { - !banished.contains(CalendarStore.calendarKey(source: "caldav", calendarId: "\($0.id)")) - } - if !cals.isEmpty { - Section(acc.name) { - ForEach(cals) { cal in - row(name: cal.name, - colorHex: cal.color ?? acc.color, - key: CalendarStore.calendarKey(source: "caldav", calendarId: "\(cal.id)")) - } - } - } - } - let visibleSubs = icalSubs.filter { - !banished.contains(CalendarStore.calendarKey(source: "ical", calendarId: "\($0.id)")) - } - if !visibleSubs.isEmpty { - Section(L10n.t("accounts.ical.header", appLang)) { - ForEach(visibleSubs) { sub in - row(name: sub.name, colorHex: sub.color, - key: CalendarStore.calendarKey(source: "ical", calendarId: "\(sub.id)")) - } - } - } - ForEach(googleAccounts) { acc in - let cals = (acc.calendars ?? []).filter { - !banished.contains(CalendarStore.calendarKey(source: "google", calendarId: "\($0.id)")) - } - if !cals.isEmpty { - Section(acc.email) { - ForEach(cals) { cal in - row(name: cal.name, - colorHex: cal.color ?? "#4285f4", - key: CalendarStore.calendarKey(source: "google", calendarId: "\(cal.id)")) - } - } - } - } - ForEach(haAccounts) { acc in - let cals = (acc.calendars ?? []).filter { - !banished.contains(CalendarStore.calendarKey(source: "homeassistant", calendarId: "\($0.id)")) - } - if !cals.isEmpty { - Section(acc.name) { - ForEach(cals) { cal in - row(name: cal.name, - colorHex: cal.color ?? "#46bdc6", - key: CalendarStore.calendarKey(source: "homeassistant", calendarId: "\(cal.id)")) - } - } - } - } - if !banished.isEmpty { - Section { - Text(L10n.t("filter.banished_footer", appLang)) - .font(.caption).foregroundStyle(.secondary) - } - } + .listStyle(.plain) + .environment(\.editMode, .constant(isSorting ? .active : .inactive)) } } } .task { await load() } } + // Above the list so it stays tappable even while the list is in edit mode. + private var controlBar: some View { + HStack(spacing: 8) { + Button(L10n.t("filter.show_all", appLang)) { + hidden = []; store.setHiddenCalendars(hidden) + } + .buttonStyle(.bordered) + Button(L10n.t("filter.hide_all", appLang)) { + hidden = allKeys; store.setHiddenCalendars(hidden) + } + .buttonStyle(.bordered) + Spacer() + Button(isSorting ? L10n.t("filter.done", appLang) : L10n.t("filter.sort", appLang)) { + withAnimation { isSorting.toggle() } + } + .buttonStyle(.bordered) + } + .padding(.horizontal, 16).padding(.vertical, 8) + } + + private func move(from source: IndexSet, to destination: Int) { + rows.move(fromOffsets: source, toOffset: destination) + store.setCalendarOrder(rows.map(\.id)) + } + @ViewBuilder - private func row(name: String, colorHex: String, key: String, readOnly: Bool = false) -> some View { - let isVisible = !hidden.contains(key) + private func row(_ cal: CalRow) -> some View { + let isVisible = !hidden.contains(cal.id) Button { - if isVisible { hidden.insert(key) } else { hidden.remove(key) } - store.setCalendarHidden(key, hidden: isVisible) + 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: colorHex)) + .fill(Color(hex: cal.colorHex)) .frame(width: 14, height: 14) .opacity(isVisible ? 1.0 : 0.35) - Text(name) + Text(cal.name) .foregroundStyle(isVisible ? .primary : .secondary) .strikethrough(!isVisible, color: .secondary) - if readOnly { + if cal.readOnly { Image(systemName: "lock.fill").font(.caption2).foregroundStyle(.secondary) } Spacer() - if reminderDisabled.contains(key) { + if reminderDisabled.contains(cal.id) { Image(systemName: "bell.slash").font(.caption).foregroundStyle(.secondary) } Image(systemName: isVisible ? "eye" : "eye.slash") @@ -153,22 +114,20 @@ struct CalendarFilterContent: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .swipeActions(edge: .leading, allowsFullSwipe: false) { - let disabled = reminderDisabled.contains(key) + .contextMenu { Button { - toggleReminders(forKey: key) + 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") } - .tint(.orange) - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { Button(role: .destructive) { - hidden.remove(key) - banished.insert(key) - store.setCalendarBanished(key, banished: true) - pushBanishToServer(key: key, hidden: true) + 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") } @@ -185,6 +144,8 @@ struct CalendarFilterContent: View { } } + // MARK: – Group overlay (unchanged: hide individual members / group calendar) + @ViewBuilder private var groupFilterList: some View { if let g = groupDetail { @@ -269,13 +230,26 @@ struct CalendarFilterContent: View { store.setReminderDisabledKeys(rd) reminderDisabled = rd - var keys = Set() - for cal in localCalendars { keys.insert(CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)")) } - for acc in caldavAccounts { for cal in acc.calendars ?? [] { keys.insert(CalendarStore.calendarKey(source: "caldav", calendarId: "\(cal.id)")) } } - for sub in icalSubs { keys.insert(CalendarStore.calendarKey(source: "ical", calendarId: "\(sub.id)")) } - for acc in googleAccounts { for cal in acc.calendars ?? [] { keys.insert(CalendarStore.calendarKey(source: "google", calendarId: "\(cal.id)")) } } - for acc in haAccounts { for cal in acc.calendars ?? [] { keys.insert(CalendarStore.calendarKey(source: "homeassistant", calendarId: "\(cal.id)")) } } - allKeys = keys + // 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 } diff --git a/Calendarr iOS/Views/SettingsView.swift b/Calendarr iOS/Views/SettingsView.swift index 65b654a..51b2372 100644 --- a/Calendarr iOS/Views/SettingsView.swift +++ b/Calendarr iOS/Views/SettingsView.swift @@ -3,6 +3,7 @@ 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" @@ -416,6 +417,8 @@ struct SettingsView: View { .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: { From 7981ced5ba92119cfea5eed89290ade932389274 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 15 Jul 2026 18:06:56 +0200 Subject: [PATCH 61/90] iOS drawer: declutter controls, bigger header, more top spacing - remove the show-all / hide-all buttons (rarely needed with a few calendars; the web has none). The calendar section now shows a 'Calendars' header + a slim Sort toggle. - larger header (avatar, name, action icons) with more padding - more vertical breathing room between header / view switcher / groups / list Co-Authored-By: Claude Opus 4.8 --- .../Views/Calendar/CalendarDrawer.swift | 24 +++++++++---------- .../Views/CalendarFilterContent.swift | 22 ++++++++--------- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/Calendarr iOS/Views/Calendar/CalendarDrawer.swift b/Calendarr iOS/Views/Calendar/CalendarDrawer.swift index e562e50..7bfa4c4 100644 --- a/Calendarr iOS/Views/Calendar/CalendarDrawer.swift +++ b/Calendarr iOS/Views/Calendar/CalendarDrawer.swift @@ -34,40 +34,40 @@ struct CalendarDrawer: View { // MARK: – Header private var header: some View { - HStack(spacing: 12) { + HStack(spacing: 14) { Circle() .fill(Color.accentColor) - .frame(width: 40, height: 40) + .frame(width: 48, height: 48) .overlay { Text(appState.username.prefix(1).uppercased()) - .font(.headline).foregroundStyle(.white) + .font(.title3.bold()).foregroundStyle(.white) } VStack(alignment: .leading, spacing: 2) { - Text(appState.username).font(.headline).lineLimit(1) + Text(appState.username).font(.title3.weight(.semibold)).lineLimit(1) Text(appState.serverURL .replacingOccurrences(of: "https://", with: "") .replacingOccurrences(of: "http://", with: "")) - .font(.caption).foregroundStyle(.secondary).lineLimit(1) + .font(.footnote).foregroundStyle(.secondary).lineLimit(1) } Spacer() - HStack(spacing: 18) { + HStack(spacing: 20) { Button { onSync() } label: { - Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 16, weight: .medium)) + 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: 16, weight: .medium)) + 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: 15, weight: .semibold)) + Image(systemName: "xmark").font(.system(size: 17, weight: .semibold)) } .buttonStyle(.plain).foregroundStyle(.secondary) } } - .padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 10) + .padding(.horizontal, 18).padding(.top, 20).padding(.bottom, 16) } // MARK: – View switcher @@ -88,7 +88,7 @@ struct CalendarDrawer: View { .buttonStyle(.plain) } } - .padding(.horizontal, 16).padding(.vertical, 8) + .padding(.horizontal, 16).padding(.vertical, 13) } } @@ -106,7 +106,7 @@ struct CalendarDrawer: View { selected: store.activeGroup?.id == g.id) { onSwitchGroup(g) } } } - .padding(.horizontal, 16).padding(.vertical, 8) + .padding(.horizontal, 16).padding(.vertical, 13) } } diff --git a/Calendarr iOS/Views/CalendarFilterContent.swift b/Calendarr iOS/Views/CalendarFilterContent.swift index 1838178..8b55dce 100644 --- a/Calendarr iOS/Views/CalendarFilterContent.swift +++ b/Calendarr iOS/Views/CalendarFilterContent.swift @@ -61,24 +61,22 @@ struct CalendarFilterContent: View { .task { await load() } } - // Above the list so it stays tappable even while the list is in edit mode. + // 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(spacing: 8) { - Button(L10n.t("filter.show_all", appLang)) { - hidden = []; store.setHiddenCalendars(hidden) - } - .buttonStyle(.bordered) - Button(L10n.t("filter.hide_all", appLang)) { - hidden = allKeys; store.setHiddenCalendars(hidden) - } - .buttonStyle(.bordered) + 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(.bordered) + .buttonStyle(.borderless) + .font(.subheadline) } - .padding(.horizontal, 16).padding(.vertical, 8) + .padding(.horizontal, 16).padding(.top, 6).padding(.bottom, 4) } private func move(from source: IndexSet, to destination: Int) { From 554ad425b14e61f98f087fca46e8b68c60b2051f Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 16 Jul 2026 11:20:21 +0200 Subject: [PATCH 62/90] Week view: render multi-day all-day events as continuous spanning bars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The all-day strip repeated a multi-day event once per day; now events span their start→end columns and pack into lanes (like the month view) via a GeometryReader layout. Co-Authored-By: Claude Opus 4.8 --- Calendarr iOS/Views/Calendar/WeekView.swift | 81 ++++++++++++++------- 1 file changed, 55 insertions(+), 26 deletions(-) diff --git a/Calendarr iOS/Views/Calendar/WeekView.swift b/Calendarr iOS/Views/Calendar/WeekView.swift index cf42141..03fe9f3 100644 --- a/Calendarr iOS/Views/Calendar/WeekView.swift +++ b/Calendarr iOS/Views/Calendar/WeekView.swift @@ -72,37 +72,66 @@ struct WeekView: View { // 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 { - HStack(spacing: 0) { - Spacer().frame(width: timeColumnWidth) - ForEach(weekDays, id: \.self) { day in - let dayEvs = allDayEvents.filter { ev in - let ds = cal.startOfDay(for: day) - let de = cal.date(byAdding: .day, value: 1, to: ds)! - return ev.startDate < de && ev.endDate > ds - } - VStack(spacing: 1) { - ForEach(dayEvs.prefix(2)) { ev in - Button { onEventTap(ev) } label: { - EventLabel(event: ev) - .font(.system(size: 9, weight: .medium)) - .foregroundStyle(.white) - .lineLimit(1) - .frame(maxWidth: .infinity) - .padding(.vertical, 2) - .background(Color(hex: ev.effectiveColor)) - .clipShape(RoundedRectangle(cornerRadius: 2)) - } - .buttonStyle(.plain) + 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)) } - } - .padding(.horizontal, 1) - .frame(maxWidth: .infinity) - .overlay(alignment: .trailing) { - Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(width: 0.5) + .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() } } From 781ccd1752b47ac3ea733ab8383059e71584ceab Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 10 Aug 2026 16:31:06 +0200 Subject: [PATCH 63/90] Make Keychain and shared-container state correct before the Mac port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two failures here are silent rather than loud, which is why they have gone unnoticed on iOS and would have been much harder to diagnose on a Mac. The old `enum Keychain` discarded all four OSStatus results. On Mac Catalyst a missing `keychain-access-groups` entitlement makes SecItem calls fail with errSecMissingEntitlement, and because nothing checked, that was indistinguishable from "no token stored" — the user would be signed out on every launch, with no error anywhere. KeychainStore now checks every status, distinguishes errSecItemNotFound from real failures, and sets kSecUseDataProtectionKeychain so macOS selects the modern entitlement-gated keychain instead of the legacy login keychain. Adding the entitlement moves the default access group to the first array entry, so that entry is deliberately the app's own group: existing tokens keep resolving with an unqualified query and nobody is signed out. loadToken() then migrates forward in three steps — shared group, own default group, and the pre-Keychain UserDefaults copy. If the entitlement is not provisioned yet, KeychainStore falls back to the default group rather than throwing. A hard failure would make the app unusable for everyone whose provisioning lags; the fallback asserts in DEBUG instead, so a misconfiguration is loud in development and survivable in production. Separately, logout() left widget-cache.json in the App Group container, so widgets kept rendering the signed-out user's events indefinitely. That is an existing iOS bug, and it would have leaked the same data to any other app reading the container. WidgetStore.clear() now removes both cache files and reloads the timelines. Co-Authored-By: Claude Opus 5 --- Calendarr iOS/Calendarr iOS.entitlements | 10 ++ Calendarr iOS/CalendarrApp.swift | 87 +++++++------- Calendarr iOS/Services/KeychainStore.swift | 131 +++++++++++++++++++++ Shared/WidgetData.swift | 11 ++ 4 files changed, 194 insertions(+), 45 deletions(-) create mode 100644 Calendarr iOS/Services/KeychainStore.swift diff --git a/Calendarr iOS/Calendarr iOS.entitlements b/Calendarr iOS/Calendarr iOS.entitlements index bb88d59..5a5e5d8 100644 --- a/Calendarr iOS/Calendarr iOS.entitlements +++ b/Calendarr iOS/Calendarr iOS.entitlements @@ -6,5 +6,15 @@ group.com.scarriffleservices.calendarr + + keychain-access-groups + + $(AppIdentifierPrefix)com.scarriffleservices.calendarr.ios + $(AppIdentifierPrefix)com.scarriffleservices.calendarr.shared + diff --git a/Calendarr iOS/CalendarrApp.swift b/Calendarr iOS/CalendarrApp.swift index fe39d08..ede36bf 100644 --- a/Calendarr iOS/CalendarrApp.swift +++ b/Calendarr iOS/CalendarrApp.swift @@ -1,5 +1,4 @@ import SwiftUI -import Security @main struct CalendarrApp: App { @@ -25,17 +24,45 @@ class AppState { init() { serverURL = UserDefaults.standard.string(forKey: "serverURL") ?? "" - // Migrate a token previously kept in UserDefaults into the Keychain once, - // so existing logins survive the change without re-authenticating. - if let legacy = UserDefaults.standard.string(forKey: "authToken"), !legacy.isEmpty { - Keychain.set(legacy, for: "authToken") - UserDefaults.standard.removeObject(forKey: "authToken") - } - authToken = Keychain.get("authToken") ?? "" + 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()) } @@ -46,7 +73,7 @@ class AppState { authToken = token username = user isAdmin = admin - Keychain.set(token, for: "authToken") // secret → Keychain, not UserDefaults + try? KeychainStore.set(token, for: "authToken") // secret → Keychain, not UserDefaults UserDefaults.standard.set(user, forKey: "username") UserDefaults.standard.set(admin, forKey: "isAdmin") } @@ -55,10 +82,14 @@ class AppState { authToken = "" username = "" isAdmin = false - Keychain.set(nil, for: "authToken") - UserDefaults.standard.removeObject(forKey: "authToken") // clear any legacy copy + try? KeychainStore.set(nil, for: "authToken") + try? KeychainStore.set(nil, for: "authToken", accessGroup: nil) // pre-entitlement copy + UserDefaults.standard.removeObject(forKey: "authToken") // pre-Keychain copy UserDefaults.standard.removeObject(forKey: "username") UserDefaults.standard.removeObject(forKey: "isAdmin") + // The shared container outlives the session, so it has to be cleared + // explicitly — otherwise widgets keep showing the signed-out user's data. + WidgetStore.clear() } func resetServer() { @@ -67,37 +98,3 @@ class AppState { UserDefaults.standard.removeObject(forKey: "serverURL") } } - -/// Minimal Keychain wrapper for secrets (the auth bearer token). Values are -/// stored as generic passwords, accessible after first unlock. -enum Keychain { - private static let service = "Calendarr" - - static func set(_ value: String?, for key: String) { - let base: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - ] - SecItemDelete(base as CFDictionary) - guard let value, let data = value.data(using: .utf8) else { return } - var add = base - add[kSecValueData as String] = data - add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock - SecItemAdd(add as CFDictionary, nil) - } - - static func get(_ key: String) -> String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - var out: AnyObject? - guard SecItemCopyMatching(query as CFDictionary, &out) == errSecSuccess, - let data = out as? Data else { return nil } - return String(data: data, encoding: .utf8) - } -} diff --git a/Calendarr iOS/Services/KeychainStore.swift b/Calendarr iOS/Services/KeychainStore.swift new file mode 100644 index 0000000..f5faf98 --- /dev/null +++ b/Calendarr iOS/Services/KeychainStore.swift @@ -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)" + } + } +} diff --git a/Shared/WidgetData.swift b/Shared/WidgetData.swift index a623aed..b78bcf6 100644 --- a/Shared/WidgetData.swift +++ b/Shared/WidgetData.swift @@ -152,6 +152,17 @@ enum WidgetStore { return (try? JSONDecoder().decode([WidgetCalendar].self, from: data)) ?? [] } + /// Drop the cached snapshot and calendar list. Called on logout and on + /// server reset: without this the files survive, and widgets — plus any + /// other app reading the group container — keep rendering the previous + /// user's events indefinitely. + static func clear() { + for url in [cacheURL, calendarsURL].compactMap({ $0 }) { + try? FileManager.default.removeItem(at: url) + } + WidgetTimelineNotifier.reload() + } + /// Rewrite the existing snapshot with the latest colour / language values /// from UserDefaults. Used when the user tweaks an appearance setting and /// we want the widgets to refresh immediately, without needing a new event From c9bd42aae59e3d4ec05d7009196b5e632ec759cf Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 10 Aug 2026 16:37:35 +0200 Subject: [PATCH 64/90] Unify app and widget version numbers; raise widget to iOS 26 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app shipped 3.5 (6) while the widget shipped 1.0.1 (2). Apple requires an embedded appex to match its host's CFBundleShortVersionString and CFBundleVersion, so this was a latent App Store validation failure that only surfaces at upload — and it would surface on the Mac submission too. Both keys now live at project level, where they cannot drift apart again. The widget also targeted iOS 17 while the container app requires 26.0, so the extension could never have been installed on 17 anyway. Raising it to 26.0 removes the availability skew that Mac Catalyst would otherwise inherit as macOS 14 vs macOS 26. Verified: app and appex Info.plists now both report 3.5 / 7 / 26.0. Co-Authored-By: Claude Opus 5 --- Calendarr iOS.xcodeproj/project.pbxproj | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index d6dac41..0e47383 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -314,12 +314,10 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = PP34X97WS3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.5; MACOSX_DEPLOYMENT_TARGET = 26.5; - MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = "com.local.scarriffle.Calendarr-iOSTests"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -341,12 +339,10 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = PP34X97WS3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.5; MACOSX_DEPLOYMENT_TARGET = 26.5; - MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = "com.local.scarriffle.Calendarr-iOSTests"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -415,7 +411,9 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + CURRENT_PROJECT_VERSION = 7; + MARKETING_VERSION = 3.5; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -473,7 +471,9 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + CURRENT_PROJECT_VERSION = 7; + MARKETING_VERSION = 3.5; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; @@ -491,7 +491,6 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = PP34X97WS3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -512,7 +511,6 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.5; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -536,7 +534,6 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = PP34X97WS3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -557,7 +554,6 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.5; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -580,19 +576,17 @@ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CODE_SIGN_ENTITLEMENTS = CalendarrWidgets/CalendarrWidgets.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = PP34X97WS3; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = CalendarrWidgets/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Calendarr Widgets"; INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices"; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.CalendarrWidgets; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -615,19 +609,17 @@ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CODE_SIGN_ENTITLEMENTS = CalendarrWidgets/CalendarrWidgets.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = PP34X97WS3; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = CalendarrWidgets/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Calendarr Widgets"; INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices"; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.CalendarrWidgets; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; From 7fa8dbdbdf2ed356c3ea6e01cfd5b1afa234702c Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 10 Aug 2026 16:57:10 +0200 Subject: [PATCH 65/90] Enable Mac Catalyst on both targets; make the widget intent parameter optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supported Destinations now includes Mac (Mac Catalyst) on the app and the widget extension. Catalyst requires the iPad device family, so both targets move to TARGETED_DEVICE_FAMILY "1,2". The first Catalyst build failed on CalendarSelectionIntent: WidgetConfigurationIntent requires every @Parameter to be optional, and the macOS SDK enforces that where the iOS one lets a bare array through. selectedCalendars is now optional. Behaviour is unchanged — the timeline provider already treated an empty selection as "show all calendars", so nil folds into the same path. Verified: builds for both Mac Catalyst and iOS Simulator. Co-Authored-By: Claude Opus 5 --- Calendarr iOS.xcodeproj/project.pbxproj | 24 +++++++++---------- CalendarrWidgets/CalendarIntent.swift | 9 +++++-- .../CalendarrTimelineProvider.swift | 4 ++-- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index 0e47383..b2b3269 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -393,6 +393,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 7; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -411,10 +412,9 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - CURRENT_PROJECT_VERSION = 7; - MARKETING_VERSION = 3.5; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 3.5; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -459,6 +459,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 7; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -471,10 +472,9 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - CURRENT_PROJECT_VERSION = 7; - MARKETING_VERSION = 3.5; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 3.5; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; @@ -515,7 +515,7 @@ PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SUPPORTS_MACCATALYST = NO; + SUPPORTS_MACCATALYST = YES; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; @@ -523,7 +523,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; @@ -558,7 +558,7 @@ PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SUPPORTS_MACCATALYST = NO; + SUPPORTS_MACCATALYST = YES; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; @@ -566,7 +566,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; @@ -592,14 +592,14 @@ SDKROOT = iphoneos; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SUPPORTS_MACCATALYST = NO; + SUPPORTS_MACCATALYST = YES; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; @@ -625,13 +625,13 @@ SDKROOT = iphoneos; SKIP_INSTALL = YES; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; - SUPPORTS_MACCATALYST = NO; + SUPPORTS_MACCATALYST = YES; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; diff --git a/CalendarrWidgets/CalendarIntent.swift b/CalendarrWidgets/CalendarIntent.swift index 6c27d8a..78e9a81 100644 --- a/CalendarrWidgets/CalendarIntent.swift +++ b/CalendarrWidgets/CalendarIntent.swift @@ -32,11 +32,16 @@ struct CalendarEntityQuery: EntityQuery { } /// Widget configuration intent: lets the user pick which calendars to show. -/// An empty selection means "show all calendars" (the default). +/// 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] + var selectedCalendars: [CalendarAppEntity]? } diff --git a/CalendarrWidgets/CalendarrTimelineProvider.swift b/CalendarrWidgets/CalendarrTimelineProvider.swift index fcb6902..d091007 100644 --- a/CalendarrWidgets/CalendarrTimelineProvider.swift +++ b/CalendarrWidgets/CalendarrTimelineProvider.swift @@ -38,8 +38,8 @@ struct CalendarrTimelineProvider: AppIntentTimelineProvider { private func filtered(_ snapshot: WidgetSnapshot?, by config: CalendarSelectionIntent) -> WidgetSnapshot? { guard let snapshot else { return nil } - let ids = config.selectedCalendars.map { $0.id } - guard !ids.isEmpty else { return snapshot } // empty = show all + let ids = (config.selectedCalendars ?? []).map { $0.id } + guard !ids.isEmpty else { return snapshot } // nothing selected = show all let keep = Set(ids) return WidgetSnapshot( writtenAt: snapshot.writtenAt, From 0d2ad6e0214e89f490022ee2499d2ec392fc6529 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 10 Aug 2026 17:03:33 +0200 Subject: [PATCH 66/90] Give Mac Catalyst its own App Group prefix and sandbox entitlements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The App Group identifier registered in the portal does not change, but the string the runtime expects does: macOS and Mac Catalyst require the Team ID prefix, iOS forbids it. CalendarrAppGroup now resolves the right one per platform. Getting this wrong is the worst failure mode in the whole port — containerURL() returns nil, every snapshot read and write quietly no-ops, and the widgets show placeholder content forever with no error anywhere. A DEBUG assertion now makes that loud during development. Because the two platforms need different values, they need different entitlements files — listing both strings in one file breaks iOS provisioning on the unregistered prefixed value. Selected via CODE_SIGN_ENTITLEMENTS[sdk=macosx*], verified to resolve correctly for both destinations. The Catalyst entitlements are written App Store grade from the start, so one configuration serves both the Mac App Store and a notarized DMG: sandbox, network client, Contacts (birthday import), user-selected files (.ics import and export), the prefixed App Group, and keychain sharing. Deliberately absent: files.downloads, network.server, device.*, temporary-exception.* — nothing needs them and each is App Review friction. ENABLE_HARDENED_RUNTIME is required for notarization and ignored by the App Store, so it is safe to set unconditionally. Verified: builds for both Mac Catalyst and iOS Simulator. Co-Authored-By: Claude Opus 5 --- Calendarr iOS.xcodeproj/project.pbxproj | 6 +++ .../Calendarr iOS-Catalyst.entitlements | 46 +++++++++++++++++++ .../CalendarrWidgets-Catalyst.entitlements | 19 ++++++++ Shared/WidgetData.swift | 40 ++++++++++++++-- 4 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 Calendarr iOS/Calendarr iOS-Catalyst.entitlements create mode 100644 CalendarrWidgets/CalendarrWidgets-Catalyst.entitlements diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index b2b3269..a3e28b3 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -490,10 +490,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements"; + "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Calendarr iOS/Calendarr iOS-Catalyst.entitlements"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = PP34X97WS3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_KEY_CFBundleDisplayName = Calendarr; INFOPLIST_KEY_CFBundleName = Calendarr; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; @@ -533,10 +535,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements"; + "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Calendarr iOS/Calendarr iOS-Catalyst.entitlements"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = PP34X97WS3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_KEY_CFBundleDisplayName = Calendarr; INFOPLIST_KEY_CFBundleName = Calendarr; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; @@ -575,6 +579,7 @@ buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CODE_SIGN_ENTITLEMENTS = CalendarrWidgets/CalendarrWidgets.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "CalendarrWidgets/CalendarrWidgets-Catalyst.entitlements"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = PP34X97WS3; GENERATE_INFOPLIST_FILE = NO; @@ -608,6 +613,7 @@ buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CODE_SIGN_ENTITLEMENTS = CalendarrWidgets/CalendarrWidgets.entitlements; + "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "CalendarrWidgets/CalendarrWidgets-Catalyst.entitlements"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = PP34X97WS3; GENERATE_INFOPLIST_FILE = NO; diff --git a/Calendarr iOS/Calendarr iOS-Catalyst.entitlements b/Calendarr iOS/Calendarr iOS-Catalyst.entitlements new file mode 100644 index 0000000..f8ac65f --- /dev/null +++ b/Calendarr iOS/Calendarr iOS-Catalyst.entitlements @@ -0,0 +1,46 @@ + + + + + + com.apple.security.app-sandbox + + + + com.apple.security.network.client + + + + com.apple.security.personal-information.addressbook + + + + com.apple.security.files.user-selected.read-write + + + + com.apple.security.application-groups + + PP34X97WS3.group.com.scarriffleservices.calendarr + + + + keychain-access-groups + + $(AppIdentifierPrefix)com.scarriffleservices.calendarr.ios + $(AppIdentifierPrefix)com.scarriffleservices.calendarr.shared + + + diff --git a/CalendarrWidgets/CalendarrWidgets-Catalyst.entitlements b/CalendarrWidgets/CalendarrWidgets-Catalyst.entitlements new file mode 100644 index 0000000..8d85469 --- /dev/null +++ b/CalendarrWidgets/CalendarrWidgets-Catalyst.entitlements @@ -0,0 +1,19 @@ + + + + + + com.apple.security.app-sandbox + + + + com.apple.security.application-groups + + PP34X97WS3.group.com.scarriffleservices.calendarr + + + diff --git a/Shared/WidgetData.swift b/Shared/WidgetData.swift index b78bcf6..0a4aea0 100644 --- a/Shared/WidgetData.swift +++ b/Shared/WidgetData.swift @@ -4,9 +4,30 @@ import WidgetKit #endif /// App-Group identifier shared between the main app and the widget extension. -/// IMPORTANT: This must match the App Group capability in BOTH targets -/// and the App Group ID registered in the Apple Developer Portal. -let widgetAppGroupID = "group.com.scarriffleservices.calendarr" +/// +/// IMPORTANT: this must stay byte-identical to `com.apple.security.application-groups` +/// in the matching .entitlements file for the platform being built, and match the +/// App Group registered in the Apple Developer portal. +/// +/// The identifier registered in the portal never changes — only the string the +/// *runtime* expects does. macOS (including Mac Catalyst) requires the Team ID +/// prefix; iOS forbids it. Get this wrong and `containerURL(forSecurityApplication‑ +/// GroupIdentifier:)` returns nil, every read and write below quietly no-ops, and +/// the widgets show placeholder content forever with no error anywhere. +enum CalendarrAppGroup { + /// As registered in the Apple Developer portal. + static let unprefixed = "group.com.scarriffleservices.calendarr" + /// Team ID — the value `$(AppIdentifierPrefix)` expands to at build time. + static let teamID = "PP34X97WS3" + + #if os(macOS) || targetEnvironment(macCatalyst) + static let current = "\(teamID).\(unprefixed)" + #else + static let current = unprefixed + #endif +} + +let widgetAppGroupID = CalendarrAppGroup.current /// Lightweight calendar descriptor stored alongside the event cache so the /// widget configuration intent can offer calendar options without a network call. @@ -108,7 +129,18 @@ enum WidgetStore { private static let calendarsFilename = "widget-calendars.json" private static var containerURL: URL? { - FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: widgetAppGroupID) + let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: widgetAppGroupID) + #if DEBUG + if url == nil { + // A nil container is always a build-configuration bug: the entitlement + // is missing, or its value does not match widgetAppGroupID. It is + // otherwise completely silent, so make it loud while developing. + assertionFailure("App Group container unavailable for \(widgetAppGroupID) — " + + "check com.apple.security.application-groups in the " + + "entitlements for this platform.") + } + #endif + return url } private static var cacheURL: URL? { From 84f5c0051695cc6a40383276891f385d4345579a Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 10 Aug 2026 17:19:55 +0200 Subject: [PATCH 67/90] Keep iOS iPhone-only; give the iPad family to Mac Catalyst alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enabling Mac Catalyst pulled in the iPad device family, which made App Store Connect reject the iOS build with error 90474: an iPad-capable bundle must declare all four interface orientations to support iPad multitasking, and this app declared two. Catalyst genuinely needs the iPad idiom, but only for its own build. A per-SDK condition gives macOS family 2 while iOS keeps family 1 — verified to resolve correctly for both destinations, and Catalyst still builds with family 2 alone. The shipped iOS bundle is back to UIDeviceFamily [1], exactly as before the port started. The iPad orientation key is removed rather than extended. With an iPhone-only iOS build it is ignored, and macOS ignores orientation keys entirely, so it was dead configuration that only served to trip the validator. Supporting iPad properly is a deliberate piece of work — the layout is portrait-only with no size-class handling — and is better done on purpose than inherited as a side effect of the Mac port. Verified: builds for both Mac Catalyst and iOS Simulator; built bundle reports UIDeviceFamily [1] and portrait-only with no iPad orientation key. Co-Authored-By: Claude Opus 5 --- Calendarr iOS.xcodeproj/project.pbxproj | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index a3e28b3..705cf89 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -507,7 +507,6 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -525,7 +524,8 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; + "TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2; }; name = Debug; }; @@ -552,7 +552,6 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -570,7 +569,8 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; + "TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2; }; name = Release; }; @@ -604,7 +604,8 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; + "TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2; }; name = Debug; }; @@ -637,7 +638,8 @@ SWIFT_COMPILATION_MODE = wholemodule; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; + "TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2; }; name = Release; }; From d1da8de06a5eab32aaaac03a776e7a6ccec49395 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 10 Aug 2026 17:42:00 +0200 Subject: [PATCH 68/90] Read and write the widget snapshot through CalendarrCore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The snapshot format was an internal detail shared between the app and its own widget extension. A second Mac app needs the same data, and the way to give it that is a documented contract rather than a format it reverse-engineers and then drifts from. Both targets now link CalendarrCore and go through SnapshotStore. Shared/WidgetData.swift becomes a thin facade. The typealiases and the flat colour accessors exist so the ~70 existing call sites across the app and the widget views compile unchanged; they are a migration convenience, not a design. The snapshot now carries what a reader outside this app actually needs: coverageStart / coverageEnd, because the published window is ~7 days back and ~42 ahead. Outside it the snapshot holds no information, which is not the same as holding no events — and only the writer knows where that edge is. Without it a consumer renders a convincingly empty March and is simply wrong. isLoggedIn plus a session record, so a reader can say "sign in to Calendarr" rather than "open Calendarr once". The events are deleted on sign-out, so the absence of a cache alone cannot tell those two apart. writerVersion, purely so a mismatch between the two apps is diagnosable. The coverage constants move to SnapshotCoverage, so the writer and the code that reconstructs the window for older files can no longer disagree about it. CalendarStore needs an explicit `import CalendarrCore` because the app target builds with SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY, which requires the defining module to be imported directly rather than picked up transitively. Verified: builds for iOS and Mac Catalyst; CalendarrKit's 11 tests pass. Co-Authored-By: Claude Opus 5 --- Calendarr iOS.xcodeproj/project.pbxproj | 31 +- .../xcschemes/xcschememanagement.plist | 2 +- Calendarr iOS/CalendarrApp.swift | 17 ++ Calendarr iOS/Models/CalendarStore.swift | 27 +- .../CalendarrTimelineProvider.swift | 19 +- Shared/WidgetData.swift | 273 ++++++------------ 6 files changed, 162 insertions(+), 207 deletions(-) diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index 705cf89..dc282ad 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ D0001A0CABCDEF0100AB5001 /* CalendarrWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + CA1E4D0A0002000000000001 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000001 /* CalendarrCore */; }; + CA1E4D0A0002000000000002 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000002 /* CalendarrCore */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -95,6 +97,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + CA1E4D0A0002000000000001 /* CalendarrCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -102,6 +105,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + CA1E4D0A0002000000000002 /* CalendarrCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -175,6 +179,7 @@ ); name = "Calendarr iOS"; packageProductDependencies = ( + CA1E4D0A0001000000000001 /* CalendarrCore */, ); productName = "Calendarr iOS"; productReference = C0000B01FC4E10100AB5001 /* Calendarr iOS.app */; @@ -198,6 +203,7 @@ ); name = CalendarrWidgets; packageProductDependencies = ( + CA1E4D0A0001000000000002 /* CalendarrCore */, ); productName = CalendarrWidgets; productReference = D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */; @@ -235,6 +241,9 @@ ); mainGroup = C0000201FB4E10100AB5001; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 39450EFC302A266A0090DDD7 /* XCLocalSwiftPackageReference "../CalendarrKit" */, + ); preferredProjectObjectVersion = 77; productRefGroup = C0000C01FB4E10100AB5001 /* Products */; projectDirPath = ""; @@ -493,9 +502,9 @@ "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Calendarr iOS/Calendarr iOS-Catalyst.entitlements"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = PP34X97WS3; + ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_KEY_CFBundleDisplayName = Calendarr; INFOPLIST_KEY_CFBundleName = Calendarr; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; @@ -538,9 +547,9 @@ "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "Calendarr iOS/Calendarr iOS-Catalyst.entitlements"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = PP34X97WS3; + ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_KEY_CFBundleDisplayName = Calendarr; INFOPLIST_KEY_CFBundleName = Calendarr; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; @@ -683,6 +692,24 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCSwiftPackageProductDependency section */ + CA1E4D0A0001000000000001 /* CalendarrCore */ = { + isa = XCSwiftPackageProductDependency; + productName = CalendarrCore; + }; + CA1E4D0A0001000000000002 /* CalendarrCore */ = { + isa = XCSwiftPackageProductDependency; + productName = CalendarrCore; + }; +/* End XCSwiftPackageProductDependency section */ + +/* Begin XCLocalSwiftPackageReference section */ + 39450EFC302A266A0090DDD7 /* XCLocalSwiftPackageReference "../CalendarrKit" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../CalendarrKit; + }; +/* End XCLocalSwiftPackageReference section */ }; rootObject = C0000301FB4E10100AB5001 /* Project object */; } diff --git a/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist b/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist index c7f1ae5..af12f91 100644 --- a/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist @@ -12,7 +12,7 @@ CalendarrWidgets.xcscheme_^#shared#^_ orderHint - 1 + 2 SuppressBuildableAutocreation diff --git a/Calendarr iOS/CalendarrApp.swift b/Calendarr iOS/CalendarrApp.swift index ede36bf..904c13b 100644 --- a/Calendarr iOS/CalendarrApp.swift +++ b/Calendarr iOS/CalendarrApp.swift @@ -76,6 +76,17 @@ class AppState { 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() { @@ -89,12 +100,18 @@ class AppState { UserDefaults.standard.removeObject(forKey: "isAdmin") // The shared container outlives the session, so it has to be cleared // explicitly — otherwise widgets keep showing the signed-out user's data. + // The session record stays behind, flagged signed-out, so a reader can + // say "sign in to Calendarr" rather than "open Calendarr once". WidgetStore.clear() + publishSession() } func resetServer() { 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() } } diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index f1a723b..05e68e4 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -1,5 +1,6 @@ import Foundation import SwiftUI +import CalendarrCore extension Notification.Name { /// Posted whenever the persistent "banished calendars" set is mutated from @@ -579,8 +580,12 @@ class CalendarStore { let now = Date() // Include the week before today so widgets that show the current week // (e.g. "This Week", "Up Next + Calendar") have data for Monday–today. - let from = cal.date(byAdding: .day, value: -7, to: cal.startOfDay(for: now)) ?? now - let to = cal.date(byAdding: .day, value: 42, to: cal.startOfDay(for: now)) ?? from + // 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. @@ -604,7 +609,7 @@ class CalendarStore { && !banishedCalendarKeys.contains(key) } .sorted { $0.startDate < $1.startDate } - .prefix(500) + .prefix(SnapshotCoverage.maxEvents) .map { ev in WidgetEvent(id: ev.id, title: ev.title, @@ -615,19 +620,9 @@ class CalendarStore { location: ev.location, calendarKey: Self.calendarKey(source: ev.source, calendarId: ev.calendarId)) } - let defaults = UserDefaults.standard - let snap = WidgetSnapshot( - writtenAt: now, - events: Array(visible), - todayColorHex: defaults.string(forKey: "todayColor") ?? "#4285f4", - textColorHex: defaults.string(forKey: "textColor") ?? "#FFFFFF", - backgroundColorHex: defaults.string(forKey: "backgroundColor") ?? "#000000", - lineColorHex: defaults.string(forKey: "lineColor") ?? "#3A3A52", - primaryColorHex: defaults.string(forKey: "primaryColor") ?? "#4285f4", - accentColorHex: defaults.string(forKey: "accentColor") ?? "#ea4335", - language: defaults.string(forKey: "appLanguage") ?? "system" - ) - WidgetStore.write(snap) + WidgetStore.write(WidgetStore.makeSnapshot(events: Array(visible), + coverageStart: from, + coverageEnd: to)) WidgetTimelineNotifier.reload() } diff --git a/CalendarrWidgets/CalendarrTimelineProvider.swift b/CalendarrWidgets/CalendarrTimelineProvider.swift index d091007..c3be55a 100644 --- a/CalendarrWidgets/CalendarrTimelineProvider.swift +++ b/CalendarrWidgets/CalendarrTimelineProvider.swift @@ -41,16 +41,17 @@ struct CalendarrTimelineProvider: AppIntentTimelineProvider { let ids = (config.selectedCalendars ?? []).map { $0.id } guard !ids.isEmpty else { return snapshot } // nothing selected = show all let keep = Set(ids) + // Filtering narrows the events but not the window: the snapshot still + // speaks for the same range, it just has fewer calendars in it. return WidgetSnapshot( - writtenAt: snapshot.writtenAt, - events: snapshot.events.filter { keep.contains($0.calendarKey) }, - todayColorHex: snapshot.todayColorHex, - textColorHex: snapshot.textColorHex, - backgroundColorHex: snapshot.backgroundColorHex, - lineColorHex: snapshot.lineColorHex, - primaryColorHex: snapshot.primaryColorHex, - accentColorHex: snapshot.accentColorHex, - language: snapshot.language + writtenAt: snapshot.writtenAt, + coverageStart: snapshot.coverageStart, + coverageEnd: snapshot.coverageEnd, + isLoggedIn: snapshot.isLoggedIn, + writerVersion: snapshot.writerVersion, + events: snapshot.events.filter { keep.contains($0.calendarKey) }, + theme: snapshot.theme, + language: snapshot.language ) } } diff --git a/Shared/WidgetData.swift b/Shared/WidgetData.swift index 0a4aea0..d23de8b 100644 --- a/Shared/WidgetData.swift +++ b/Shared/WidgetData.swift @@ -1,221 +1,136 @@ import Foundation +@_spi(Writer) import CalendarrCore #if canImport(WidgetKit) import WidgetKit #endif -/// App-Group identifier shared between the main app and the widget extension. -/// -/// IMPORTANT: this must stay byte-identical to `com.apple.security.application-groups` -/// in the matching .entitlements file for the platform being built, and match the -/// App Group registered in the Apple Developer portal. -/// -/// The identifier registered in the portal never changes — only the string the -/// *runtime* expects does. macOS (including Mac Catalyst) requires the Team ID -/// prefix; iOS forbids it. Get this wrong and `containerURL(forSecurityApplication‑ -/// GroupIdentifier:)` returns nil, every read and write below quietly no-ops, and -/// the widgets show placeholder content forever with no error anywhere. -enum CalendarrAppGroup { - /// As registered in the Apple Developer portal. - static let unprefixed = "group.com.scarriffleservices.calendarr" - /// Team ID — the value `$(AppIdentifierPrefix)` expands to at build time. - static let teamID = "PP34X97WS3" - - #if os(macOS) || targetEnvironment(macCatalyst) - static let current = "\(teamID).\(unprefixed)" - #else - static let current = unprefixed - #endif -} +// The snapshot types and the App Group plumbing now live in CalendarrCore, so +// that a second app from this team reads the exact same format instead of +// reimplementing it and drifting. +// +// These aliases keep the ~70 existing call sites in the app and the widget +// compiling unchanged. They are a migration convenience, not a design: new code +// should use the CalendarrCore names directly. +typealias WidgetEvent = SnapshotEvent +typealias WidgetCalendar = SnapshotCalendar +typealias WidgetSnapshot = CalendarrSnapshot +/// The App Group identifier for this platform. See `CalendarrAppGroup`. let widgetAppGroupID = CalendarrAppGroup.current -/// Lightweight calendar descriptor stored alongside the event cache so the -/// widget configuration intent can offer calendar options without a network call. -struct WidgetCalendar: Codable, Identifiable, Hashable { - let id: String // calendarKey ("source-calendarId") - let name: String - let colorHex: String -} - -/// Lightweight event representation that lives inside the widget cache. -/// We strip everything the widget doesn't need (notes, calendar IDs, URLs). -struct WidgetEvent: Codable, Hashable, Identifiable { - let id: String - let title: String - let start: Date - let end: Date - let isAllDay: Bool - let colorHex: String - let location: String - let calendarKey: String // used for per-calendar filtering in widget config - - init(id: String, title: String, start: Date, end: Date, - isAllDay: Bool, colorHex: String, location: String, calendarKey: String) { - self.id = id; self.title = title; self.start = start; self.end = end - self.isAllDay = isAllDay; self.colorHex = colorHex - self.location = location; self.calendarKey = calendarKey - } - - // Backward-compatible decoder: old caches have no calendarKey field. - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - id = try c.decode(String.self, forKey: .id) - title = try c.decode(String.self, forKey: .title) - start = try c.decode(Date.self, forKey: .start) - end = try c.decode(Date.self, forKey: .end) - isAllDay = try c.decode(Bool.self, forKey: .isAllDay) - colorHex = try c.decode(String.self, forKey: .colorHex) - location = try c.decode(String.self, forKey: .location) - calendarKey = try c.decodeIfPresent(String.self, forKey: .calendarKey) ?? "" - } -} - -/// Snapshot blob the app writes to the App-Group container and the widget reads. -struct WidgetSnapshot: Codable { - let writtenAt: Date - let events: [WidgetEvent] - /// Mirrors the user's chosen visual settings so the widget looks the same - /// as the app even when its own AppStorage in the extension is empty. - let todayColorHex: String - let textColorHex: String - let backgroundColorHex: String - let lineColorHex: String - let primaryColorHex: String - let accentColorHex: String - let language: String - - init(writtenAt: Date, - events: [WidgetEvent], - todayColorHex: String, - textColorHex: String, - backgroundColorHex: String, - lineColorHex: String, - primaryColorHex: String, - accentColorHex: String, - language: String) { - self.writtenAt = writtenAt - self.events = events - self.todayColorHex = todayColorHex - self.textColorHex = textColorHex - self.backgroundColorHex = backgroundColorHex - self.lineColorHex = lineColorHex - self.primaryColorHex = primaryColorHex - self.accentColorHex = accentColorHex - self.language = language - } - - /// Custom decoder so older caches without the new colour fields still load. - init(from decoder: Decoder) throws { - let c = try decoder.container(keyedBy: CodingKeys.self) - writtenAt = try c.decode(Date.self, forKey: .writtenAt) - events = try c.decode([WidgetEvent].self, forKey: .events) - todayColorHex = try c.decode(String.self, forKey: .todayColorHex) - textColorHex = try c.decode(String.self, forKey: .textColorHex) - backgroundColorHex = try c.decode(String.self, forKey: .backgroundColorHex) - lineColorHex = try c.decode(String.self, forKey: .lineColorHex) - language = try c.decode(String.self, forKey: .language) - primaryColorHex = try c.decodeIfPresent(String.self, forKey: .primaryColorHex) ?? "#4285f4" - accentColorHex = try c.decodeIfPresent(String.self, forKey: .accentColorHex) ?? "#ea4335" - } - - private enum CodingKeys: String, CodingKey { - case writtenAt, events, todayColorHex, textColorHex, backgroundColorHex - case lineColorHex, primaryColorHex, accentColorHex, language - } +extension CalendarrSnapshot { + // The six colours are stored flat on the wire for backwards compatibility + // but exposed as `theme` in the package. These keep the widget views, which + // read them individually, from having to change. + var todayColorHex: String { theme.today } + var textColorHex: String { theme.text } + var backgroundColorHex: String { theme.background } + var lineColorHex: String { theme.line } + var primaryColorHex: String { theme.primary } + var accentColorHex: String { theme.accent } } +/// Thin facade over `CalendarrCore.SnapshotStore`, preserving the static API the +/// app and widget already use. Errors are swallowed here exactly as before — +/// a failed widget cache write must never interrupt the user — but the store now +/// reports *why* a read failed, which is what `read()` discards and callers that +/// need the distinction should use `SnapshotStore` directly for. enum WidgetStore { - private static let cacheFilename = "widget-cache.json" - private static let calendarsFilename = "widget-calendars.json" + private static let store = SnapshotStore() + private static let sessions = SharedSessionStore() - private static var containerURL: URL? { - let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: widgetAppGroupID) - #if DEBUG - if url == nil { - // A nil container is always a build-configuration bug: the entitlement - // is missing, or its value does not match widgetAppGroupID. It is - // otherwise completely silent, so make it loud while developing. - assertionFailure("App Group container unavailable for \(widgetAppGroupID) — " - + "check com.apple.security.application-groups in the " - + "entitlements for this platform.") - } - #endif - return url + /// Version of the app writing the cache, for diagnostics in the consumer. + private static var writerVersion: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown" } - private static var cacheURL: URL? { - containerURL?.appendingPathComponent(cacheFilename) - } - - private static var calendarsURL: URL? { - containerURL?.appendingPathComponent(calendarsFilename) - } - - /// Called by the app whenever the event cache changes. static func write(_ snapshot: WidgetSnapshot) { - guard let url = cacheURL else { return } - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - if let data = try? encoder.encode(snapshot) { - try? data.write(to: url, options: .atomic) - } + try? store.write(snapshot) } - /// Called by the widget timeline provider to load the latest snapshot. static func read() -> WidgetSnapshot? { - guard let url = cacheURL, let data = try? Data(contentsOf: url) else { return nil } - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return try? decoder.decode(WidgetSnapshot.self, from: data) + store.read().snapshot } - /// Write the list of available calendars so the widget intent can offer - /// calendar options for filtering without a network call. static func writeCalendars(_ calendars: [WidgetCalendar]) { - guard let url = calendarsURL else { return } - if let data = try? JSONEncoder().encode(calendars) { - try? data.write(to: url, options: .atomic) - } + try? store.writeCalendars(calendars) } - /// Read the calendar list written by the main app. static func readCalendars() -> [WidgetCalendar] { - guard let url = calendarsURL, let data = try? Data(contentsOf: url) else { return [] } - return (try? JSONDecoder().decode([WidgetCalendar].self, from: data)) ?? [] + store.readCalendars() } - /// Drop the cached snapshot and calendar list. Called on logout and on - /// server reset: without this the files survive, and widgets — plus any - /// other app reading the group container — keep rendering the previous - /// user's events indefinitely. + /// Record which server we are pointed at and whether anyone is signed in, so + /// a reading app can tell "signed out" from "never ran" without reaching into + /// this app's UserDefaults, which lives in another sandbox. + static func writeSession(baseURL: String, username: String, isLoggedIn: Bool) { + try? sessions.write(SharedSession(baseURL: baseURL, + username: username, + isLoggedIn: isLoggedIn, + writtenAt: Date())) + } + + /// Forget the session entirely. Used on server reset, where the app returns + /// to unconfigured and there is no longer a server worth naming. + static func clearSession() { + sessions.clear() + } + + /// Drop the cached calendar. Called on sign-out and server reset: the + /// container outlives the session, so without this every reader keeps + /// rendering the previous user's events. static func clear() { - for url in [cacheURL, calendarsURL].compactMap({ $0 }) { - try? FileManager.default.removeItem(at: url) - } + store.clear() WidgetTimelineNotifier.reload() } - /// Rewrite the existing snapshot with the latest colour / language values - /// from UserDefaults. Used when the user tweaks an appearance setting and - /// we want the widgets to refresh immediately, without needing a new event - /// sync. No-op if there's no cached snapshot yet. + /// Rewrite the existing snapshot with the latest colour / language values so + /// widgets pick up an appearance change immediately, without waiting for the + /// next event sync. No-op when nothing is cached yet. static func republishAppearanceOnly() { guard let existing = read() else { return } let defaults = UserDefaults.standard let updated = WidgetSnapshot( writtenAt: Date(), + coverageStart: existing.coverageStart, + coverageEnd: existing.coverageEnd, + isLoggedIn: existing.isLoggedIn, + writerVersion: writerVersion, events: existing.events, - todayColorHex: defaults.string(forKey: "todayColor") ?? existing.todayColorHex, - textColorHex: defaults.string(forKey: "textColor") ?? existing.textColorHex, - backgroundColorHex: defaults.string(forKey: "backgroundColor") ?? existing.backgroundColorHex, - lineColorHex: defaults.string(forKey: "lineColor") ?? existing.lineColorHex, - primaryColorHex: defaults.string(forKey: "primaryColor") ?? existing.primaryColorHex, - accentColorHex: defaults.string(forKey: "accentColor") ?? existing.accentColorHex, - language: defaults.string(forKey: "appLanguage") ?? existing.language - ) + theme: SnapshotTheme( + today: defaults.string(forKey: "todayColor") ?? existing.theme.today, + text: defaults.string(forKey: "textColor") ?? existing.theme.text, + background: defaults.string(forKey: "backgroundColor") ?? existing.theme.background, + line: defaults.string(forKey: "lineColor") ?? existing.theme.line, + primary: defaults.string(forKey: "primaryColor") ?? existing.theme.primary, + accent: defaults.string(forKey: "accentColor") ?? existing.theme.accent), + language: defaults.string(forKey: "appLanguage") ?? existing.language) write(updated) WidgetTimelineNotifier.reload() } + + /// Build a snapshot from the app's current state. Kept here so the coverage + /// window and the writer version are stamped in exactly one place. + static func makeSnapshot(events: [WidgetEvent], + coverageStart: Date, + coverageEnd: Date) -> WidgetSnapshot { + let defaults = UserDefaults.standard + return WidgetSnapshot( + writtenAt: Date(), + coverageStart: coverageStart, + coverageEnd: coverageEnd, + isLoggedIn: true, // only ever written while signed in + writerVersion: writerVersion, + events: events, + theme: SnapshotTheme( + today: defaults.string(forKey: "todayColor") ?? "#4285f4", + text: defaults.string(forKey: "textColor") ?? "#FFFFFF", + background: defaults.string(forKey: "backgroundColor") ?? "#000000", + line: defaults.string(forKey: "lineColor") ?? "#3A3A52", + primary: defaults.string(forKey: "primaryColor") ?? "#4285f4", + accent: defaults.string(forKey: "accentColor") ?? "#ea4335"), + language: defaults.string(forKey: "appLanguage") ?? "system") + } } enum WidgetTimelineNotifier { From 7b51cab6937ec9c37556d294a5dd11f3c076aab1 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 9 Sep 2026 18:33:59 +0200 Subject: [PATCH 69/90] feat(auth): SSO-Anmeldung ueber OpenID Connect (AppAuth) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zusaetzlicher Anmeldeweg neben Benutzername/Passwort. Der Login-Screen holt die konfigurierten Provider von GET /api/auth/oidc/providers; ohne Provider oder gegen einen aelteren Server bleibt der Screen unveraendert. Die App laeuft als Public Client: Authorization Code Flow mit PKCE in einer ASWebAuthenticationSession (nie ein WKWebView), ohne Client Secret. Das ID-Token wird an POST /api/auth/oidc/exchange gegen ein Calendarr-Token getauscht; die Antwort hat dieselbe Form wie der Passwort-Login, sodass der bestehende Session-Pfad unveraendert weiterlaeuft. - OIDCAuthenticator kapselt Discovery, PKCE-Request, Code-Tausch und die stille Erneuerung. Der AuthState mit dem Refresh-Token liegt in der Keychain (kSecAttrAccessibleAfterFirstUnlock) und wird beim Logout aus beiden Zugriffsgruppen entfernt. - OIDCSessionRefresher erneuert die Sitzung beim Start, wenn das Calendarr-Token binnen 24 h ablaeuft — ohne Browser, ueber den Refresh-Token des Providers. - Die angefragten Scopes kommen vom Server (mobile_scopes); ein Scope, den der Provider nicht kennt, wuerde die ganze Anfrage scheitern lassen. - Bei aktiviertem gefuehrten Zugriff weicht AppAuth auf SFSafariViewController aus, dessen Redirect nur ueber ein registriertes URL-Schema zurueckkaeme. Da das App-Target seine Info.plist generiert und CFBundleURLTypes dort nicht setzbar ist, wird der Fall vorab sauber abgelehnt statt endlos auf einen Callback zu warten, der nicht kommen kann. - Abbrueche werden mit Fehlerdomaene geprueft (user- und programCanceled) und still behandelt; alle uebrigen Fehler tragen Domain, Code und OAuth-Fehler ins DEBUG-Log, statt spurlos zu verschwinden. project.pbxproj: AppAuth-iOS 1.7.5 als SPM-Abhaengigkeit, nur am App-Target. OTHER_LDFLAGS bekommt -ObjC, weil OIDAuthState+IOS.m ausschliesslich eine Kategorie enthaelt und ohne Symbol sonst wegoptimiert werden koennte. Achtung: mangels macOS/Xcode ist dieser Stand nicht kompiliert. Beim ersten Oeffnen loest Xcode das Paket auf und legt Package.resolved an; die bitte mit committen. Co-Authored-By: Claude Opus 5 --- Calendarr iOS.xcodeproj/project.pbxproj | 28 ++ Calendarr iOS/CalendarrApp.swift | 3 + Calendarr iOS/Models/Localization.swift | 38 +++ Calendarr iOS/Services/CalendarrAPI.swift | 73 +++++ .../Services/OIDCAuthenticator.swift | 259 ++++++++++++++++++ .../Services/OIDCSessionRefresher.swift | 65 +++++ Calendarr iOS/Views/LoginView.swift | 79 ++++++ Calendarr iOS/Views/RootView.swift | 3 + 8 files changed, 548 insertions(+) create mode 100644 Calendarr iOS/Services/OIDCAuthenticator.swift create mode 100644 Calendarr iOS/Services/OIDCSessionRefresher.swift diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index dc282ad..bdaaaad 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ D0001A0CABCDEF0100AB5001 /* CalendarrWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; CA1E4D0A0002000000000001 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000001 /* CalendarrCore */; }; CA1E4D0A0002000000000002 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000002 /* CalendarrCore */; }; + AA9701000001000000000001 /* AppAuth in Frameworks */ = {isa = PBXBuildFile; productRef = AA9701000002000000000001 /* AppAuth */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -98,6 +99,7 @@ buildActionMask = 2147483647; files = ( CA1E4D0A0002000000000001 /* CalendarrCore in Frameworks */, + AA9701000001000000000001 /* AppAuth in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -180,6 +182,7 @@ name = "Calendarr iOS"; packageProductDependencies = ( CA1E4D0A0001000000000001 /* CalendarrCore */, + AA9701000002000000000001 /* AppAuth */, ); productName = "Calendarr iOS"; productReference = C0000B01FC4E10100AB5001 /* Calendarr iOS.app */; @@ -243,6 +246,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 39450EFC302A266A0090DDD7 /* XCLocalSwiftPackageReference "../CalendarrKit" */, + AA9701000003000000000001 /* XCRemoteSwiftPackageReference "AppAuth-iOS" */, ); preferredProjectObjectVersion = 77; productRefGroup = C0000C01FB4E10100AB5001 /* Products */; @@ -521,6 +525,10 @@ "$(inherited)", "@executable_path/Frameworks", ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -566,6 +574,10 @@ "$(inherited)", "@executable_path/Frameworks", ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -702,6 +714,11 @@ isa = XCSwiftPackageProductDependency; productName = CalendarrCore; }; + AA9701000002000000000001 /* AppAuth */ = { + isa = XCSwiftPackageProductDependency; + package = AA9701000003000000000001 /* XCRemoteSwiftPackageReference "AppAuth-iOS" */; + productName = AppAuth; + }; /* End XCSwiftPackageProductDependency section */ /* Begin XCLocalSwiftPackageReference section */ @@ -710,6 +727,17 @@ relativePath = ../CalendarrKit; }; /* End XCLocalSwiftPackageReference section */ + +/* Begin XCRemoteSwiftPackageReference section */ + AA9701000003000000000001 /* XCRemoteSwiftPackageReference "AppAuth-iOS" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/openid/AppAuth-iOS.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.7.5; + }; + }; +/* End XCRemoteSwiftPackageReference section */ }; rootObject = C0000301FB4E10100AB5001 /* Project object */; } diff --git a/Calendarr iOS/CalendarrApp.swift b/Calendarr iOS/CalendarrApp.swift index 904c13b..a12c147 100644 --- a/Calendarr iOS/CalendarrApp.swift +++ b/Calendarr iOS/CalendarrApp.swift @@ -95,6 +95,9 @@ class AppState { 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") diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index f577319..c35e1d6 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -294,6 +294,25 @@ private let strings: [String: [String: String]] = [ "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", @@ -668,6 +687,25 @@ private let strings: [String: [String: String]] = [ "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", diff --git a/Calendarr iOS/Services/CalendarrAPI.swift b/Calendarr iOS/Services/CalendarrAPI.swift index 3d4dd03..1407333 100644 --- a/Calendarr iOS/Services/CalendarrAPI.swift +++ b/Calendarr iOS/Services/CalendarrAPI.swift @@ -78,6 +78,79 @@ class CalendarrAPI { 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) diff --git a/Calendarr iOS/Services/OIDCAuthenticator.swift b/Calendarr iOS/Services/OIDCAuthenticator.swift new file mode 100644 index 0000000..cb319f6 --- /dev/null +++ b/Calendarr iOS/Services/OIDCAuthenticator.swift @@ -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 { UIAccessibilityIsGuidedAccessEnabled() } + + 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 + } +} diff --git a/Calendarr iOS/Services/OIDCSessionRefresher.swift b/Calendarr iOS/Services/OIDCSessionRefresher.swift new file mode 100644 index 0000000..894ad77 --- /dev/null +++ b/Calendarr iOS/Services/OIDCSessionRefresher.swift @@ -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 + } +} diff --git a/Calendarr iOS/Views/LoginView.swift b/Calendarr iOS/Views/LoginView.swift index a5b9ff8..8eef38b 100644 --- a/Calendarr iOS/Views/LoginView.swift +++ b/Calendarr iOS/Views/LoginView.swift @@ -1,3 +1,4 @@ +import Foundation import SwiftUI struct LoginView: View { @@ -10,6 +11,11 @@ struct LoginView: View { @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 { @@ -95,9 +101,45 @@ struct LoginView: View { .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) @@ -109,6 +151,43 @@ struct LoginView: View { } } .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 } } diff --git a/Calendarr iOS/Views/RootView.swift b/Calendarr iOS/Views/RootView.swift index e3649c7..8e42edf 100644 --- a/Calendarr iOS/Views/RootView.swift +++ b/Calendarr iOS/Views/RootView.swift @@ -10,6 +10,9 @@ struct RootView: View { 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) } } } } From 214f120859db6f416784264b7c2dfe2bf33b9f09 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 11 Sep 2026 15:35:28 +0200 Subject: [PATCH 70/90] Add design spec for the Apple Watch app and watch-face complications The watch is a read-only consumer of the phone's snapshot: WatchConnectivity carries a trimmed CalendarrSnapshot to the watch, which writes it into its own App Group container through the same SnapshotStore. One format, one reader. Also specifies the iOS background refresh the watch depends on, which fixes a pre-existing staleness in the home-screen widgets as a side effect. Co-Authored-By: Claude Opus 5 --- .../2026-09-11-apple-watch-app-design.md | 289 ++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-11-apple-watch-app-design.md diff --git a/docs/superpowers/specs/2026-09-11-apple-watch-app-design.md b/docs/superpowers/specs/2026-09-11-apple-watch-app-design.md new file mode 100644 index 0000000..e553ab4 --- /dev/null +++ b/docs/superpowers/specs/2026-09-11-apple-watch-app-design.md @@ -0,0 +1,289 @@ +# Apple Watch app and watch-face complications + +Date: 2026-09-11 +Status: approved — ready for implementation planning + +## Goal + +Put "what is my next appointment" on the wrist as watch-face complications, with a +tap-through app that shows a plain agenda list and lets the user narrow which +calendars count. Nothing more. + +## Non-goals + +- Creating or editing events on the watch. The snapshot is deliberately lossy — + no notes, recurrence rules or permissions — which is enough to *display* a + calendar and deliberately not enough to edit one. Editing would mean a second + API client and is out of scope. +- Running without the iPhone. The watch is a read-only consumer of the phone's + snapshot. Direct server access stays available as a later option (the server is + reachable over HTTPS) but is not built now. +- Wear OS parity. A separate project with its own decision. + +## Platform floor + +iOS 26 (unchanged) and watchOS 26. Nothing below that needs to work, so no +availability branching. + +## Design rules + +- **No emojis as icons**, anywhere — SF Symbols only (`Image(systemName:)`). An + emoji renders differently on every platform and font, and complications tint + SF Symbols with the watch face's colour, which an emoji ignores. +- Exactly one element per complication is marked `widgetAccentable()`. +- The watch app's background stays black rather than following `snapshot.theme`; + the always-on display and OLED make that the right choice. `snapshot.theme` + supplies accents only. + +## Architecture + +``` +iPhone Apple Watch +────────────────────────────────── ────────────────────────────── +CalendarStore.publishWidgetSnapshot() + ├─ SnapshotStore.write() (App Group iOS) + ├─ WidgetTimelineNotifier.reload() + └─ WatchSyncService.push() ──WCSession──▶ WatchSnapshotReceiver + ├─ SnapshotStore.write() (App Group watchOS) + ├─ SharedSessionStore.write() + └─ WidgetCenter.reloadAllTimelines() +BGAppRefreshTask (new) + └─ refresh → publishWidgetSnapshot() Watch app foreground + └─ sendMessage ──▶ pull when reachable +``` + +App Groups are per-device: the phone's container is not the watch's. The watch +therefore receives the snapshot over WatchConnectivity and writes it into *its +own* App Group container using the same `SnapshotStore` from CalendarrKit. One +format, one reader, no drift — the watch is exactly the read-only consumer the +CalendarrKit README already describes. + +`CalendarrAppGroup.current` is already correct for watchOS: the +`#if os(macOS) || targetEnvironment(macCatalyst)` guard falls through to the +unprefixed identifier, which is what watchOS requires. + +## Module changes + +| Module | Change | +|---|---| +| `CalendarrKit` | Add `.watchOS(.v26)` to `platforms`. No code change — the Foundation-only constraint pays off here. | +| `CalendarrKit` | New `Snapshot/WatchCoverage.swift`: the transport trimming policy plus `CalendarrSnapshot.trimmedForWatch()`. Shared by sender and receiver so they cannot disagree about it. | +| iOS app | New `Services/WatchSyncService.swift` — WCSession sender, called from `publishWidgetSnapshot()`. | +| iOS app | New `Services/BackgroundRefresh.swift` — `BGAppRefreshTask` that refreshes events, republishes the snapshot and pushes to the watch. Benefits the existing widgets too. | +| New target | `CalendarrWatch` (watchOS app): receiver, agenda, event detail, calendar filter. | +| New target | `CalendarrWatchComplications` (watchOS widget extension): the complications. | + +## Transport + +### Trimming policy + +`WatchCoverage`, mirroring the existing `SnapshotCoverage`: + +- `daysBehind = 1` +- `daysAhead = 14` +- `maxEvents = 120` + +`trimmedForWatch()` must narrow `coverageStart`/`coverageEnd` along with the +events. Carrying 14 days of events while still claiming a 42-day window would +make the watch render an empty week that it has no information about — the exact +failure the README warns against. If the `maxEvents` cap truncates before +`daysAhead` is reached, `coverageEnd` is pulled back to the start of the day of +the last retained event. + +Trimmed payload is roughly 20–30 KB, far below the WatchConnectivity limit. + +### Three delivery paths + +Each one alone leaves a gap, so all three ship: + +1. **`updateApplicationContext`** — the main path. Carries only the latest state, + survives restarts, and has no queue to overflow. Delivered when the watch app + next launches or wakes. +2. **`transferCurrentComplicationUserInfo`** — sent only when the *next* event + changed identity (first event after now, compared by id and start). This is + the only path that wakes the watch app while it is not running, which is why + it exists; its daily budget (~50) is why it is not used for every write. + Additionally rate-limited to at most 4 per hour. +3. **`sendMessage` pull on watch app foreground** — covers "I am looking at it + right now and it is stale". + +Payload is a property-list dictionary of `Data` blobs: `snapshot`, `calendars`, +`session`, plus an integer `schema` matching `CalendarrSnapshot.currentSchema`. A +receiver refuses a higher schema rather than guessing, same rule as the file +format. + +Only the watch **app** runs WCSession; a widget extension cannot. The extension +always reads what the app last wrote. + +## Timeline strategy + +This deliberately differs from the iOS provider, which emits 24 hourly entries +(`CalendarrTimelineProvider.swift`). On the watch, entries are placed **on event +boundaries**: now, plus every event start and end inside the next 24 hours, +deduplicated and sorted, with hourly filler so at least 24 hours are covered, and +capped at 100 entries. The complication then flips in the exact minute the next +appointment changes, with no network call and no refresh budget spent. + +Refresh policy is `.atEnd`. Data changes arrive by push; when the timeline simply +runs out it regenerates from the same snapshot, which is cheap and correct. + +Countdowns use `Text(event.start, style: .timer)`, which updates live without +consuming timeline entries and dims correctly on the always-on display. + +## Complication catalogue + +Eight kinds. Families in brackets. + +| Kind | Families | Content | +|---|---|---| +| `WatchNextEvent` | rectangular, inline | Time, title, location | +| `WatchCornerCountdown` | corner | Value: live countdown. Curved label: `"14:30 Zahnarzt"` | +| `WatchCornerTime` | corner | Value: start time. Curved label: event title | +| `WatchCountdown` | circular, inline | Remaining time; circular draws a draining ring | +| `WatchDate` | circular, inline | Day number and month | +| `WatchTodayCount` | circular, inline | Number of events today | +| `WatchNextTwo` | rectangular | The next two events, time plus title | +| `WatchDatePlusEvent` | rectangular | Weekday and date plus the next event | + +`WatchDate`, `WatchTodayCount` and `WatchCountdown` are ports of the existing +lock-screen widgets in `LockScreenWidgetViews.swift`, which already render +`accessoryCircular`, `accessoryRectangular` and `accessoryInline`. + +### Which face shows what + +A round Infograph-style face has **no rectangular slot**: it offers four corners +and a circular sub-dial. The rectangular kinds therefore only appear on +Modular-family faces. Since the user's face is round, `accessoryCorner` is the +primary slot and gets the most attention; the rectangular kinds still ship +because they are cheap and cover the other faces. + +### accessoryCorner + +New and watchOS-only. The curved bezel text comes from +`.widgetLabel { Text(...) }` — WidgetKit lays it along the edge, so no manual arc +drawing. + +The hard constraint: a corner holds **one** value plus the curved label. A second +inner line runs into the curved text and must not be attempted. Both corner kinds +therefore carry a single value and put the context in the label. + +Two content variants ship rather than one, because they share the whole data path +and differ only in formatting — the user picks on the watch face. A +date-in-the-corner variant was considered and dropped: it carries no appointment +information, which is the whole point of the complication. + +watchOS tints complications monochrome using the watch face's colour, so +per-calendar colours are not available in complications. Calendar colours appear +in the app only. + +## Watch app + +- **Agenda** — a scrolling list grouped by day, day headers, one row per event + with a leading bar in the event's colour, time range or all-day label, and + title. Covers the trimmed window; days outside `snapshot.covers(date)` are not + rendered as empty but greyed with a note. +- **Event detail** — title, time, location, calendar name. Read-only. +- **Calendar filter** — one row per calendar from `readCalendars()`, rendered as a + `Button` with an SF Symbol checkbox (`checkmark.square.fill` / `square`) plus + the calendar's colour dot. Deliberately **not** a watchOS `Toggle`: its switch + reads as a state indicator you wait on rather than a control you tap. + +The background stays black rather than following `snapshot.theme`, because the +always-on display and OLED make black the right choice on the watch. +`snapshot.theme` is used for accents only. + +## Calendar filter semantics + +Stored in the watch's App Group `UserDefaults` under +`watchHiddenCalendarKeys: [String]`, so the widget extension reads the same +value and one toggle affects every complication at once. + +The snapshot already excludes hidden and banished calendars, so the watch filter +narrows *further* and never contradicts the phone. `CalendarSelectionIntent` is +ported so a complication can override the app-level filter, but the in-app +toggles are the primary path: intent configuration is awkward to operate on the +watch face itself. + +## Error and empty states + +`SnapshotReadResult` already covers most of them. One case is new. + +| State | Display | +|---|---| +| `.neverWritten` / nothing received yet | "Open Calendarr on your iPhone once" | +| `.loggedOut` | "Sign in on your iPhone" — never show the previous events | +| `.incompatible` | "Update the watch app" | +| outside `covers(date)` | Greyed with a note, never rendered as "nothing scheduled" | +| Snapshot present but visibly old | Show `writtenAt` as "as of …" rather than implying freshness | + +The last one has no iPhone equivalent: the phone writes its own cache, so it is +never stale in a way the reader cannot see. The watch can hold a snapshot from +days ago because the phone was unreachable, and must say so. + +## iOS background refresh + +- Register `BGAppRefreshTaskRequest` with identifier + `com.scarriffleservices.calendarr.ios.refresh` in `CalendarrApp`, rescheduling + after every run with `earliestBeginDate` 30 minutes out. +- Info.plist needs `BGTaskSchedulerPermittedIdentifiers` and the `fetch` + background mode. +- The task fetches the coverage window, calls `publishWidgetSnapshot()`, and lets + the existing call chain reload widgets and push to the watch. It must finish + well inside the ~30 s budget and set an expiration handler. + +iOS decides when this runs; frequency follows how the user actually opens the +app. This improves freshness, it does not guarantee it. + +## Build, signing and distribution + +- Bundle identifiers: `com.scarriffleservices.calendarr.ios.watchkitapp` and + `com.scarriffleservices.calendarr.ios.watchkitapp.complications`. The + `.watchkitapp` suffix is required for a companion watch app; + `WKCompanionAppBundleIdentifier` points at the iOS app. +- Both new targets need `com.apple.security.application-groups` set to + `group.com.scarriffleservices.calendarr`, unprefixed. +- No keychain access group on the watch: there is no token there to protect. +- Register both bundle IDs and enable the App Group for them in the developer + portal. +- App Store Connect: a watch app is not a new app record, it is a new target in + the existing one — a normal version submission and a normal review. Watch + screenshots are additionally required. +- The server needs no change at all. + +## Risks + +1. **Xcode does not currently support the test device.** Xcode 26.6 ships the + watchOS 26.5 SDK; the target watch runs watchOS 27 beta. Installing builds on + it needs an Xcode with watchOS 27 support. Code can be written and built + against watchOS 26 in the meantime, but on-device verification is blocked + until that Xcode is installed. This is the first thing to resolve. +2. **WatchConnectivity background delivery is opportunistic** and cannot be + verified on this machine — only on a real paired device. +3. **Complication push budget.** Path 2 is deliberately rate-limited; if pushes + are exhausted, freshness silently degrades to path 1. The stale-snapshot + display state is what keeps that honest rather than wrong. +4. **First run is a chicken-and-egg.** The watch app can be installed before the + phone has ever pushed. The empty state must name the fix rather than look + broken. +5. **App review** may ask for demo credentials, since the app needs a + self-hosted server to show anything. + +## Verification + +Per the project's working rules, this machine builds but does not run apps: + +``` +xcodebuild -project "Calendarr iOS.xcodeproj" -scheme CalendarrWatch \ + -sdk watchsimulator CODE_SIGNING_ALLOWED=NO build +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift build # CalendarrKit +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test +``` + +Behaviour, layout and complication refresh are verified by the user on the +device. Nothing about the look is asserted here. + +## Later, not now + +- Direct HTTPS fetch from the watch, for freshness independent of the phone. +- Creating events on the watch. +- Wear OS equivalent. From e3ff2b824ca5b89887596e3b36894c30815d5f6d Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 11 Sep 2026 16:13:59 +0200 Subject: [PATCH 71/90] Add the implementation plan for the Apple Watch app Seventeen tasks. Every piece of testable logic lands in CalendarrCore first, under TDD, because that is the only target this machine can actually run tests for; the watch targets hold view and WCSession glue that is build-verified only. The iOS background refresh is task 7 and commits on its own: it fixes the home-screen widgets' staleness whether or not the watch app follows. Co-Authored-By: Claude Opus 5 --- .../plans/2026-09-11-apple-watch-app.md | 3065 +++++++++++++++++ 1 file changed, 3065 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-11-apple-watch-app.md diff --git a/docs/superpowers/plans/2026-09-11-apple-watch-app.md b/docs/superpowers/plans/2026-09-11-apple-watch-app.md new file mode 100644 index 0000000..5759806 --- /dev/null +++ b/docs/superpowers/plans/2026-09-11-apple-watch-app.md @@ -0,0 +1,3065 @@ +# Apple Watch App and Complications Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a read-only Apple Watch app plus eight watch-face complications that show the next Calendarr appointment, fed from the iPhone over WatchConnectivity. + +**Architecture:** The watch is a read-only consumer of the phone's snapshot. Every piece of logic that can be tested lives in `CalendarrCore` (testable with `swift test` on the dev machine); the watch targets hold only thin SwiftUI and WCSession glue, which can only be build-verified here. The phone trims its 42-day snapshot to a 14-day watch payload and sends it; the watch writes it into its *own* App Group container through the same `SnapshotStore`, so there is one wire format and one reader. + +**Tech Stack:** Swift 6 toolchain in Swift 5 language mode, SwiftUI, WidgetKit (complications), WatchConnectivity, BackgroundTasks, Swift Testing (`import Testing`), Swift Package Manager (local package `CalendarrKit`). + +**Spec:** `docs/superpowers/specs/2026-09-11-apple-watch-app-design.md` + +--- + +## Prerequisite (blocks device verification only) + +Xcode 26.6 ships the watchOS 26.5 SDK. The target device runs watchOS 27 beta, so builds cannot be installed on it until an Xcode with watchOS 27 support is present. Every task below can be written and build-verified without it. Do not block on this; note it when handing back. + +## Working rules for this repo + +- Never run or start the server, and never run the apps. Apps are **build-verified only**; behaviour, layout and complication refresh are verified by the user on the device. +- Commit messages in English. Commit after every task. +- `CalendarrKit` is a **separate git repo** at `../CalendarrKit` with **no remote** — commit there, do not push. The iOS repo (`.`) pushes to `origin main`. +- `CalendarrCore` must stay Foundation-only. No SwiftUI, no UIKit, no WidgetKit imports in the package. + +## File structure + +### `../CalendarrKit` (new files) + +| File | Responsibility | +|---|---| +| `Sources/CalendarrCore/Snapshot/WatchCoverage.swift` | Watch trimming policy and `trimmedForWatch()`. The only place the watch window is defined. | +| `Sources/CalendarrCore/Snapshot/SnapshotFilter.swift` | Calendar-key filtering, shared by the iOS and watch timeline providers. | +| `Sources/CalendarrCore/Transport/WatchTransportPayload.swift` | The WatchConnectivity wire format: encode, decode, schema refusal. | +| `Sources/CalendarrCore/Transport/WatchPushPolicy.swift` | "Did the next appointment change?" — decides when to spend a complication push. | +| `Sources/CalendarrCore/Complications/ComplicationTimeline.swift` | Event-boundary entry dates for a WidgetKit timeline. Pure function. | +| `Tests/CalendarrCoreTests/WatchTrimmingTests.swift` | Tests for `WatchCoverage`. | +| `Tests/CalendarrCoreTests/SnapshotFilterTests.swift` | Tests for filtering. | +| `Tests/CalendarrCoreTests/WatchTransportTests.swift` | Tests for the wire format. | +| `Tests/CalendarrCoreTests/WatchPushPolicyTests.swift` | Tests for push throttling. | +| `Tests/CalendarrCoreTests/ComplicationTimelineTests.swift` | Tests for entry dates. | + +### `.` (iOS repo, new files) + +| File | Responsibility | +|---|---| +| `Calendarr iOS/Services/BackgroundRefresh.swift` | `BGAppRefreshTask` registration and handling. | +| `Calendarr iOS/Services/WatchSyncService.swift` | WCSession sender on the phone. | +| `CalendarrWatch/CalendarrWatchApp.swift` | watchOS app entry point. | +| `CalendarrWatch/WatchSnapshotReceiver.swift` | WCSession delegate; writes the received payload. | +| `CalendarrWatch/WatchFilterStore.swift` | Per-watch hidden-calendar set in the App Group. | +| `CalendarrWatch/WatchSnapshotModel.swift` | Observable read model shared by the watch views. | +| `CalendarrWatch/Views/WatchAgendaView.swift` | Day-grouped agenda list. | +| `CalendarrWatch/Views/WatchEventRow.swift` | One event row. | +| `CalendarrWatch/Views/WatchEventDetailView.swift` | Read-only event detail. | +| `CalendarrWatch/Views/WatchCalendarFilterView.swift` | Checkbox rows for calendars. | +| `CalendarrWatch/Views/WatchStatusView.swift` | Empty and error states. | +| `CalendarrWatch/WatchL10n.swift` | German/English strings for the watch app and complications. | +| `CalendarrWatchComplications/WatchComplicationProvider.swift` | `AppIntentTimelineProvider` for all kinds. | +| `CalendarrWatchComplications/WatchComplicationSupport.swift` | Colour helper, time formatting, next-event helpers. | +| `CalendarrWatchComplications/CornerComplicationViews.swift` | The two corner kinds. | +| `CalendarrWatchComplications/RectangularComplicationViews.swift` | The three rectangular kinds. | +| `CalendarrWatchComplications/SmallComplicationViews.swift` | Circular and inline kinds. | +| `CalendarrWatchComplications/CalendarrWatchComplications.swift` | `WidgetBundle` with all eight kinds. | + +### Modified + +| File | Change | +|---|---| +| `../CalendarrKit/Package.swift` | Add `.watchOS(.v26)`. | +| `Calendarr iOS/CalendarrApp.swift` | Register the background task; start `WatchSyncService`. | +| `Calendarr iOS/Models/CalendarStore.swift:574-628` | Push to the watch at the end of `publishWidgetSnapshot()`. | +| `CalendarrWidgets/CalendarrTimelineProvider.swift:38-55` | Use the shared filter from `CalendarrCore`. | +| `Calendarr iOS.xcodeproj/project.pbxproj` | Two new targets, entitlements, Info.plist keys. | + +--- + +## Task 1: Make CalendarrKit build for watchOS + +**Files:** +- Modify: `../CalendarrKit/Package.swift:6-11` + +- [ ] **Step 1: Add the watchOS platform** + +In `../CalendarrKit/Package.swift`, change the `platforms` array to: + +```swift + platforms: [ + .iOS(.v17), + .watchOS(.v26), + .macOS(.v14), + .macCatalyst(.v17), + ], +``` + +- [ ] **Step 2: Verify the package still builds and tests green** + +Run from `../CalendarrKit`: + +```bash +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift build +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test +``` + +Expected: build succeeds, all existing tests pass. This builds for native macOS, which is what catches a UIKit import before a consumer sees it. + +- [ ] **Step 3: Commit** + +```bash +cd ../CalendarrKit +git add Package.swift +git commit -m "Declare watchOS support so a watch app can consume the snapshot" +``` + +--- + +## Task 2: Watch trimming policy + +The phone publishes ~42 days and up to 500 events. The watch gets a narrower window. The window must be narrowed **with** the events: carrying 14 days while still claiming 42 would make the watch render an empty week it has no information about. + +**Files:** +- Create: `../CalendarrKit/Sources/CalendarrCore/Snapshot/WatchCoverage.swift` +- Test: `../CalendarrKit/Tests/CalendarrCoreTests/WatchTrimmingTests.swift` + +- [ ] **Step 1: Write the failing tests** + +Create `../CalendarrKit/Tests/CalendarrCoreTests/WatchTrimmingTests.swift`: + +```swift +import Foundation +import Testing +@testable import CalendarrCore + +/// The watch payload is a narrower snapshot, not a different one. These tests +/// pin down the part that is easy to get wrong: the coverage window has to +/// shrink with the events, or the watch renders an empty week it knows nothing +/// about. +struct WatchTrimmingTests { + + private static let cal = Calendar(identifier: .gregorian) + private static let now = Date(timeIntervalSince1970: 1_789_000_000) + + private static func event(_ id: String, dayOffset: Int, hour: Int = 9) -> SnapshotEvent { + let dayStart = cal.startOfDay(for: now) + let day = cal.date(byAdding: .day, value: dayOffset, to: dayStart)! + let start = cal.date(byAdding: .hour, value: hour, to: day)! + return SnapshotEvent(id: id, title: "Event \(id)", + start: start, end: start.addingTimeInterval(3600), + isAllDay: false, colorHex: "#4285f4", + location: "", calendarKey: "local:1") + } + + private static func snapshot(_ events: [SnapshotEvent]) -> CalendarrSnapshot { + let dayStart = cal.startOfDay(for: now) + return CalendarrSnapshot( + writtenAt: now, + coverageStart: cal.date(byAdding: .day, value: -SnapshotCoverage.daysBehind, to: dayStart)!, + coverageEnd: cal.date(byAdding: .day, value: SnapshotCoverage.daysAhead, to: dayStart)!, + isLoggedIn: true, + writerVersion: "1.0", + events: events, + theme: SnapshotTheme(today: "#1", text: "#2", background: "#3", + line: "#4", primary: "#5", accent: "#6"), + language: "de") + } + + @Test("The window narrows to the watch policy, not just the events") + func narrowsWindow() { + let trimmed = Self.snapshot([]).trimmedForWatch(now: Self.now, calendar: Self.cal) + let dayStart = Self.cal.startOfDay(for: Self.now) + + #expect(trimmed.coverageStart == Self.cal.date(byAdding: .day, value: -WatchCoverage.daysBehind, to: dayStart)) + #expect(trimmed.coverageEnd == Self.cal.date(byAdding: .day, value: WatchCoverage.daysAhead, to: dayStart)) + } + + @Test("Events outside the watch window are dropped") + func dropsEventsOutsideWindow() { + let inside = Self.event("in", dayOffset: 3) + let outside = Self.event("out", dayOffset: 30) + let trimmed = Self.snapshot([inside, outside]).trimmedForWatch(now: Self.now, calendar: Self.cal) + + #expect(trimmed.events.map(\.id) == ["in"]) + } + + @Test("An event still running at the window start is kept") + func keepsRunningEvent() { + let dayStart = Self.cal.startOfDay(for: Self.now) + let start = Self.cal.date(byAdding: .day, value: -5, to: dayStart)! + let running = SnapshotEvent(id: "running", title: "Urlaub", + start: start, + end: Self.cal.date(byAdding: .day, value: 5, to: dayStart)!, + isAllDay: true, colorHex: "#4285f4", + location: "", calendarKey: "local:1") + let trimmed = Self.snapshot([running]).trimmedForWatch(now: Self.now, calendar: Self.cal) + + #expect(trimmed.events.map(\.id) == ["running"]) + } + + @Test("Metadata survives trimming unchanged") + func preservesMetadata() { + let original = Self.snapshot([Self.event("a", dayOffset: 1)]) + let trimmed = original.trimmedForWatch(now: Self.now, calendar: Self.cal) + + #expect(trimmed.language == "de") + #expect(trimmed.isLoggedIn == true) + #expect(trimmed.writerVersion == "1.0") + #expect(trimmed.theme.accent == "#6") + #expect(trimmed.writtenAt == original.writtenAt) + #expect(trimmed.schemaVersion == CalendarrSnapshot.currentSchema) + } + + @Test("When the event cap truncates, coverageEnd pulls back to what we can prove") + func capPullsBackCoverage() { + // Two events per day for 14 days is 28; ask for a cap of 4 by building + // far more events than the policy allows. + var many: [SnapshotEvent] = [] + for day in 0..<14 { + for hour in 8..<18 { many.append(Self.event("d\(day)h\(hour)", dayOffset: day, hour: hour)) } + } + #expect(many.count > WatchCoverage.maxEvents) + + let trimmed = Self.snapshot(many).trimmedForWatch(now: Self.now, calendar: Self.cal) + + #expect(trimmed.events.count <= WatchCoverage.maxEvents) + // Everything we kept starts before the window we claim. + #expect(trimmed.events.allSatisfy { $0.start < trimmed.coverageEnd }) + // And we no longer claim the full 14 days, because we cannot speak for them. + let dayStart = Self.cal.startOfDay(for: Self.now) + let fullEnd = Self.cal.date(byAdding: .day, value: WatchCoverage.daysAhead, to: dayStart)! + #expect(trimmed.coverageEnd < fullEnd) + #expect(trimmed.coverageEnd > trimmed.coverageStart) + } + + @Test("Trimming never widens a window the writer already narrowed") + func neverWidens() { + let dayStart = Self.cal.startOfDay(for: Self.now) + let narrow = CalendarrSnapshot( + writtenAt: Self.now, + coverageStart: dayStart, + coverageEnd: Self.cal.date(byAdding: .day, value: 2, to: dayStart)!, + isLoggedIn: true, writerVersion: "1.0", events: [], + theme: SnapshotTheme(today: "#1", text: "#2", background: "#3", + line: "#4", primary: "#5", accent: "#6"), + language: "de") + + let trimmed = narrow.trimmedForWatch(now: Self.now, calendar: Self.cal) + + #expect(trimmed.coverageStart == dayStart) + #expect(trimmed.coverageEnd == Self.cal.date(byAdding: .day, value: 2, to: dayStart)) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +cd ../CalendarrKit +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test --filter WatchTrimmingTests +``` + +Expected: FAIL to compile — `cannot find 'WatchCoverage' in scope` and `value of type 'CalendarrSnapshot' has no member 'trimmedForWatch'`. + +- [ ] **Step 3: Write the implementation** + +Create `../CalendarrKit/Sources/CalendarrCore/Snapshot/WatchCoverage.swift`: + +```swift +import Foundation + +/// How much of the calendar travels to the watch. +/// +/// Deliberately narrower than `SnapshotCoverage`: the payload crosses +/// WatchConnectivity on every publish, and a watch complication never needs six +/// weeks of history to answer "what is next". +public enum WatchCoverage { + /// Days before today. One is enough for "still running" all-day events. + public static let daysBehind = 1 + /// Days after today. + public static let daysAhead = 14 + /// Hard cap on events sent, applied after sorting by start date. + public static let maxEvents = 120 +} + +extension CalendarrSnapshot { + /// A narrower copy of this snapshot, suitable for sending to the watch. + /// + /// The coverage window shrinks together with the events, and never widens + /// past what the writer already claimed. If `WatchCoverage.maxEvents` + /// truncates the list, `coverageEnd` is pulled back to the first event we + /// had to drop: everything before that point is provably complete, and + /// beyond it the watch must not pretend to know anything. + public func trimmedForWatch(now: Date = Date(), + calendar: Calendar = Calendar(identifier: .gregorian)) -> CalendarrSnapshot { + let dayStart = calendar.startOfDay(for: now) + let windowStart = max(coverageStart, + calendar.date(byAdding: .day, value: -WatchCoverage.daysBehind, to: dayStart) ?? dayStart) + let windowEnd = min(coverageEnd, + calendar.date(byAdding: .day, value: WatchCoverage.daysAhead, to: dayStart) ?? dayStart) + + guard windowEnd > windowStart else { + return Self.rebuilt(from: self, start: windowStart, end: windowStart, events: []) + } + + let inWindow = events + .filter { $0.start < windowEnd && $0.end > windowStart } + .sorted { $0.start < $1.start } + + guard inWindow.count > WatchCoverage.maxEvents else { + return Self.rebuilt(from: self, start: windowStart, end: windowEnd, events: inWindow) + } + + let kept = Array(inWindow.prefix(WatchCoverage.maxEvents)) + // The first dropped event marks the edge of what we can prove. Its start + // becomes the new end of coverage; the last kept event is dropped too + // when it starts at that same instant, which keeps the invariant + // "every event we carry starts before coverageEnd" exactly true. + let provenEnd = max(windowStart, min(windowEnd, inWindow[WatchCoverage.maxEvents].start)) + return Self.rebuilt(from: self, start: windowStart, end: provenEnd, + events: kept.filter { $0.start < provenEnd && $0.end > windowStart }) + } + + private static func rebuilt(from source: CalendarrSnapshot, + start: Date, end: Date, + events: [SnapshotEvent]) -> CalendarrSnapshot { + CalendarrSnapshot(writtenAt: source.writtenAt, + coverageStart: start, + coverageEnd: end, + isLoggedIn: source.isLoggedIn, + writerVersion: source.writerVersion, + events: events, + theme: source.theme, + language: source.language) + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cd ../CalendarrKit +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test --filter WatchTrimmingTests +``` + +Expected: PASS, 6 tests. + +- [ ] **Step 5: Commit** + +```bash +cd ../CalendarrKit +git add Sources/CalendarrCore/Snapshot/WatchCoverage.swift Tests/CalendarrCoreTests/WatchTrimmingTests.swift +git commit -m "Add the watch coverage policy and snapshot trimming" +``` + +--- + +## Task 3: Shared calendar filtering + +Both timeline providers narrow a snapshot by calendar key. `CalendarrTimelineProvider.filtered(_:by:)` already does it on iOS; the watch needs the same rule, so it moves into the package rather than being written twice. + +**Files:** +- Create: `../CalendarrKit/Sources/CalendarrCore/Snapshot/SnapshotFilter.swift` +- Test: `../CalendarrKit/Tests/CalendarrCoreTests/SnapshotFilterTests.swift` +- Modify: `CalendarrWidgets/CalendarrTimelineProvider.swift:38-55` + +- [ ] **Step 1: Write the failing tests** + +Create `../CalendarrKit/Tests/CalendarrCoreTests/SnapshotFilterTests.swift`: + +```swift +import Foundation +import Testing +@testable import CalendarrCore + +struct SnapshotFilterTests { + + private static let now = Date(timeIntervalSince1970: 1_789_000_000) + + private static func event(_ id: String, key: String) -> SnapshotEvent { + SnapshotEvent(id: id, title: id, start: now, end: now.addingTimeInterval(3600), + isAllDay: false, colorHex: "#4285f4", location: "", calendarKey: key) + } + + private static func snapshot(_ events: [SnapshotEvent]) -> CalendarrSnapshot { + CalendarrSnapshot(writtenAt: now, + coverageStart: now.addingTimeInterval(-86400), + coverageEnd: now.addingTimeInterval(86400), + isLoggedIn: true, writerVersion: "1.0", events: events, + theme: SnapshotTheme(today: "#1", text: "#2", background: "#3", + line: "#4", primary: "#5", accent: "#6"), + language: "de") + } + + @Test("An empty allow-list means every calendar, not no calendar") + func emptyAllowListKeepsEverything() { + let snap = Self.snapshot([Self.event("a", key: "local:1"), Self.event("b", key: "caldav:2")]) + #expect(snap.filtered(toCalendarKeys: []).events.count == 2) + } + + @Test("An allow-list keeps only the listed calendars") + func allowListNarrows() { + let snap = Self.snapshot([Self.event("a", key: "local:1"), Self.event("b", key: "caldav:2")]) + #expect(snap.filtered(toCalendarKeys: ["local:1"]).events.map(\.id) == ["a"]) + } + + @Test("A deny-list removes only the listed calendars") + func denyListNarrows() { + let snap = Self.snapshot([Self.event("a", key: "local:1"), Self.event("b", key: "caldav:2")]) + #expect(snap.filtered(excludingCalendarKeys: ["local:1"]).events.map(\.id) == ["b"]) + } + + @Test("Filtering narrows the events but never the window") + func windowIsUntouched() { + let snap = Self.snapshot([Self.event("a", key: "local:1")]) + let filtered = snap.filtered(toCalendarKeys: ["nothing:0"]) + + #expect(filtered.events.isEmpty) + #expect(filtered.coverageStart == snap.coverageStart) + #expect(filtered.coverageEnd == snap.coverageEnd) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +cd ../CalendarrKit +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test --filter SnapshotFilterTests +``` + +Expected: FAIL to compile — no member `filtered(toCalendarKeys:)`. + +- [ ] **Step 3: Write the implementation** + +Create `../CalendarrKit/Sources/CalendarrCore/Snapshot/SnapshotFilter.swift`: + +```swift +import Foundation + +extension CalendarrSnapshot { + /// Keep only the listed calendars. An empty set means "every calendar", + /// which is what an unconfigured widget or complication asks for. + /// + /// Filtering narrows the events but deliberately not the window: the + /// snapshot still speaks for the same range, it just has fewer calendars + /// in it. + public func filtered(toCalendarKeys keys: Set) -> CalendarrSnapshot { + guard !keys.isEmpty else { return self } + return replacingEvents(events.filter { keys.contains($0.calendarKey) }) + } + + /// Remove the listed calendars, keeping everything else. An empty set is a + /// no-op. + public func filtered(excludingCalendarKeys keys: Set) -> CalendarrSnapshot { + guard !keys.isEmpty else { return self } + return replacingEvents(events.filter { !keys.contains($0.calendarKey) }) + } + + private func replacingEvents(_ events: [SnapshotEvent]) -> CalendarrSnapshot { + CalendarrSnapshot(writtenAt: writtenAt, + coverageStart: coverageStart, + coverageEnd: coverageEnd, + isLoggedIn: isLoggedIn, + writerVersion: writerVersion, + events: events, + theme: theme, + language: language) + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cd ../CalendarrKit +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test --filter SnapshotFilterTests +``` + +Expected: PASS, 4 tests. + +- [ ] **Step 5: Adopt it in the iOS widget provider** + +In `CalendarrWidgets/CalendarrTimelineProvider.swift`, replace the whole `// MARK: – Filtering` section (the `filtered(_:by:)` method, currently lines 38-55) with: + +```swift + // 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))) + } +``` + +Then add the import at the top of the file, after `import AppIntents`: + +```swift +import CalendarrCore +``` + +- [ ] **Step 6: Build the iOS app to verify the adoption compiles** + +```bash +xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build +``` + +Expected: `** BUILD SUCCEEDED **`. + +- [ ] **Step 7: Commit both repos** + +```bash +cd ../CalendarrKit +git add Sources/CalendarrCore/Snapshot/SnapshotFilter.swift Tests/CalendarrCoreTests/SnapshotFilterTests.swift +git commit -m "Move calendar-key filtering into the shared package" +cd "../Calendarr iOS" +git add CalendarrWidgets/CalendarrTimelineProvider.swift +git commit -m "Filter widget snapshots through the shared package rule" +``` + +--- + +## Task 4: WatchConnectivity wire format + +**Files:** +- Create: `../CalendarrKit/Sources/CalendarrCore/Transport/WatchTransportPayload.swift` +- Test: `../CalendarrKit/Tests/CalendarrCoreTests/WatchTransportTests.swift` + +- [ ] **Step 1: Write the failing tests** + +Create `../CalendarrKit/Tests/CalendarrCoreTests/WatchTransportTests.swift`: + +```swift +import Foundation +import Testing +@testable import CalendarrCore + +/// The phone and the watch ship on independent schedules, so the transport gets +/// the same treatment as the file format: refuse a newer schema rather than +/// mis-decode it. +struct WatchTransportTests { + + private static let now = Date(timeIntervalSince1970: 1_789_000_000) + + private static func snapshot() -> CalendarrSnapshot { + CalendarrSnapshot(writtenAt: now, + coverageStart: now.addingTimeInterval(-86400), + coverageEnd: now.addingTimeInterval(14 * 86400), + isLoggedIn: true, writerVersion: "1.2", + events: [SnapshotEvent(id: "a", title: "Zahnarzt", + start: now.addingTimeInterval(3600), + end: now.addingTimeInterval(7200), + isAllDay: false, colorHex: "#4285f4", + location: "Bern", calendarKey: "local:3")], + theme: SnapshotTheme(today: "#1", text: "#2", background: "#3", + line: "#4", primary: "#5", accent: "#6"), + language: "de") + } + + @Test("A payload survives a round trip") + func roundTrips() throws { + let payload = WatchTransportPayload( + snapshot: Self.snapshot(), + calendars: [SnapshotCalendar(id: "local:3", name: "Privat", colorHex: "#4285f4")], + session: SharedSession(baseURL: "https://cal.example.com", username: "guido", + isLoggedIn: true, writtenAt: Self.now)) + + let decoded = try WatchTransportPayload.decode(try payload.encoded()) + + #expect(decoded.snapshot.events.map(\.id) == ["a"]) + #expect(decoded.snapshot.language == "de") + #expect(decoded.calendars.map(\.name) == ["Privat"]) + #expect(decoded.session?.username == "guido") + } + + @Test("A payload with no session record still decodes") + func sessionIsOptional() throws { + let payload = WatchTransportPayload(snapshot: Self.snapshot(), calendars: [], session: nil) + let decoded = try WatchTransportPayload.decode(try payload.encoded()) + + #expect(decoded.session == nil) + #expect(decoded.calendars.isEmpty) + } + + @Test("The encoded form only contains property-list types") + func encodesToPlistTypes() throws { + let dict = try WatchTransportPayload(snapshot: Self.snapshot(), calendars: [], session: nil).encoded() + + #expect(dict[WatchTransportKey.schema] is Int) + #expect(dict[WatchTransportKey.snapshot] is Data) + #expect(PropertyListSerialization.propertyList(dict, isValidFor: .binary)) + } + + @Test("A newer schema is refused rather than guessed at") + func refusesNewerSchema() throws { + var dict = try WatchTransportPayload(snapshot: Self.snapshot(), calendars: [], session: nil).encoded() + dict[WatchTransportKey.schema] = WatchTransportPayload.maxSupportedSchema + 1 + + #expect(throws: WatchTransportError.unsupportedSchema( + found: WatchTransportPayload.maxSupportedSchema + 1, + maxSupported: WatchTransportPayload.maxSupportedSchema)) { + try WatchTransportPayload.decode(dict) + } + } + + @Test("A payload without a snapshot is rejected") + func rejectsMissingSnapshot() { + let dict: [String: Any] = [WatchTransportKey.schema: WatchTransportPayload.currentSchema] + + #expect(throws: WatchTransportError.missingSnapshot) { + try WatchTransportPayload.decode(dict) + } + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +cd ../CalendarrKit +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test --filter WatchTransportTests +``` + +Expected: FAIL to compile — `cannot find 'WatchTransportPayload' in scope`. + +- [ ] **Step 3: Write the implementation** + +Create `../CalendarrKit/Sources/CalendarrCore/Transport/WatchTransportPayload.swift`: + +```swift +import Foundation + +/// Dictionary keys used on the WatchConnectivity wire. Stable within a major +/// version, like the snapshot filenames. +public enum WatchTransportKey { + public static let schema = "schema" + public static let snapshot = "snapshot" + public static let calendars = "calendars" + public static let session = "session" +} + +public enum WatchTransportError: Error, Equatable, LocalizedError { + case missingSnapshot + case unsupportedSchema(found: Int, maxSupported: Int) + case malformed(String) + + public var errorDescription: String? { + switch self { + case .missingSnapshot: + return "Watch payload carried no snapshot." + case .unsupportedSchema(let found, let max): + return "Watch payload schema \(found) is newer than this build understands (\(max))." + case .malformed(let why): + return "Watch payload could not be decoded: \(why)" + } + } +} + +/// What the phone sends to the watch: a trimmed snapshot, the calendar list for +/// a filter UI, and the non-secret session facts so the watch can tell "signed +/// out" from "never received anything". +/// +/// The auth token is deliberately absent. The watch never talks to the server, +/// so there is nothing for it to authenticate with and nothing to leak. +public struct WatchTransportPayload: Sendable { + /// Bump only for a breaking change to the dictionary shape. + public static let currentSchema = 1 + /// Payloads claiming a higher schema are refused rather than mis-decoded. + public static let maxSupportedSchema = 1 + + public let snapshot: CalendarrSnapshot + public let calendars: [SnapshotCalendar] + public let session: SharedSession? + + public init(snapshot: CalendarrSnapshot, + calendars: [SnapshotCalendar], + session: SharedSession?) { + self.snapshot = snapshot + self.calendars = calendars + self.session = session + } + + private static func encoder() -> JSONEncoder { + let e = JSONEncoder(); e.dateEncodingStrategy = .iso8601; return e + } + + private static func decoder() -> JSONDecoder { + let d = JSONDecoder(); d.dateDecodingStrategy = .iso8601; return d + } + + /// Encode for `WCSession`. Values are `Data` and `Int` only, because + /// WatchConnectivity accepts property-list types and rejects everything + /// else at runtime rather than at compile time. + public func encoded() throws -> [String: Any] { + let e = Self.encoder() + var dict: [String: Any] = [ + WatchTransportKey.schema: Self.currentSchema, + WatchTransportKey.snapshot: try e.encode(snapshot), + WatchTransportKey.calendars: try e.encode(calendars), + ] + if let session { dict[WatchTransportKey.session] = try e.encode(session) } + return dict + } + + public static func decode(_ dict: [String: Any]) throws -> WatchTransportPayload { + let found = dict[WatchTransportKey.schema] as? Int ?? 1 + guard found <= maxSupportedSchema else { + throw WatchTransportError.unsupportedSchema(found: found, maxSupported: maxSupportedSchema) + } + guard let snapshotData = dict[WatchTransportKey.snapshot] as? Data else { + throw WatchTransportError.missingSnapshot + } + + let d = decoder() + let snapshot: CalendarrSnapshot + do { snapshot = try d.decode(CalendarrSnapshot.self, from: snapshotData) } + catch { throw WatchTransportError.malformed("snapshot: \(error)") } + + var calendars: [SnapshotCalendar] = [] + if let data = dict[WatchTransportKey.calendars] as? Data { + calendars = (try? d.decode([SnapshotCalendar].self, from: data)) ?? [] + } + + var session: SharedSession? + if let data = dict[WatchTransportKey.session] as? Data { + session = try? d.decode(SharedSession.self, from: data) + } + + return WatchTransportPayload(snapshot: snapshot, calendars: calendars, session: session) + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cd ../CalendarrKit +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test --filter WatchTransportTests +``` + +Expected: PASS, 5 tests. + +- [ ] **Step 5: Commit** + +```bash +cd ../CalendarrKit +git add Sources/CalendarrCore/Transport/WatchTransportPayload.swift Tests/CalendarrCoreTests/WatchTransportTests.swift +git commit -m "Add the WatchConnectivity payload format with schema refusal" +``` + +--- + +## Task 5: Complication push policy + +`transferCurrentComplicationUserInfo` is the only path that wakes the watch app while it is not running, and its daily budget is small. It is therefore spent only when the *next* appointment actually changed. + +**Files:** +- Create: `../CalendarrKit/Sources/CalendarrCore/Transport/WatchPushPolicy.swift` +- Test: `../CalendarrKit/Tests/CalendarrCoreTests/WatchPushPolicyTests.swift` + +- [ ] **Step 1: Write the failing tests** + +Create `../CalendarrKit/Tests/CalendarrCoreTests/WatchPushPolicyTests.swift`: + +```swift +import Foundation +import Testing +@testable import CalendarrCore + +struct WatchPushPolicyTests { + + private static let now = Date(timeIntervalSince1970: 1_789_000_000) + + private static func event(_ id: String, startOffset: TimeInterval, + duration: TimeInterval = 3600) -> SnapshotEvent { + SnapshotEvent(id: id, title: id, + start: now.addingTimeInterval(startOffset), + end: now.addingTimeInterval(startOffset + duration), + isAllDay: false, colorHex: "#4285f4", location: "", calendarKey: "local:1") + } + + private static func snapshot(_ events: [SnapshotEvent]) -> CalendarrSnapshot { + CalendarrSnapshot(writtenAt: now, + coverageStart: now.addingTimeInterval(-86400), + coverageEnd: now.addingTimeInterval(14 * 86400), + isLoggedIn: true, writerVersion: "1.0", events: events, + theme: SnapshotTheme(today: "#1", text: "#2", background: "#3", + line: "#4", primary: "#5", accent: "#6"), + language: "de") + } + + @Test("The next event is the first one that has not ended yet") + func picksFirstUnfinished() { + let snap = Self.snapshot([Self.event("past", startOffset: -7200), + Self.event("running", startOffset: -600), + Self.event("later", startOffset: 3600)]) + + #expect(WatchPushPolicy.nextEventIdentity(in: snap, at: Self.now)?.id == "running") + } + + @Test("An empty calendar has no next event") + func emptyHasNoIdentity() { + #expect(WatchPushPolicy.nextEventIdentity(in: Self.snapshot([]), at: Self.now) == nil) + } + + @Test("Events are considered in start order regardless of input order") + func sortsBeforePicking() { + let snap = Self.snapshot([Self.event("late", startOffset: 7200), + Self.event("soon", startOffset: 1800)]) + + #expect(WatchPushPolicy.nextEventIdentity(in: snap, at: Self.now)?.id == "soon") + } + + @Test("An unchanged next event does not spend a push") + func unchangedDoesNotPush() { + let identity = NextEventIdentity(id: "a", start: Self.now) + #expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: identity, current: identity) == false) + } + + @Test("A different event, a moved event, or a first event all spend a push") + func changesPush() { + let a = NextEventIdentity(id: "a", start: Self.now) + let b = NextEventIdentity(id: "b", start: Self.now) + let moved = NextEventIdentity(id: "a", start: Self.now.addingTimeInterval(600)) + + #expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: a, current: b)) + #expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: a, current: moved)) + #expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: nil, current: a)) + } + + @Test("Losing the last event spends a push, so the watch stops showing it") + func clearingPushes() { + let a = NextEventIdentity(id: "a", start: Self.now) + #expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: a, current: nil)) + } + + @Test("Two empty calendars in a row do not push") + func stayingEmptyDoesNotPush() { + #expect(WatchPushPolicy.shouldPushComplicationUpdate(previous: nil, current: nil) == false) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +cd ../CalendarrKit +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test --filter WatchPushPolicyTests +``` + +Expected: FAIL to compile — `cannot find 'WatchPushPolicy' in scope`. + +- [ ] **Step 3: Write the implementation** + +Create `../CalendarrKit/Sources/CalendarrCore/Transport/WatchPushPolicy.swift`: + +```swift +import Foundation + +/// Identifies the appointment a complication is currently showing. +/// +/// The start date is part of the identity on purpose: a moved event keeps its +/// id, and the complication is wrong until it is told. +public struct NextEventIdentity: Equatable, Sendable, Codable { + public let id: String + public let start: Date + + public init(id: String, start: Date) { + self.id = id + self.start = start + } +} + +public enum WatchPushPolicy { + /// The first event that has not ended yet — which is what every "next + /// appointment" complication renders, including one that is running now. + public static func nextEventIdentity(in snapshot: CalendarrSnapshot, + at now: Date) -> NextEventIdentity? { + snapshot.events + .filter { $0.end > now } + .min { $0.start < $1.start } + .map { NextEventIdentity(id: $0.id, start: $0.start) } + } + + /// Whether this change is worth one of the day's limited complication + /// pushes. Only a changed next appointment is — everything else reaches the + /// watch on the next application-context delivery. + public static func shouldPushComplicationUpdate(previous: NextEventIdentity?, + current: NextEventIdentity?) -> Bool { + previous != current + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cd ../CalendarrKit +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test --filter WatchPushPolicyTests +``` + +Expected: PASS, 7 tests. + +- [ ] **Step 5: Commit** + +```bash +cd ../CalendarrKit +git add Sources/CalendarrCore/Transport/WatchPushPolicy.swift Tests/CalendarrCoreTests/WatchPushPolicyTests.swift +git commit -m "Decide complication pushes from the next appointment's identity" +``` + +--- + +## Task 6: Complication timeline boundaries + +The iOS provider emits 24 hourly entries. On the watch, entries land on event boundaries instead, so the complication flips in the exact minute the next appointment changes — with no network call and no refresh budget spent. + +**Files:** +- Create: `../CalendarrKit/Sources/CalendarrCore/Complications/ComplicationTimeline.swift` +- Test: `../CalendarrKit/Tests/CalendarrCoreTests/ComplicationTimelineTests.swift` + +- [ ] **Step 1: Write the failing tests** + +Create `../CalendarrKit/Tests/CalendarrCoreTests/ComplicationTimelineTests.swift`: + +```swift +import Foundation +import Testing +@testable import CalendarrCore + +struct ComplicationTimelineTests { + + private static let now = Date(timeIntervalSince1970: 1_789_000_000) + + private static func event(_ id: String, startOffset: TimeInterval, + duration: TimeInterval) -> SnapshotEvent { + SnapshotEvent(id: id, title: id, + start: now.addingTimeInterval(startOffset), + end: now.addingTimeInterval(startOffset + duration), + isAllDay: false, colorHex: "#4285f4", location: "", calendarKey: "local:1") + } + + private static func snapshot(_ events: [SnapshotEvent]) -> CalendarrSnapshot { + CalendarrSnapshot(writtenAt: now, + coverageStart: now.addingTimeInterval(-86400), + coverageEnd: now.addingTimeInterval(14 * 86400), + isLoggedIn: true, writerVersion: "1.0", events: events, + theme: SnapshotTheme(today: "#1", text: "#2", background: "#3", + line: "#4", primary: "#5", accent: "#6"), + language: "de") + } + + @Test("The first entry is always now, so the complication renders immediately") + func startsAtNow() { + let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot([])) + #expect(dates.first == Self.now) + } + + @Test("Entries are sorted and free of duplicates") + func sortedAndDeduplicated() { + // Two events that meet exactly: one ends where the next begins. + let snap = Self.snapshot([Self.event("a", startOffset: 3600, duration: 1800), + Self.event("b", startOffset: 5400, duration: 1800)]) + let dates = ComplicationTimeline.entryDates(from: Self.now, in: snap) + + #expect(dates == dates.sorted()) + #expect(Set(dates).count == dates.count) + } + + @Test("Event starts and ends inside the horizon become entries") + func includesEventBoundaries() { + let snap = Self.snapshot([Self.event("a", startOffset: 3600, duration: 1800)]) + let dates = ComplicationTimeline.entryDates(from: Self.now, in: snap) + + #expect(dates.contains(Self.now.addingTimeInterval(3600))) + #expect(dates.contains(Self.now.addingTimeInterval(5400))) + } + + @Test("Boundaries outside the horizon are ignored") + func ignoresBoundariesBeyondHorizon() { + let snap = Self.snapshot([Self.event("far", startOffset: 40 * 3600, duration: 1800)]) + let dates = ComplicationTimeline.entryDates(from: Self.now, in: snap, horizon: 24 * 3600) + + #expect(dates.contains(Self.now.addingTimeInterval(40 * 3600)) == false) + #expect(dates.allSatisfy { $0 <= Self.now.addingTimeInterval(24 * 3600) }) + } + + @Test("Boundaries already in the past are ignored") + func ignoresPastBoundaries() { + let snap = Self.snapshot([Self.event("past", startOffset: -7200, duration: 1800)]) + let dates = ComplicationTimeline.entryDates(from: Self.now, in: snap) + + #expect(dates.allSatisfy { $0 >= Self.now }) + } + + @Test("An empty calendar still gets hourly entries across the horizon") + func fillsHourlyWhenEmpty() { + let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot([]), + horizon: 24 * 3600) + #expect(dates.count >= 24) + } + + @Test("The entry count is capped, keeping the earliest entries") + func capsEntries() { + var events: [SnapshotEvent] = [] + for i in 0..<300 { + events.append(Self.event("e\(i)", startOffset: Double(i) * 60, duration: 30)) + } + let dates = ComplicationTimeline.entryDates(from: Self.now, in: Self.snapshot(events), + maxEntries: 50) + + #expect(dates.count == 50) + #expect(dates.first == Self.now) + #expect(dates == dates.sorted()) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +cd ../CalendarrKit +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test --filter ComplicationTimelineTests +``` + +Expected: FAIL to compile — `cannot find 'ComplicationTimeline' in scope`. + +- [ ] **Step 3: Write the implementation** + +Create `../CalendarrKit/Sources/CalendarrCore/Complications/ComplicationTimeline.swift`: + +```swift +import Foundation + +/// When a complication needs to be redrawn. +/// +/// A watch-face complication has a small refresh budget, so the timeline is +/// pre-computed from data the watch already holds instead of being refreshed on +/// a schedule: an entry on every event boundary means the complication changes +/// in the exact minute the next appointment does, without a network call. +public enum ComplicationTimeline { + /// Dates at which a complication should re-render. + /// + /// Always begins with `now` so the first entry renders immediately. Event + /// starts and ends within `horizon` become entries; hourly marks fill the + /// rest so a quiet calendar still advances its date and countdown. The + /// result is sorted, deduplicated to the second, and capped at + /// `maxEntries`, keeping the earliest. + public static func entryDates(from now: Date, + in snapshot: CalendarrSnapshot, + horizon: TimeInterval = 24 * 3600, + maxEntries: Int = 100) -> [Date] { + let end = now.addingTimeInterval(horizon) + var seen: Set = [] + var dates: [Date] = [] + + func add(_ date: Date) { + guard date >= now, date <= end else { return } + // Deduplicate to whole seconds: two events that meet exactly would + // otherwise produce two entries a float apart. + let key = Int(date.timeIntervalSince1970.rounded()) + guard seen.insert(key).inserted else { return } + dates.append(date) + } + + add(now) + for event in snapshot.events { + add(event.start) + add(event.end) + } + var hourly = now.addingTimeInterval(3600) + while hourly <= end { + add(hourly) + hourly = hourly.addingTimeInterval(3600) + } + + return Array(dates.sorted().prefix(maxEntries)) + } +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +cd ../CalendarrKit +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test --filter ComplicationTimelineTests +``` + +Expected: PASS, 7 tests. + +- [ ] **Step 5: Run the whole package suite** + +```bash +cd ../CalendarrKit +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift build +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test +``` + +Expected: build succeeds against the native macOS SDK (which proves no UIKit crept in) and every test passes. + +- [ ] **Step 6: Commit** + +```bash +cd ../CalendarrKit +git add Sources/CalendarrCore/Complications/ComplicationTimeline.swift Tests/CalendarrCoreTests/ComplicationTimelineTests.swift +git commit -m "Compute complication timeline entries from event boundaries" +``` + +--- +## Task 7: iOS background refresh + +Independently valuable: the home-screen widgets are currently only as fresh as the last time the app was opened, because `publishWidgetSnapshot()` runs only from `CalendarStore` actions. This task fixes that, and the watch inherits the fix. + +`allCachedEvents` is in-memory only, so a `CalendarStore` created in the background starts with no events but with the persisted hidden/banished/reminder sets intact. A forced fetch fills it, and `publishWidgetSnapshot()` then runs exactly as it does in the foreground. + +**Files:** +- Create: `Calendarr iOS/Services/BackgroundRefresh.swift` +- Modify: `Calendarr iOS/CalendarrApp.swift:5-13` +- Modify: `Calendarr iOS.xcodeproj/project.pbxproj` (two Info.plist build settings, both configurations) + +- [ ] **Step 1: Write the refresh service** + +Create `Calendarr iOS/Services/BackgroundRefresh.swift`: + +```swift +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 + } +} +``` + +- [ ] **Step 2: Register it at launch** + +In `Calendarr iOS/CalendarrApp.swift`, replace the struct body so it reads: + +```swift +@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() + } + + var body: some Scene { + WindowGroup { + RootView() + .environment(appState) + .task { BackgroundRefresh.schedule() } + } + } +} +``` + +- [ ] **Step 3: Add the Info.plist keys** + +In Xcode, select the **Calendarr iOS** target → Build Settings, and add to **both** Debug and Release: + +- `INFOPLIST_KEY_UIBackgroundModes` = `fetch` +- `INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers` = `com.scarriffleservices.calendarr.ios.refresh` + +- [ ] **Step 4: Build and verify the keys reached the built Info.plist** + +```bash +xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -derivedDataPath /tmp/calendarr-dd CODE_SIGNING_ALLOWED=NO build +plutil -p "/tmp/calendarr-dd/Build/Products/Debug-iphonesimulator/Calendarr iOS.app/Info.plist" | grep -A3 -i "BGTaskScheduler\|UIBackgroundModes" +``` + +Expected: `** BUILD SUCCEEDED **`, and the output shows both `UIBackgroundModes` containing `fetch` and `BGTaskSchedulerPermittedIdentifiers` containing the identifier. + +If `INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers` does not appear, the build setting is not supported by this Xcode. In that case create `Calendarr iOS/Info.plist` containing only the two keys, set `INFOPLIST_FILE = Calendarr iOS/Info.plist` and `GENERATE_INFOPLIST_FILE = NO` on the target, and re-run this step: + +```xml + + + + + UIBackgroundModes + fetch + BGTaskSchedulerPermittedIdentifiers + com.scarriffleservices.calendarr.ios.refresh + + +``` + +- [ ] **Step 5: Commit** + +```bash +git add "Calendarr iOS/Services/BackgroundRefresh.swift" "Calendarr iOS/CalendarrApp.swift" "Calendarr iOS.xcodeproj/project.pbxproj" +git commit -m "Refresh the shared snapshot in the background so widgets stop going stale" +git push origin main +``` + +--- + +## Task 8: WatchConnectivity sender on the phone + +**Files:** +- Create: `Calendarr iOS/Services/WatchSyncService.swift` +- Modify: `Calendarr iOS/Models/CalendarStore.swift:602` and `:623-628` +- Modify: `Calendarr iOS/CalendarrApp.swift` (activate the session) +- Modify: `Calendarr iOS/CalendarrApp.swift` `AppState.logout()` + +- [ ] **Step 1: Write the sender** + +Create `Calendarr iOS/Services/WatchSyncService.swift`: + +```swift +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() + } +} +``` + +- [ ] **Step 2: Activate the session at launch** + +In `Calendarr iOS/CalendarrApp.swift`, extend `init()` so it reads: + +```swift + 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() + } +``` + +- [ ] **Step 3: Push from the one place that publishes** + +In `Calendarr iOS/Models/CalendarStore.swift`, inside `publishWidgetSnapshot()`, replace this line (currently line 602): + +```swift + WidgetStore.writeCalendars(Array(calendarMap.values).sorted { $0.name < $1.name }) +``` + +with: + +```swift + let calendars = Array(calendarMap.values).sorted { $0.name < $1.name } + WidgetStore.writeCalendars(calendars) +``` + +Then replace the closing lines of the same method (currently lines 623-628): + +```swift + WidgetStore.write(WidgetStore.makeSnapshot(events: Array(visible), + coverageStart: from, + coverageEnd: to)) + WidgetTimelineNotifier.reload() +``` + +with: + +```swift + 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) +``` + +- [ ] **Step 4: Tell the watch about sign-out** + +In `Calendarr iOS/CalendarrApp.swift`, in `AppState.logout()`, after the existing `publishSession()` call, add: + +```swift + // 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() +``` + +- [ ] **Step 5: Build the iOS app** + +```bash +xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build +``` + +Expected: `** BUILD SUCCEEDED **`. + +- [ ] **Step 6: Commit** + +```bash +git add "Calendarr iOS/Services/WatchSyncService.swift" "Calendarr iOS/CalendarrApp.swift" "Calendarr iOS/Models/CalendarStore.swift" +git commit -m "Send the trimmed snapshot to the watch over WatchConnectivity" +git push origin main +``` + +--- + +## Task 9: Create the two watchOS targets + +This is Xcode project work, not code. Editing `project.pbxproj` by hand to add targets is error-prone; use the UI. + +**Files:** +- Create: `CalendarrWatch/CalendarrWatch.entitlements` +- Create: `CalendarrWatchComplications/CalendarrWatchComplications.entitlements` +- Modify: `Calendarr iOS.xcodeproj/project.pbxproj` + +- [ ] **Step 1: Add the watch app target** + +In Xcode: File → New → Target → watchOS → **App**. + +- Product Name: `CalendarrWatch` +- Bundle Identifier: `com.scarriffleservices.calendarr.ios.watchkitapp` +- Interface: SwiftUI, Language: Swift +- **Uncheck** Include Notification Scene +- **Check** "Watch App for Existing iOS App" / set the companion to **Calendarr iOS** so `WKCompanionAppBundleIdentifier` resolves to `com.scarriffleservices.calendarr.ios` + +Then in Build Settings for the new target set `WATCHOS_DEPLOYMENT_TARGET = 26.0`. + +- [ ] **Step 2: Add the complications target** + +File → New → Target → watchOS → **Widget Extension**. + +- Product Name: `CalendarrWatchComplications` +- Bundle Identifier: `com.scarriffleservices.calendarr.ios.watchkitapp.complications` +- Embed in Application: `CalendarrWatch` +- **Uncheck** Include Live Activity +- **Check** Include Configuration App Intent + +Set `WATCHOS_DEPLOYMENT_TARGET = 26.0`. + +Delete the template files Xcode generated in both new target folders; the tasks below create the real ones. + +- [ ] **Step 3: Link the shared package to both targets** + +For `CalendarrWatch` and `CalendarrWatchComplications`: target → General → "Frameworks, Libraries, and Embedded Content" → **+** → `CalendarrCore` (under the local `CalendarrKit` package). + +- [ ] **Step 4: Write the entitlements** + +Create `CalendarrWatch/CalendarrWatch.entitlements`: + +```xml + + + + + com.apple.security.application-groups + + group.com.scarriffleservices.calendarr + + + +``` + +Create `CalendarrWatchComplications/CalendarrWatchComplications.entitlements` with **identical** contents. + +Set `CODE_SIGN_ENTITLEMENTS` on each target to its own file. + +No keychain access group: the watch never holds a token, so there is nothing there to protect. + +The App Group identifier carries **no** Team ID prefix. watchOS follows the iOS rule here, and `CalendarrAppGroup.current` already resolves to the unprefixed form because its `#if os(macOS) || targetEnvironment(macCatalyst)` guard falls through. Getting this wrong fails silently: `containerURL(...)` returns nil and every read and write no-ops. + +- [ ] **Step 5: Register the identifiers in the developer portal** + +For both new bundle IDs, enable the **App Groups** capability and select `group.com.scarriffleservices.calendarr`. + +- [ ] **Step 6: Verify both targets build** + +```bash +xcodebuild -project "Calendarr iOS.xcodeproj" -scheme CalendarrWatch -sdk watchsimulator -destination 'generic/platform=watchOS Simulator' CODE_SIGNING_ALLOWED=NO build +``` + +Expected: `** BUILD SUCCEEDED **` (an empty app at this point). + +- [ ] **Step 7: Commit** + +```bash +git add "Calendarr iOS.xcodeproj/project.pbxproj" CalendarrWatch CalendarrWatchComplications +git commit -m "Add the watchOS app and complication targets" +git push origin main +``` + +--- +## Task 10: Receive the payload on the watch + +**Files:** +- Create: `CalendarrWatch/WatchL10n.swift` (membership: **both** watch targets) +- Create: `CalendarrWatch/WatchFilterStore.swift` (membership: **both** watch targets) +- Create: `CalendarrWatch/WatchSnapshotReceiver.swift` +- Create: `CalendarrWatch/CalendarrWatchApp.swift` + +- [ ] **Step 1: Write the strings** + +Create `CalendarrWatch/WatchL10n.swift`. Add it to **both** watch targets (the complications need the same strings). + +```swift +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.today": "Heute", + "watch.tomorrow": "Morgen", + "watch.events_today": "Termine", + "watch.open_on_iphone": "Calendarr einmal auf dem iPhone öffnen", + "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 für diesen Zeitraum", + ], + "en": [ + "watch.title": "Calendarr", + "watch.calendars": "Calendars", + "watch.allday": "All-day", + "watch.no_events": "No events", + "watch.today": "Today", + "watch.tomorrow": "Tomorrow", + "watch.events_today": "events", + "watch.open_on_iphone": "Open Calendarr on your iPhone once", + "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 for this range", + ], + ] +} +``` + +- [ ] **Step 2: Write the watch-local filter store** + +Create `CalendarrWatch/WatchFilterStore.swift`. Add it to **both** watch targets — the app writes it, the complications read it, and that is exactly why it lives in the App Group rather than in the app's own defaults. + +```swift +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 { + Set(defaults?.stringArray(forKey: key) ?? []) + } + + static func save(_ keys: Set) { + defaults?.set(keys.sorted(), forKey: key) + } +} +``` + +- [ ] **Step 3: Write the receiver** + +Create `CalendarrWatch/WatchSnapshotReceiver.swift`: + +```swift +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) } + } +} +``` + +- [ ] **Step 4: Write the app entry point** + +Create `CalendarrWatch/CalendarrWatchApp.swift`: + +```swift +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) + } + } +} +``` + +- [ ] **Step 5: Commit (the build is verified in Task 12, once the views exist)** + +```bash +git add CalendarrWatch +git commit -m "Receive the phone's snapshot on the watch and store it locally" +``` + +--- + +## Task 11: Watch read model and status states + +**Files:** +- Create: `CalendarrWatch/WatchSnapshotModel.swift` +- Create: `CalendarrWatch/Views/WatchStatusView.swift` + +- [ ] **Step 1: Write the read model** + +Create `CalendarrWatch/WatchSnapshotModel.swift`: + +```swift +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 = 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() + } +} +``` + +- [ ] **Step 2: Write the status view** + +Create `CalendarrWatch/Views/WatchStatusView.swift`: + +```swift +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) + } + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add CalendarrWatch/WatchSnapshotModel.swift CalendarrWatch/Views/WatchStatusView.swift +git commit -m "Add the watch read model and its named empty states" +``` + +--- + +## Task 12: Agenda list + +**Files:** +- Create: `CalendarrWatch/Views/WatchEventRow.swift` +- Create: `CalendarrWatch/Views/WatchAgendaView.swift` + +- [ ] **Step 1: Write the event row** + +Create `CalendarrWatch/Views/WatchEventRow.swift`: + +```swift +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) -> String { + if event.isAllDay { return WatchL10n.t("watch.allday", language) } + let formatter = DateFormatter() + formatter.locale = WatchL10n.locale(language) + formatter.dateFormat = "HH:mm" + let start = formatter.string(from: event.start) + guard event.end > event.start else { return start } + return "\(start) – \(formatter.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) + } +} +``` + +- [ ] **Step 2: Write the agenda** + +Create `CalendarrWatch/Views/WatchAgendaView.swift`: + +```swift +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) + + " \u{2192} " + 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 } + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add CalendarrWatch/Views/WatchEventRow.swift CalendarrWatch/Views/WatchAgendaView.swift +git commit -m "Add the watch agenda list" +``` + +--- + +## Task 13: Event detail + +**Files:** +- Create: `CalendarrWatch/Views/WatchEventDetailView.swift` + +- [ ] **Step 1: Write the detail view** + +Create `CalendarrWatch/Views/WatchEventDetailView.swift`: + +```swift +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) + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add CalendarrWatch/Views/WatchEventDetailView.swift +git commit -m "Add the read-only event detail on the watch" +``` + +--- + +## Task 14: Calendar filter + +The rows are `Button`s with an SF Symbol checkbox, deliberately not watchOS `Toggle`s: a switch reads as a state indicator you wait on rather than a control you tap. + +**Files:** +- Create: `CalendarrWatch/Views/WatchCalendarFilterView.swift` + +- [ ] **Step 1: Write the filter view** + +Create `CalendarrWatch/Views/WatchCalendarFilterView.swift`: + +```swift +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) { + 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] : []) + } +} +``` + +- [ ] **Step 2: Build the watch app** + +```bash +xcodebuild -project "Calendarr iOS.xcodeproj" -scheme CalendarrWatch -sdk watchsimulator -destination 'generic/platform=watchOS Simulator' CODE_SIGNING_ALLOWED=NO build +``` + +Expected: `** BUILD SUCCEEDED **`. + +- [ ] **Step 3: Commit** + +```bash +git add CalendarrWatch/Views/WatchCalendarFilterView.swift +git commit -m "Add the watch calendar filter with tappable checkbox rows" +git push origin main +``` + +--- +## Task 15: Complication data plumbing + +**Files:** +- Create: `CalendarrWatchComplications/WatchComplicationSupport.swift` +- Create: `CalendarrWatchComplications/WatchCalendarSelectionIntent.swift` +- Create: `CalendarrWatchComplications/WatchComplicationProvider.swift` + +- [ ] **Step 1: Write the shared helpers** + +Create `CalendarrWatchComplications/WatchComplicationSupport.swift`: + +```swift +import SwiftUI +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 + } + + /// 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 + } + + /// 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)) + } + + 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 bezel label for a corner complication: time plus title, or a + /// plain "no events". + static func cornerLabel(_ 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)" + } +} +``` + +- [ ] **Step 2: Write the configuration intent** + +Create `CalendarrWatchComplications/WatchCalendarSelectionIntent.swift`: + +```swift +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]? +} +``` + +- [ ] **Step 3: Write the timeline provider** + +Create `CalendarrWatchComplications/WatchComplicationProvider.swift`: + +```swift +import WidgetKit +import AppIntents +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 { + 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) + } + + /// 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))) + } +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add CalendarrWatchComplications +git commit -m "Add complication data plumbing and event-boundary timelines" +``` + +--- + +## Task 16: The eight complications + +watchOS tints complications monochrome with the watch face's colour, so per-calendar colours are unavailable here and exactly one element per complication is marked `widgetAccentable()`. + +**Files:** +- Create: `CalendarrWatchComplications/CornerComplicationViews.swift` +- Create: `CalendarrWatchComplications/RectangularComplicationViews.swift` +- Create: `CalendarrWatchComplications/SmallComplicationViews.swift` +- Create: `CalendarrWatchComplications/CalendarrWatchComplications.swift` + +- [ ] **Step 1: Write the corner views** + +Create `CalendarrWatchComplications/CornerComplicationViews.swift`: + +```swift +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.countdownTarget(next, at: entry.date), style: .timer) + .widgetAccentable() + } else { + Image(systemName: "calendar") + } + } + .widgetLabel { + Text(WatchComplicationSupport.cornerLabel(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)) + .widgetAccentable() + } else { + Image(systemName: "calendar") + } + } + .widgetLabel { + Text(next?.title ?? WatchL10n.t("watch.no_events", entry.language)) + } + } +} +``` + +- [ ] **Step 2: Write the rectangular views** + +Create `CalendarrWatchComplications/RectangularComplicationViews.swift`: + +```swift +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() + } +} +``` + +- [ ] **Step 3: Write the circular and inline views** + +Create `CalendarrWatchComplications/SmallComplicationViews.swift`: + +```swift +import SwiftUI +import WidgetKit +import CalendarrCore + +/// 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(target, style: .timer) + .font(.system(size: 12)) + } + .gaugeStyle(.accessoryCircularCapacity) + .widgetAccentable() + } else { + ZStack { + AccessoryWidgetBackground() + Image(systemName: "calendar").font(.system(size: 18)) + } + } + } + + private func inline(_ next: SnapshotEvent?) -> some View { + guard let next else { + return Label(WatchL10n.t("watch.no_events", entry.language), systemImage: "calendar") + } + let target = WatchComplicationSupport.countdownTarget(next, at: entry.date) + return Label { + Text(target, style: .timer) + } icon: { + Image(systemName: "timer") + } + } +} + +/// Day number and month. No appointment information — a date replacement. +struct DateComplicationView: View { + let entry: WatchComplicationEntry + @Environment(\.widgetFamily) private var family + + private var day: Int { + Calendar(identifier: .gregorian).component(.day, from: entry.date) + } + + var body: some View { + switch family { + case .accessoryInline: + Label(WatchComplicationSupport.monthAbbreviation(entry.date, language: entry.language) + + " \(day)", systemImage: "calendar") + default: + ZStack { + AccessoryWidgetBackground() + VStack(spacing: 0) { + Text("\(day)") + .font(.system(size: 22, weight: .bold)) + .minimumScaleFactor(0.7) + .widgetAccentable() + Text(WatchComplicationSupport.monthAbbreviation(entry.date, language: entry.language)) + .font(.system(size: 8, weight: .semibold)) + } + } + } + } +} + +/// 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: + ZStack { + AccessoryWidgetBackground() + VStack(spacing: 0) { + Text("\(count)") + .font(.system(size: 22, weight: .bold)) + .widgetAccentable() + Text(WatchL10n.t("watch.events_today", entry.language).uppercased()) + .font(.system(size: 7, weight: .semibold)) + .lineLimit(1) + .minimumScaleFactor(0.6) + } + } + } + } +} +``` + +- [ ] **Step 4: Write the widget bundle** + +Create `CalendarrWatchComplications/CalendarrWatchComplications.swift`: + +```swift +import WidgetKit +import SwiftUI + +@main +struct CalendarrWatchComplicationBundle: WidgetBundle { + var body: some Widget { + WatchNextEventWidget() + WatchCornerCountdownWidget() + 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 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]) + } +} +``` + +- [ ] **Step 5: Build the complications target** + +```bash +xcodebuild -project "Calendarr iOS.xcodeproj" -scheme CalendarrWatch -sdk watchsimulator -destination 'generic/platform=watchOS Simulator' CODE_SIGNING_ALLOWED=NO build +``` + +Expected: `** BUILD SUCCEEDED **` — the scheme builds the embedded extension too. + +- [ ] **Step 6: Commit** + +```bash +git add CalendarrWatchComplications +git commit -m "Add the eight watch-face complications" +git push origin main +``` + +--- + +## Task 17: Whole-stack verification and handoff + +**Files:** +- Modify: `docs/superpowers/specs/2026-09-11-apple-watch-app-design.md` (status line) + +- [ ] **Step 1: Run the package test suite** + +```bash +cd ../CalendarrKit +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift build +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test +``` + +Expected: build succeeds against the native macOS SDK, every test passes. + +- [ ] **Step 2: Build every app target** + +```bash +cd "../Calendarr iOS" +xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build +xcodebuild -project "Calendarr iOS.xcodeproj" -scheme CalendarrWatch -sdk watchsimulator -destination 'generic/platform=watchOS Simulator' CODE_SIGNING_ALLOWED=NO build +``` + +Expected: `** BUILD SUCCEEDED **` twice. + +- [ ] **Step 3: Confirm the package stayed Foundation-only** + +```bash +cd ../CalendarrKit +grep -rn "import SwiftUI\|import UIKit\|import AppKit\|import WidgetKit\|import WatchConnectivity" Sources/ || echo "clean" +``` + +Expected: `clean`. A UIKit import here would compile for Catalyst and break the native macOS consumer. + +- [ ] **Step 4: Mark the spec implemented** + +In `docs/superpowers/specs/2026-09-11-apple-watch-app-design.md`, change the status line to: + +```markdown +Status: implemented — pending on-device verification +``` + +- [ ] **Step 5: Commit** + +```bash +cd "../Calendarr iOS" +git add docs/superpowers/specs/2026-09-11-apple-watch-app-design.md +git commit -m "Mark the watch app spec implemented" +git push origin main +``` + +- [ ] **Step 6: Hand back with the open items named** + +Report these explicitly; none of them can be closed on this machine: + +1. **Xcode with watchOS 27 support** must be installed before anything can be installed on the watch. Xcode 26.6 ships the watchOS 26.5 SDK; the device runs watchOS 27 beta. +2. **Developer portal**: both new bundle IDs need the App Groups capability enabled for `group.com.scarriffleservices.calendarr`, or every read on the watch silently returns nothing. +3. **On-device verification** belongs to the user: complication layout on the round face, the curved corner label, whether the agenda's day grouping reads well, and whether a payload actually arrives when the watch app is closed. +4. **App Store Connect**: watch screenshots are required for the version that ships this. The watch app is a new target in the existing app record, not a new app. +5. `CalendarrKit` has **no git remote** — its commits are local only. If the watch app is ever built on another machine, that repo needs a remote first. From 75a6a5bd79daa80fe888426d841c935b47efeb1c Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 11 Sep 2026 16:35:23 +0200 Subject: [PATCH 72/90] Fix two OIDC compile errors against the current SDK and AppAuth UIAccessibilityIsGuidedAccessEnabled() is gone in iOS 26; use the property. AppAuth declares lastAuthorizationResponse and its request as nonnull, so the optional chain was rejected. Behaviour is unchanged: nonce is optional either way, which is the tolerance the comment there already describes. Co-Authored-By: Claude Opus 5 --- Calendarr iOS/Services/OIDCAuthenticator.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Calendarr iOS/Services/OIDCAuthenticator.swift b/Calendarr iOS/Services/OIDCAuthenticator.swift index cb319f6..eae83b1 100644 --- a/Calendarr iOS/Services/OIDCAuthenticator.swift +++ b/Calendarr iOS/Services/OIDCAuthenticator.swift @@ -66,7 +66,7 @@ final class OIDCAuthenticator { /// 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 { UIAccessibilityIsGuidedAccessEnabled() } + 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") } @@ -174,7 +174,7 @@ final class OIDCAuthenticator { // 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 + let nonce = state.lastAuthorizationResponse.request.nonce return (idToken, tokens.1, nonce) } From c8a36c161f51121a07189ba775fa8f850b50894c Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 11 Sep 2026 16:35:23 +0200 Subject: [PATCH 73/90] Filter widget snapshots through the shared package rule Co-Authored-By: Claude Opus 5 --- .../CalendarrTimelineProvider.swift | 20 ++++--------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/CalendarrWidgets/CalendarrTimelineProvider.swift b/CalendarrWidgets/CalendarrTimelineProvider.swift index c3be55a..3ad84dc 100644 --- a/CalendarrWidgets/CalendarrTimelineProvider.swift +++ b/CalendarrWidgets/CalendarrTimelineProvider.swift @@ -1,5 +1,6 @@ import WidgetKit import AppIntents +import CalendarrCore struct CalendarrEntry: TimelineEntry { let date: Date @@ -37,22 +38,9 @@ struct CalendarrTimelineProvider: AppIntentTimelineProvider { // MARK: – Filtering private func filtered(_ snapshot: WidgetSnapshot?, by config: CalendarSelectionIntent) -> WidgetSnapshot? { - guard let snapshot else { return nil } - let ids = (config.selectedCalendars ?? []).map { $0.id } - guard !ids.isEmpty else { return snapshot } // nothing selected = show all - let keep = Set(ids) - // Filtering narrows the events but not the window: the snapshot still - // speaks for the same range, it just has fewer calendars in it. - return WidgetSnapshot( - writtenAt: snapshot.writtenAt, - coverageStart: snapshot.coverageStart, - coverageEnd: snapshot.coverageEnd, - isLoggedIn: snapshot.isLoggedIn, - writerVersion: snapshot.writerVersion, - events: snapshot.events.filter { keep.contains($0.calendarKey) }, - theme: snapshot.theme, - language: snapshot.language - ) + // 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))) } } From ad7a405f3295537b2e94e2a593408b9b1214c257 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 11 Sep 2026 16:39:34 +0200 Subject: [PATCH 74/90] Refresh the shared snapshot in the background so widgets stop going stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit publishWidgetSnapshot() only ever ran from a user action, so an event created on the web or on Android did not reach the widgets until the app was opened again. A BGAppRefreshTask now republishes without the foreground. The two keys this needs are arrays, which Xcode does not expose as INFOPLIST_KEY_ build settings — verified by building and inspecting the result. Hence a partial Calendarr-Info.plist that generation merges into. It sits at the project root rather than inside the target folder: that folder is a synchronized group, which would also copy the file as a resource and collide with the processed one. Co-Authored-By: Claude Opus 5 --- Calendarr iOS.xcodeproj/project.pbxproj | 2 + Calendarr iOS/CalendarrApp.swift | 7 ++ .../Services/BackgroundRefresh.swift | 82 +++++++++++++++++++ Calendarr-Info.plist | 14 ++++ 4 files changed, 105 insertions(+) create mode 100644 Calendarr iOS/Services/BackgroundRefresh.swift create mode 100644 Calendarr-Info.plist diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index bdaaaad..1152630 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -509,6 +509,7 @@ ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Calendarr-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = Calendarr; INFOPLIST_KEY_CFBundleName = Calendarr; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; @@ -558,6 +559,7 @@ ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Calendarr-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = Calendarr; INFOPLIST_KEY_CFBundleName = Calendarr; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; diff --git a/Calendarr iOS/CalendarrApp.swift b/Calendarr iOS/CalendarrApp.swift index a12c147..c6bbe26 100644 --- a/Calendarr iOS/CalendarrApp.swift +++ b/Calendarr iOS/CalendarrApp.swift @@ -4,10 +4,17 @@ import SwiftUI 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() + } + var body: some Scene { WindowGroup { RootView() .environment(appState) + .task { BackgroundRefresh.schedule() } } } } diff --git a/Calendarr iOS/Services/BackgroundRefresh.swift b/Calendarr iOS/Services/BackgroundRefresh.swift new file mode 100644 index 0000000..fbacf28 --- /dev/null +++ b/Calendarr iOS/Services/BackgroundRefresh.swift @@ -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 + } +} diff --git a/Calendarr-Info.plist b/Calendarr-Info.plist new file mode 100644 index 0000000..c982e17 --- /dev/null +++ b/Calendarr-Info.plist @@ -0,0 +1,14 @@ + + + + + BGTaskSchedulerPermittedIdentifiers + + com.scarriffleservices.calendarr.ios.refresh + + UIBackgroundModes + + fetch + + + From b28c5a0502b8a226eca5e53842916271299f6789 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 11 Sep 2026 16:40:45 +0200 Subject: [PATCH 75/90] Send the trimmed snapshot to the watch over WatchConnectivity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App Groups are per-device, so the watch cannot read the file the phone writes and has to be handed the data. Three delivery paths: application context for the latest state, a complication push only when the next appointment actually changed (the budget is small), and a reply to the watch asking directly. Sign-out pushes an empty signed-out snapshot. Clearing our own container is not enough — the watch holds its own copy and would keep rendering the previous user's calendar. Co-Authored-By: Claude Opus 5 --- Calendarr iOS/CalendarrApp.swift | 4 + Calendarr iOS/Models/CalendarStore.swift | 13 +- Calendarr iOS/Services/WatchSyncService.swift | 156 ++++++++++++++++++ 3 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 Calendarr iOS/Services/WatchSyncService.swift diff --git a/Calendarr iOS/CalendarrApp.swift b/Calendarr iOS/CalendarrApp.swift index c6bbe26..8f41dc2 100644 --- a/Calendarr iOS/CalendarrApp.swift +++ b/Calendarr iOS/CalendarrApp.swift @@ -8,6 +8,7 @@ struct CalendarrApp: App { // 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 { @@ -114,6 +115,9 @@ class AppState { // 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() { diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index 05e68e4..266c20c 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -599,7 +599,8 @@ class CalendarStore { colorHex: ev.calendarColor) } } - WidgetStore.writeCalendars(Array(calendarMap.values).sorted { $0.name < $1.name }) + let calendars = Array(calendarMap.values).sorted { $0.name < $1.name } + WidgetStore.writeCalendars(calendars) let visible = allCachedEvents .filter { ev in @@ -620,10 +621,14 @@ class CalendarStore { location: ev.location, calendarKey: Self.calendarKey(source: ev.source, calendarId: ev.calendarId)) } - WidgetStore.write(WidgetStore.makeSnapshot(events: Array(visible), - coverageStart: from, - coverageEnd: to)) + 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 diff --git a/Calendarr iOS/Services/WatchSyncService.swift b/Calendarr iOS/Services/WatchSyncService.swift new file mode 100644 index 0000000..c5bb05f --- /dev/null +++ b/Calendarr iOS/Services/WatchSyncService.swift @@ -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() + } +} From ddda0ecd3ea93f716ddf71abc223cac717bfcbcc Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 11 Sep 2026 16:41:07 +0200 Subject: [PATCH 76/90] Correct the plan's Info.plist step to what actually works Xcode exposes neither UIBackgroundModes nor BGTaskSchedulerPermittedIdentifiers as an INFOPLIST_KEY_ build setting, and a partial plist inside a synchronized group collides with the processed one. Both found by building, not by reading. Also records that synchronized groups pick up new source files on their own. Co-Authored-By: Claude Opus 5 --- .../plans/2026-09-11-apple-watch-app.md | 62 +++++++++++++------ 1 file changed, 43 insertions(+), 19 deletions(-) diff --git a/docs/superpowers/plans/2026-09-11-apple-watch-app.md b/docs/superpowers/plans/2026-09-11-apple-watch-app.md index 5759806..9b0135e 100644 --- a/docs/superpowers/plans/2026-09-11-apple-watch-app.md +++ b/docs/superpowers/plans/2026-09-11-apple-watch-app.md @@ -12,6 +12,14 @@ --- +## Note on the source files + +`Calendarr iOS/`, `CalendarrWidgets/` and `Shared/` are +`PBXFileSystemSynchronizedRootGroup`s, so **new `.swift` files are picked up +automatically** — no `project.pbxproj` surgery to add them. This does not extend +to new *targets* (Task 9), and it is why a partial Info.plist has to live +outside those folders (Task 7). + ## Prerequisite (blocks device verification only) Xcode 26.6 ships the watchOS 26.5 SDK. The target device runs watchOS 27 beta, so builds cannot be installed on it until an Xcode with watchOS 27 support is present. Every task below can be written and build-verified without it. Do not block on this; note it when handing back. @@ -44,6 +52,7 @@ Xcode 26.6 ships the watchOS 26.5 SDK. The target device runs watchOS 27 beta, s | File | Responsibility | |---|---| +| `Calendarr-Info.plist` | Partial Info.plist for the two background-task array keys, merged into the generated one. At the project root, not in the target folder. | | `Calendarr iOS/Services/BackgroundRefresh.swift` | `BGAppRefreshTask` registration and handling. | | `Calendarr iOS/Services/WatchSyncService.swift` | WCSession sender on the phone. | | `CalendarrWatch/CalendarrWatchApp.swift` | watchOS app entry point. | @@ -1114,7 +1123,8 @@ Independently valuable: the home-screen widgets are currently only as fresh as t **Files:** - Create: `Calendarr iOS/Services/BackgroundRefresh.swift` - Modify: `Calendarr iOS/CalendarrApp.swift:5-13` -- Modify: `Calendarr iOS.xcodeproj/project.pbxproj` (two Info.plist build settings, both configurations) +- Create: `Calendarr-Info.plist` (project root) +- Modify: `Calendarr iOS.xcodeproj/project.pbxproj` (`INFOPLIST_FILE`, both configurations) - [ ] **Step 1: Write the refresh service** @@ -1230,37 +1240,51 @@ struct CalendarrApp: App { } ``` -- [ ] **Step 3: Add the Info.plist keys** +- [x] **Step 3: Add the Info.plist keys** -In Xcode, select the **Calendarr iOS** target → Build Settings, and add to **both** Debug and Release: +Both keys are arrays, and Xcode does **not** expose either as an +`INFOPLIST_KEY_` build setting — verified by setting them, building, and finding +them absent from the result while `INFOPLIST_KEY_UIApplicationSupportsIndirect\ +InputEvents` did land. Use a partial Info.plist instead; generation stays on and +merges into it. -- `INFOPLIST_KEY_UIBackgroundModes` = `fetch` -- `INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers` = `com.scarriffleservices.calendarr.ios.refresh` - -- [ ] **Step 4: Build and verify the keys reached the built Info.plist** - -```bash -xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -derivedDataPath /tmp/calendarr-dd CODE_SIGNING_ALLOWED=NO build -plutil -p "/tmp/calendarr-dd/Build/Products/Debug-iphonesimulator/Calendarr iOS.app/Info.plist" | grep -A3 -i "BGTaskScheduler\|UIBackgroundModes" -``` - -Expected: `** BUILD SUCCEEDED **`, and the output shows both `UIBackgroundModes` containing `fetch` and `BGTaskSchedulerPermittedIdentifiers` containing the identifier. - -If `INFOPLIST_KEY_BGTaskSchedulerPermittedIdentifiers` does not appear, the build setting is not supported by this Xcode. In that case create `Calendarr iOS/Info.plist` containing only the two keys, set `INFOPLIST_FILE = Calendarr iOS/Info.plist` and `GENERATE_INFOPLIST_FILE = NO` on the target, and re-run this step: +Create `Calendarr-Info.plist` at the **project root**: ```xml - UIBackgroundModes - fetch BGTaskSchedulerPermittedIdentifiers - com.scarriffleservices.calendarr.ios.refresh + + com.scarriffleservices.calendarr.ios.refresh + + UIBackgroundModes + + fetch + ``` +Then set `INFOPLIST_FILE = "Calendarr-Info.plist"` on the **Calendarr iOS** +target in both configurations, leaving `GENERATE_INFOPLIST_FILE = YES`. + +The root location is load-bearing: `Calendarr iOS/` is a +`PBXFileSystemSynchronizedRootGroup`, so a plist inside it is also copied as a +resource and the build fails with "Multiple commands produce .../Info.plist". + +- [x] **Step 4: Build and verify the merge lost nothing** + +```bash +xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -derivedDataPath /tmp/calendarr-dd CODE_SIGNING_ALLOWED=NO build +plutil -p "/tmp/calendarr-dd/Build/Products/Release-iphonesimulator/Calendarr iOS.app/Info.plist" | grep -A3 -i "BGTaskScheduler\|UIBackgroundModes" +``` + +Note the scheme builds **Release**, not Debug. Expected: `** BUILD SUCCEEDED **` +and both keys present. Diff the whole plist against a build without the change +to confirm no generated key was displaced. + - [ ] **Step 5: Commit** ```bash From 9394a80ea259bfd7844db120857d63edcb16d1f9 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 11 Sep 2026 16:56:26 +0200 Subject: [PATCH 77/90] Add the watchOS app and complication targets The companion dropdown in Xcode's target template came up empty, which left both bundle identifiers starting with a bare dot and WKCompanionAppBundleIdentifier as an empty string. Both are set explicitly here, along with the App Group entitlements and watchOS 26 as the floor. The iOS app had no Embed Watch Content phase, so the watch app would have built and never shipped. Added, with the matching target dependency: the product now lands at Calendarr iOS.app/Watch/CalendarrWatch.app with the complications inside it. Folders follow what they contain: CalendarrWatch is the app, CalendarrWatchComplications the extension. Xcode had them the other way round. Package.resolved is now tracked. It pins AppAuth, whose version drift is what produced the two compile errors fixed in 75a6a5b. Co-Authored-By: Claude Opus 5 --- Calendarr iOS.xcodeproj/project.pbxproj | 640 +++++++++++++++++- .../xcshareddata/swiftpm/Package.resolved | 15 + .../xcschemes/xcschememanagement.plist | 22 +- .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 13 + CalendarrWatch/Assets.xcassets/Contents.json | 6 + CalendarrWatch/CalendarrWatch.entitlements | 10 + CalendarrWatch/CalendarrWatchApp.swift | 17 + CalendarrWatch/ContentView.swift | 24 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 13 + .../Assets.xcassets/Contents.json | 6 + .../WidgetBackground.colorset/Contents.json | 11 + .../CalendarrWatch.swift | 86 +++ .../CalendarrWatchBundle.swift | 17 + .../CalendarrWatchComplications.entitlements | 10 + .../CalendarrWatchControl.swift | 54 ++ CalendarrWatchComplications/Info.plist | 11 + .../CalendarrWatch_Watch_AppTests.swift | 19 + .../CalendarrWatch_Watch_AppUITests.swift | 43 ++ 20 files changed, 1019 insertions(+), 20 deletions(-) create mode 100644 Calendarr iOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 CalendarrWatch/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 CalendarrWatch/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 CalendarrWatch/Assets.xcassets/Contents.json create mode 100644 CalendarrWatch/CalendarrWatch.entitlements create mode 100644 CalendarrWatch/CalendarrWatchApp.swift create mode 100644 CalendarrWatch/ContentView.swift create mode 100644 CalendarrWatchComplications/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 CalendarrWatchComplications/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 CalendarrWatchComplications/Assets.xcassets/Contents.json create mode 100644 CalendarrWatchComplications/Assets.xcassets/WidgetBackground.colorset/Contents.json create mode 100644 CalendarrWatchComplications/CalendarrWatch.swift create mode 100644 CalendarrWatchComplications/CalendarrWatchBundle.swift create mode 100644 CalendarrWatchComplications/CalendarrWatchComplications.entitlements create mode 100644 CalendarrWatchComplications/CalendarrWatchControl.swift create mode 100644 CalendarrWatchComplications/Info.plist create mode 100644 CalendarrWatchTests/CalendarrWatch_Watch_AppTests.swift create mode 100644 CalendarrWatchUITests/CalendarrWatch_Watch_AppUITests.swift diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index 1152630..bebfaa5 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -7,10 +7,16 @@ objects = { /* Begin PBXBuildFile section */ - D0001A0CABCDEF0100AB5001 /* CalendarrWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 3934CF4630544C2E003958B8 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3934CF4530544C2E003958B8 /* WidgetKit.framework */; }; + 3934CF4830544C2E003958B8 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3934CF4730544C2E003958B8 /* SwiftUI.framework */; }; + 3934CF5530544C2E003958B8 /* CalendarrWatchComplications.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3934CF4330544C2E003958B8 /* CalendarrWatchComplications.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + AA9701000001000000000001 /* AppAuth in Frameworks */ = {isa = PBXBuildFile; productRef = AA9701000002000000000001 /* AppAuth */; }; CA1E4D0A0002000000000001 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000001 /* CalendarrCore */; }; CA1E4D0A0002000000000002 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000002 /* CalendarrCore */; }; - AA9701000001000000000001 /* AppAuth in Frameworks */ = {isa = PBXBuildFile; productRef = AA9701000002000000000001 /* AppAuth */; }; + CA1E4D0A0002000000000003 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000003 /* CalendarrCore */; }; + CA1E4D0A0002000000000004 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000004 /* CalendarrCore */; }; + D0001A0CABCDEF0100AB5001 /* CalendarrWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 39E1B0010001000000000001 /* CalendarrWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 3934CF1A30544C01003958B8 /* CalendarrWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -21,6 +27,27 @@ remoteGlobalIDString = C0000A01FB4E10100AB5001; remoteInfo = "Calendarr iOS"; }; + 3934CF2730544C02003958B8 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = C0000301FB4E10100AB5001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 3934CF1930544C01003958B8; + remoteInfo = CalendarrWatch; + }; + 3934CF3130544C02003958B8 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = C0000301FB4E10100AB5001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 3934CF1930544C01003958B8; + remoteInfo = CalendarrWatch; + }; + 3934CF5330544C2E003958B8 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = C0000301FB4E10100AB5001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 3934CF4230544C2E003958B8; + remoteInfo = CalendarrWatchComplications; + }; D0001A0DABCDEF0100AB5001 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = C0000301FB4E10100AB5001 /* Project object */; @@ -28,9 +55,27 @@ remoteGlobalIDString = D0001A01ABCDEF0100AB5001; remoteInfo = CalendarrWidgets; }; + 39E1B0010003000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = C0000301FB4E10100AB5001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 3934CF1930544C01003958B8; + remoteInfo = CalendarrWatch; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + 3934CF5A30544C2E003958B8 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 3934CF5530544C2E003958B8 /* CalendarrWatchComplications.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; D0001A0BABCDEF0100AB5001 /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -42,15 +87,39 @@ name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; + 39E1B0010002000000000001 /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + 39E1B0010001000000000001 /* CalendarrWatch.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ 3927C7BC2FB99D0E00EAD8ED /* Calendarr iOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Calendarr iOSTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + 3934CF1A30544C01003958B8 /* CalendarrWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CalendarrWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 3934CF2630544C02003958B8 /* CalendarrWatchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CalendarrWatchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3934CF3030544C02003958B8 /* CalendarrWatchUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CalendarrWatchUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3934CF4330544C2E003958B8 /* CalendarrWatchComplications.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CalendarrWatchComplications.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 3934CF4530544C2E003958B8 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 3934CF4730544C2E003958B8 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; C0000B01FC4E10100AB5001 /* Calendarr iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Calendarr iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = CalendarrWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 3934CF5630544C2E003958B8 /* Exceptions for "CalendarrWatchComplications" folder in "CalendarrWatchComplications" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 3934CF4230544C2E003958B8 /* CalendarrWatchComplications */; + }; D0001A0FABCDEF0100AB5001 /* Exceptions for "CalendarrWidgets" folder in "CalendarrWidgets" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -66,6 +135,29 @@ path = "Calendarr iOSTests"; sourceTree = ""; }; + 3934CF1B30544C01003958B8 /* CalendarrWatch */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = CalendarrWatch; + sourceTree = ""; + }; + 3934CF2930544C02003958B8 /* CalendarrWatchTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = CalendarrWatchTests; + sourceTree = ""; + }; + 3934CF3330544C02003958B8 /* CalendarrWatchUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = CalendarrWatchUITests; + sourceTree = ""; + }; + 3934CF4930544C2E003958B8 /* CalendarrWatchComplications */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 3934CF5630544C2E003958B8 /* Exceptions for "CalendarrWatchComplications" folder in "CalendarrWatchComplications" target */, + ); + path = CalendarrWatchComplications; + sourceTree = ""; + }; C0000D01FC4E10100AB5001 /* Calendarr iOS */ = { isa = PBXFileSystemSynchronizedRootGroup; path = "Calendarr iOS"; @@ -94,6 +186,38 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 3934CF1730544C01003958B8 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + CA1E4D0A0002000000000003 /* CalendarrCore in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3934CF2330544C02003958B8 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3934CF2D30544C02003958B8 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3934CF4030544C2E003958B8 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 3934CF4830544C2E003958B8 /* SwiftUI.framework in Frameworks */, + 3934CF4630544C2E003958B8 /* WidgetKit.framework in Frameworks */, + CA1E4D0A0002000000000004 /* CalendarrCore in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; C0000801FB4E10100AB5001 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -114,6 +238,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 3934CF4430544C2E003958B8 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 3934CF4530544C2E003958B8 /* WidgetKit.framework */, + 3934CF4730544C2E003958B8 /* SwiftUI.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; C0000201FB4E10100AB5001 = { isa = PBXGroup; children = ( @@ -121,6 +254,11 @@ D0001A03ABCDEF0100AB5001 /* CalendarrWidgets */, D0001A04ABCDEF0100AB5001 /* Shared */, 3927C7BD2FB99D0F00EAD8ED /* Calendarr iOSTests */, + 3934CF1B30544C01003958B8 /* CalendarrWatch */, + 3934CF2930544C02003958B8 /* CalendarrWatchTests */, + 3934CF3330544C02003958B8 /* CalendarrWatchUITests */, + 3934CF4930544C2E003958B8 /* CalendarrWatchComplications */, + 3934CF4430544C2E003958B8 /* Frameworks */, C0000C01FB4E10100AB5001 /* Products */, ); sourceTree = ""; @@ -131,6 +269,10 @@ C0000B01FC4E10100AB5001 /* Calendarr iOS.app */, 3927C7BC2FB99D0E00EAD8ED /* Calendarr iOSTests.xctest */, D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */, + 3934CF1A30544C01003958B8 /* CalendarrWatch.app */, + 3934CF2630544C02003958B8 /* CalendarrWatchTests.xctest */, + 3934CF3030544C02003958B8 /* CalendarrWatchUITests.xctest */, + 3934CF4330544C2E003958B8 /* CalendarrWatchComplications.appex */, ); name = Products; sourceTree = ""; @@ -161,6 +303,100 @@ productReference = 3927C7BC2FB99D0E00EAD8ED /* Calendarr iOSTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + 3934CF1930544C01003958B8 /* CalendarrWatch */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3934CF3C30544C02003958B8 /* Build configuration list for PBXNativeTarget "CalendarrWatch" */; + buildPhases = ( + 3934CF1630544C01003958B8 /* Sources */, + 3934CF1730544C01003958B8 /* Frameworks */, + 3934CF1830544C01003958B8 /* Resources */, + 3934CF5A30544C2E003958B8 /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + 3934CF5430544C2E003958B8 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 3934CF1B30544C01003958B8 /* CalendarrWatch */, + ); + name = CalendarrWatch; + packageProductDependencies = ( + CA1E4D0A0001000000000003 /* CalendarrCore */, + ); + productName = CalendarrWatch; + productReference = 3934CF1A30544C01003958B8 /* CalendarrWatch.app */; + productType = "com.apple.product-type.application"; + }; + 3934CF2530544C02003958B8 /* CalendarrWatchTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3934CF3D30544C02003958B8 /* Build configuration list for PBXNativeTarget "CalendarrWatchTests" */; + buildPhases = ( + 3934CF2230544C02003958B8 /* Sources */, + 3934CF2330544C02003958B8 /* Frameworks */, + 3934CF2430544C02003958B8 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 3934CF2830544C02003958B8 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 3934CF2930544C02003958B8 /* CalendarrWatchTests */, + ); + name = CalendarrWatchTests; + packageProductDependencies = ( + ); + productName = CalendarrWatchTests; + productReference = 3934CF2630544C02003958B8 /* CalendarrWatchTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 3934CF2F30544C02003958B8 /* CalendarrWatchUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3934CF3E30544C02003958B8 /* Build configuration list for PBXNativeTarget "CalendarrWatchUITests" */; + buildPhases = ( + 3934CF2C30544C02003958B8 /* Sources */, + 3934CF2D30544C02003958B8 /* Frameworks */, + 3934CF2E30544C02003958B8 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 3934CF3230544C02003958B8 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 3934CF3330544C02003958B8 /* CalendarrWatchUITests */, + ); + name = CalendarrWatchUITests; + packageProductDependencies = ( + ); + productName = CalendarrWatchUITests; + productReference = 3934CF3030544C02003958B8 /* CalendarrWatchUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; + 3934CF4230544C2E003958B8 /* CalendarrWatchComplications */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3934CF5730544C2E003958B8 /* Build configuration list for PBXNativeTarget "CalendarrWatchComplications" */; + buildPhases = ( + 3934CF3F30544C2E003958B8 /* Sources */, + 3934CF4030544C2E003958B8 /* Frameworks */, + 3934CF4130544C2E003958B8 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 3934CF4930544C2E003958B8 /* CalendarrWatchComplications */, + ); + name = CalendarrWatchComplications; + packageProductDependencies = ( + CA1E4D0A0001000000000004 /* CalendarrCore */, + ); + productName = CalendarrWatchComplications; + productReference = 3934CF4330544C2E003958B8 /* CalendarrWatchComplications.appex */; + productType = "com.apple.product-type.app-extension"; + }; C0000A01FB4E10100AB5001 /* Calendarr iOS */ = { isa = PBXNativeTarget; buildConfigurationList = C0001601FB4E10100AB5001 /* Build configuration list for PBXNativeTarget "Calendarr iOS" */; @@ -169,11 +405,13 @@ C0000801FB4E10100AB5001 /* Frameworks */, C0000901FB4E10100AB5001 /* Resources */, D0001A0BABCDEF0100AB5001 /* Embed Foundation Extensions */, + 39E1B0010002000000000001 /* Embed Watch Content */, ); buildRules = ( ); dependencies = ( D0001A0EABCDEF0100AB5001 /* PBXTargetDependency */, + 39E1B0010004000000000001 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( C0000D01FC4E10100AB5001 /* Calendarr iOS */, @@ -219,13 +457,27 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 2650; + LastSwiftUpdateCheck = 2700; LastUpgradeCheck = 2640; TargetAttributes = { 3927C7BB2FB99D0E00EAD8ED = { CreatedOnToolsVersion = 26.5; TestTargetID = C0000A01FB4E10100AB5001; }; + 3934CF1930544C01003958B8 = { + CreatedOnToolsVersion = 27.0; + }; + 3934CF2530544C02003958B8 = { + CreatedOnToolsVersion = 27.0; + TestTargetID = 3934CF1930544C01003958B8; + }; + 3934CF2F30544C02003958B8 = { + CreatedOnToolsVersion = 27.0; + TestTargetID = 3934CF1930544C01003958B8; + }; + 3934CF4230544C2E003958B8 = { + CreatedOnToolsVersion = 27.0; + }; C0000A01FB4E10100AB5001 = { CreatedOnToolsVersion = 26.4.1; }; @@ -256,6 +508,10 @@ C0000A01FB4E10100AB5001 /* Calendarr iOS */, D0001A01ABCDEF0100AB5001 /* CalendarrWidgets */, 3927C7BB2FB99D0E00EAD8ED /* Calendarr iOSTests */, + 3934CF1930544C01003958B8 /* CalendarrWatch */, + 3934CF2530544C02003958B8 /* CalendarrWatchTests */, + 3934CF2F30544C02003958B8 /* CalendarrWatchUITests */, + 3934CF4230544C2E003958B8 /* CalendarrWatchComplications */, ); }; /* End PBXProject section */ @@ -268,6 +524,34 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 3934CF1830544C01003958B8 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3934CF2430544C02003958B8 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3934CF2E30544C02003958B8 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3934CF4130544C2E003958B8 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; C0000901FB4E10100AB5001 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -292,6 +576,34 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 3934CF1630544C01003958B8 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3934CF2230544C02003958B8 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3934CF2C30544C02003958B8 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 3934CF3F30544C2E003958B8 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; C0000701FB4E10100AB5001 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -314,11 +626,31 @@ target = C0000A01FB4E10100AB5001 /* Calendarr iOS */; targetProxy = 3927C7C02FB99D0F00EAD8ED /* PBXContainerItemProxy */; }; + 3934CF2830544C02003958B8 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 3934CF1930544C01003958B8 /* CalendarrWatch */; + targetProxy = 3934CF2730544C02003958B8 /* PBXContainerItemProxy */; + }; + 3934CF3230544C02003958B8 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 3934CF1930544C01003958B8 /* CalendarrWatch */; + targetProxy = 3934CF3130544C02003958B8 /* PBXContainerItemProxy */; + }; + 3934CF5430544C2E003958B8 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 3934CF4230544C2E003958B8 /* CalendarrWatchComplications */; + targetProxy = 3934CF5330544C2E003958B8 /* PBXContainerItemProxy */; + }; D0001A0EABCDEF0100AB5001 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = D0001A01ABCDEF0100AB5001 /* CalendarrWidgets */; targetProxy = D0001A0DABCDEF0100AB5001 /* PBXContainerItemProxy */; }; + 39E1B0010004000000000001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 3934CF1930544C01003958B8 /* CalendarrWatch */; + targetProxy = 39E1B0010003000000000001 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -372,6 +704,232 @@ }; name = Release; }; + 3934CF3630544C02003958B8 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = PP34X97WS3; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = CalendarrWatch; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.scarriffleservices.calendarr.ios; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + CODE_SIGN_ENTITLEMENTS = CalendarrWatch/CalendarrWatch.entitlements; + PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 26.0; + }; + name = Debug; + }; + 3934CF3730544C02003958B8 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = PP34X97WS3; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = CalendarrWatch; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.scarriffleservices.calendarr.ios; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + CODE_SIGN_ENTITLEMENTS = CalendarrWatch/CalendarrWatch.entitlements; + PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 26.0; + }; + name = Release; + }; + 3934CF3830544C02003958B8 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = PP34X97WS3; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.scarriffleservices.calendarr.ios.watchkitapp.CalendarrWatch-Watch-AppTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CalendarrWatch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/CalendarrWatch"; + WATCHOS_DEPLOYMENT_TARGET = 26.0; + }; + name = Debug; + }; + 3934CF3930544C02003958B8 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = PP34X97WS3; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.scarriffleservices.calendarr.ios.watchkitapp.CalendarrWatch-Watch-AppTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CalendarrWatch.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/CalendarrWatch"; + WATCHOS_DEPLOYMENT_TARGET = 26.0; + }; + name = Release; + }; + 3934CF3A30544C02003958B8 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = PP34X97WS3; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.scarriffleservices.calendarr.ios.watchkitapp.CalendarrWatch-Watch-AppUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_TARGET_NAME = CalendarrWatch; + WATCHOS_DEPLOYMENT_TARGET = 26.0; + }; + name = Debug; + }; + 3934CF3B30544C02003958B8 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = PP34X97WS3; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.scarriffleservices.calendarr.ios.watchkitapp.CalendarrWatch-Watch-AppUITests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_TARGET_NAME = CalendarrWatch; + WATCHOS_DEPLOYMENT_TARGET = 26.0; + }; + name = Release; + }; + 3934CF5830544C2E003958B8 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = PP34X97WS3; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = CalendarrWatchComplications/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = CalendarrWatch; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MARKETING_VERSION = 1.0; + CODE_SIGN_ENTITLEMENTS = CalendarrWatchComplications/CalendarrWatchComplications.entitlements; + PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp.complications; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 26.0; + }; + name = Debug; + }; + 3934CF5930544C2E003958B8 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = PP34X97WS3; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = CalendarrWatchComplications/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = CalendarrWatch; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MARKETING_VERSION = 1.0; + CODE_SIGN_ENTITLEMENTS = CalendarrWatchComplications/CalendarrWatchComplications.entitlements; + PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp.complications; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 26.0; + }; + name = Release; + }; C0001401FB4E10100AB5001 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -678,6 +1236,42 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 3934CF3C30544C02003958B8 /* Build configuration list for PBXNativeTarget "CalendarrWatch" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3934CF3630544C02003958B8 /* Debug */, + 3934CF3730544C02003958B8 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 3934CF3D30544C02003958B8 /* Build configuration list for PBXNativeTarget "CalendarrWatchTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3934CF3830544C02003958B8 /* Debug */, + 3934CF3930544C02003958B8 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 3934CF3E30544C02003958B8 /* Build configuration list for PBXNativeTarget "CalendarrWatchUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3934CF3A30544C02003958B8 /* Debug */, + 3934CF3B30544C02003958B8 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 3934CF5730544C2E003958B8 /* Build configuration list for PBXNativeTarget "CalendarrWatchComplications" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3934CF5830544C2E003958B8 /* Debug */, + 3934CF5930544C2E003958B8 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; C0000601FB4E10100AB5001 /* Build configuration list for PBXProject "Calendarr iOS" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -707,22 +1301,6 @@ }; /* End XCConfigurationList section */ -/* Begin XCSwiftPackageProductDependency section */ - CA1E4D0A0001000000000001 /* CalendarrCore */ = { - isa = XCSwiftPackageProductDependency; - productName = CalendarrCore; - }; - CA1E4D0A0001000000000002 /* CalendarrCore */ = { - isa = XCSwiftPackageProductDependency; - productName = CalendarrCore; - }; - AA9701000002000000000001 /* AppAuth */ = { - isa = XCSwiftPackageProductDependency; - package = AA9701000003000000000001 /* XCRemoteSwiftPackageReference "AppAuth-iOS" */; - productName = AppAuth; - }; -/* End XCSwiftPackageProductDependency section */ - /* Begin XCLocalSwiftPackageReference section */ 39450EFC302A266A0090DDD7 /* XCLocalSwiftPackageReference "../CalendarrKit" */ = { isa = XCLocalSwiftPackageReference; @@ -740,6 +1318,30 @@ }; }; /* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + AA9701000002000000000001 /* AppAuth */ = { + isa = XCSwiftPackageProductDependency; + package = AA9701000003000000000001 /* XCRemoteSwiftPackageReference "AppAuth-iOS" */; + productName = AppAuth; + }; + CA1E4D0A0001000000000001 /* CalendarrCore */ = { + isa = XCSwiftPackageProductDependency; + productName = CalendarrCore; + }; + CA1E4D0A0001000000000003 /* CalendarrCore */ = { + isa = XCSwiftPackageProductDependency; + productName = CalendarrCore; + }; + CA1E4D0A0001000000000004 /* CalendarrCore */ = { + isa = XCSwiftPackageProductDependency; + productName = CalendarrCore; + }; + CA1E4D0A0001000000000002 /* CalendarrCore */ = { + isa = XCSwiftPackageProductDependency; + productName = CalendarrCore; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = C0000301FB4E10100AB5001 /* Project object */; } diff --git a/Calendarr iOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Calendarr iOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..895da1c --- /dev/null +++ b/Calendarr iOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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 +} diff --git a/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist b/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist index af12f91..d6bd252 100644 --- a/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist @@ -9,11 +9,31 @@ orderHint 0 - CalendarrWidgets.xcscheme_^#shared#^_ + CalendarrWatch Watch App.xcscheme_^#shared#^_ + + orderHint + 3 + + CalendarrWatch.xcscheme_^#shared#^_ orderHint 2 + CalendarrWatchComplications.xcscheme_^#shared#^_ + + orderHint + 3 + + CalendarrWatchExtension.xcscheme_^#shared#^_ + + orderHint + 4 + + CalendarrWidgets.xcscheme_^#shared#^_ + + orderHint + 4 + SuppressBuildableAutocreation diff --git a/CalendarrWatch/Assets.xcassets/AccentColor.colorset/Contents.json b/CalendarrWatch/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/CalendarrWatch/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CalendarrWatch/Assets.xcassets/AppIcon.appiconset/Contents.json b/CalendarrWatch/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..49c81cd --- /dev/null +++ b/CalendarrWatch/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CalendarrWatch/Assets.xcassets/Contents.json b/CalendarrWatch/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/CalendarrWatch/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CalendarrWatch/CalendarrWatch.entitlements b/CalendarrWatch/CalendarrWatch.entitlements new file mode 100644 index 0000000..bb88d59 --- /dev/null +++ b/CalendarrWatch/CalendarrWatch.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.scarriffleservices.calendarr + + + diff --git a/CalendarrWatch/CalendarrWatchApp.swift b/CalendarrWatch/CalendarrWatchApp.swift new file mode 100644 index 0000000..d059cc7 --- /dev/null +++ b/CalendarrWatch/CalendarrWatchApp.swift @@ -0,0 +1,17 @@ +// +// CalendarrWatchApp.swift +// CalendarrWatch Watch App +// +// Created by Guido Schmit on 11.09.2026. +// + +import SwiftUI + +@main +struct CalendarrWatch_Watch_AppApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/CalendarrWatch/ContentView.swift b/CalendarrWatch/ContentView.swift new file mode 100644 index 0000000..01101ce --- /dev/null +++ b/CalendarrWatch/ContentView.swift @@ -0,0 +1,24 @@ +// +// ContentView.swift +// CalendarrWatch Watch App +// +// Created by Guido Schmit on 11.09.2026. +// + +import SwiftUI + +struct ContentView: View { + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundStyle(.tint) + Text("Hello, world!") + } + .padding() + } +} + +#Preview { + ContentView() +} diff --git a/CalendarrWatchComplications/Assets.xcassets/AccentColor.colorset/Contents.json b/CalendarrWatchComplications/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/CalendarrWatchComplications/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CalendarrWatchComplications/Assets.xcassets/AppIcon.appiconset/Contents.json b/CalendarrWatchComplications/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..49c81cd --- /dev/null +++ b/CalendarrWatchComplications/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CalendarrWatchComplications/Assets.xcassets/Contents.json b/CalendarrWatchComplications/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/CalendarrWatchComplications/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CalendarrWatchComplications/Assets.xcassets/WidgetBackground.colorset/Contents.json b/CalendarrWatchComplications/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/CalendarrWatchComplications/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CalendarrWatchComplications/CalendarrWatch.swift b/CalendarrWatchComplications/CalendarrWatch.swift new file mode 100644 index 0000000..ac6c300 --- /dev/null +++ b/CalendarrWatchComplications/CalendarrWatch.swift @@ -0,0 +1,86 @@ +// +// CalendarrWatch.swift +// CalendarrWatch +// +// Created by Guido Schmit on 11.09.2026. +// + +import WidgetKit +import SwiftUI + +struct Provider: TimelineProvider { + func placeholder(in context: Context) -> SimpleEntry { + SimpleEntry(date: Date(), emoji: "😀") + } + + func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) { + let entry = SimpleEntry(date: Date(), emoji: "😀") + completion(entry) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { + var entries: [SimpleEntry] = [] + + // Generate a timeline consisting of five entries an hour apart, starting from the current date. + let currentDate = Date() + for hourOffset in 0 ..< 5 { + let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! + let entry = SimpleEntry(date: entryDate, emoji: "😀") + entries.append(entry) + } + + let timeline = Timeline(entries: entries, policy: .atEnd) + completion(timeline) + } + +// func relevances() async -> WidgetRelevances { +// // Generate a list containing the contexts this widget is relevant in. +// } +} + +struct SimpleEntry: TimelineEntry { + let date: Date + let emoji: String +} + +struct CalendarrWatchEntryView : View { + var entry: Provider.Entry + + var body: some View { + VStack { + HStack { + Text("Time:") + Text(entry.date, style: .time) + } + + Text("Emoji:") + Text(entry.emoji) + } + } +} + +struct CalendarrWatch: Widget { + let kind: String = "CalendarrWatch" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: Provider()) { entry in + if #available(watchOS 10.0, *) { + CalendarrWatchEntryView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } else { + CalendarrWatchEntryView(entry: entry) + .padding() + .background() + } + } + .configurationDisplayName("My Widget") + .description("This is an example widget.") + } +} + +#Preview(as: .accessoryRectangular) { + CalendarrWatch() +} timeline: { + SimpleEntry(date: .now, emoji: "😀") + SimpleEntry(date: .now, emoji: "🤩") +} diff --git a/CalendarrWatchComplications/CalendarrWatchBundle.swift b/CalendarrWatchComplications/CalendarrWatchBundle.swift new file mode 100644 index 0000000..e9dbbf2 --- /dev/null +++ b/CalendarrWatchComplications/CalendarrWatchBundle.swift @@ -0,0 +1,17 @@ +// +// CalendarrWatchBundle.swift +// CalendarrWatch +// +// Created by Guido Schmit on 11.09.2026. +// + +import WidgetKit +import SwiftUI + +@main +struct CalendarrWatchBundle: WidgetBundle { + var body: some Widget { + CalendarrWatch() + CalendarrWatchControl() + } +} diff --git a/CalendarrWatchComplications/CalendarrWatchComplications.entitlements b/CalendarrWatchComplications/CalendarrWatchComplications.entitlements new file mode 100644 index 0000000..bb88d59 --- /dev/null +++ b/CalendarrWatchComplications/CalendarrWatchComplications.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.scarriffleservices.calendarr + + + diff --git a/CalendarrWatchComplications/CalendarrWatchControl.swift b/CalendarrWatchComplications/CalendarrWatchControl.swift new file mode 100644 index 0000000..64e323e --- /dev/null +++ b/CalendarrWatchComplications/CalendarrWatchControl.swift @@ -0,0 +1,54 @@ +// +// CalendarrWatchControl.swift +// CalendarrWatch +// +// Created by Guido Schmit on 11.09.2026. +// + +import AppIntents +import SwiftUI +import WidgetKit + +struct CalendarrWatchControl: ControlWidget { + var body: some ControlWidgetConfiguration { + StaticControlConfiguration( + kind: ".watchkitapp.CalendarrWatch", + provider: Provider() + ) { value in + ControlWidgetToggle( + "Start Timer", + isOn: value, + action: StartTimerIntent() + ) { isRunning in + Label(isRunning ? "On" : "Off", systemImage: "timer") + } + } + .displayName("Timer") + .description("A an example control that runs a timer.") + } +} + +extension CalendarrWatchControl { + struct Provider: ControlValueProvider { + var previewValue: Bool { + false + } + + func currentValue() async throws -> Bool { + let isRunning = true // Check if the timer is running + return isRunning + } + } +} + +struct StartTimerIntent: SetValueIntent { + static let title: LocalizedStringResource = "Start a timer" + + @Parameter(title: "Timer is running") + var value: Bool + + func perform() async throws -> some IntentResult { + // Start / stop the timer based on `value`. + return .result() + } +} diff --git a/CalendarrWatchComplications/Info.plist b/CalendarrWatchComplications/Info.plist new file mode 100644 index 0000000..0f118fb --- /dev/null +++ b/CalendarrWatchComplications/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/CalendarrWatchTests/CalendarrWatch_Watch_AppTests.swift b/CalendarrWatchTests/CalendarrWatch_Watch_AppTests.swift new file mode 100644 index 0000000..0f40448 --- /dev/null +++ b/CalendarrWatchTests/CalendarrWatch_Watch_AppTests.swift @@ -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 + } + +} diff --git a/CalendarrWatchUITests/CalendarrWatch_Watch_AppUITests.swift b/CalendarrWatchUITests/CalendarrWatch_Watch_AppUITests.swift new file mode 100644 index 0000000..5c27a48 --- /dev/null +++ b/CalendarrWatchUITests/CalendarrWatch_Watch_AppUITests.swift @@ -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() + } + } +} From 6a7afe0647eab417586afdaa526fe09a4121ffd5 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 11 Sep 2026 16:56:37 +0200 Subject: [PATCH 78/90] Drop -sdk from the plan's build commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -sdk overrides SDKROOT for the whole dependency graph, so the watch app was built against the iOS SDK. It still succeeded, and produced a watch app with no WKApplication key — the kind of failure that only shows up on the device. Co-Authored-By: Claude Opus 5 --- .../plans/2026-09-11-apple-watch-app.md | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/plans/2026-09-11-apple-watch-app.md b/docs/superpowers/plans/2026-09-11-apple-watch-app.md index 9b0135e..d5f7bc0 100644 --- a/docs/superpowers/plans/2026-09-11-apple-watch-app.md +++ b/docs/superpowers/plans/2026-09-11-apple-watch-app.md @@ -26,6 +26,12 @@ Xcode 26.6 ships the watchOS 26.5 SDK. The target device runs watchOS 27 beta, s ## Working rules for this repo +- **Never pass `-sdk` to xcodebuild.** It overrides SDKROOT for every target in + the dependency graph, so the watch app gets built against the iOS SDK: the + build still succeeds, but `WKApplication` is missing from the result and + `DTSDKName` reads `iphonesimulator`. Select the platform with `-destination` + alone and let each target keep its own SDK. + - Never run or start the server, and never run the apps. Apps are **build-verified only**; behaviour, layout and complication refresh are verified by the user on the device. - Commit messages in English. Commit after every task. - `CalendarrKit` is a **separate git repo** at `../CalendarrKit` with **no remote** — commit there, do not push. The iOS repo (`.`) pushes to `origin main`. @@ -509,7 +515,7 @@ import CalendarrCore - [ ] **Step 6: Build the iOS app to verify the adoption compiles** ```bash -xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build +xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build ``` Expected: `** BUILD SUCCEEDED **`. @@ -1529,7 +1535,7 @@ In `Calendarr iOS/CalendarrApp.swift`, in `AppState.logout()`, after the existin - [ ] **Step 5: Build the iOS app** ```bash -xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build +xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build ``` Expected: `** BUILD SUCCEEDED **`. @@ -1615,7 +1621,7 @@ For both new bundle IDs, enable the **App Groups** capability and select `group. - [ ] **Step 6: Verify both targets build** ```bash -xcodebuild -project "Calendarr iOS.xcodeproj" -scheme CalendarrWatch -sdk watchsimulator -destination 'generic/platform=watchOS Simulator' CODE_SIGNING_ALLOWED=NO build +xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build ``` Expected: `** BUILD SUCCEEDED **` (an empty app at this point). @@ -2349,7 +2355,7 @@ struct WatchCalendarFilterView: View { - [ ] **Step 2: Build the watch app** ```bash -xcodebuild -project "Calendarr iOS.xcodeproj" -scheme CalendarrWatch -sdk watchsimulator -destination 'generic/platform=watchOS Simulator' CODE_SIGNING_ALLOWED=NO build +xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build ``` Expected: `** BUILD SUCCEEDED **`. @@ -3012,7 +3018,7 @@ struct WatchDatePlusEventWidget: Widget { - [ ] **Step 5: Build the complications target** ```bash -xcodebuild -project "Calendarr iOS.xcodeproj" -scheme CalendarrWatch -sdk watchsimulator -destination 'generic/platform=watchOS Simulator' CODE_SIGNING_ALLOWED=NO build +xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build ``` Expected: `** BUILD SUCCEEDED **` — the scheme builds the embedded extension too. @@ -3046,8 +3052,8 @@ Expected: build succeeds against the native macOS SDK, every test passes. ```bash cd "../Calendarr iOS" -xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build -xcodebuild -project "Calendarr iOS.xcodeproj" -scheme CalendarrWatch -sdk watchsimulator -destination 'generic/platform=watchOS Simulator' CODE_SIGNING_ALLOWED=NO build +xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build +xcodebuild -project "Calendarr iOS.xcodeproj" -scheme "Calendarr iOS" -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build ``` Expected: `** BUILD SUCCEEDED **` twice. From f9bc1965a5590d224bbcb3797382ae496908886b Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 11 Sep 2026 17:08:27 +0200 Subject: [PATCH 79/90] Add the watch app and its eight complications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The watch receives the phone's payload over WatchConnectivity and writes it into its own App Group container through the same SnapshotStore, so there is one wire format and one reader. The agenda, the detail screen and the filter all read that; the complications read what the app last wrote, since an extension cannot run a WCSession of its own. Complication timelines land on event boundaries rather than a schedule, so the next appointment flips in the exact minute it changes without spending refresh budget. CalendarrWatchShared holds what both watch targets need, attached to each the way Shared/ is attached to the app and the widgets. recommendations() is implemented because watchOS requires it — iOS has a default, the watch does not, since the complication gallery has to be populated. Found by building, not by reading. Co-Authored-By: Claude Opus 5 --- Calendarr iOS.xcodeproj/project.pbxproj | 72 +++++---- CalendarrWatch/CalendarrWatchApp.swift | 20 +-- CalendarrWatch/ContentView.swift | 24 --- CalendarrWatch/Views/WatchAgendaView.swift | 114 ++++++++++++++ .../Views/WatchCalendarFilterView.swift | 49 ++++++ .../Views/WatchEventDetailView.swift | 60 ++++++++ CalendarrWatch/Views/WatchEventRow.swift | 76 +++++++++ CalendarrWatch/Views/WatchStatusView.swift | 44 ++++++ CalendarrWatch/WatchSnapshotModel.swift | 52 +++++++ CalendarrWatch/WatchSnapshotReceiver.swift | 81 ++++++++++ .../CalendarrWatch.swift | 86 ----------- .../CalendarrWatchBundle.swift | 17 -- .../CalendarrWatchComplications.swift | 145 ++++++++++++++++++ .../CalendarrWatchControl.swift | 54 ------- .../CornerComplicationViews.swift | 49 ++++++ .../RectangularComplicationViews.swift | 113 ++++++++++++++ .../SmallComplicationViews.swift | 113 ++++++++++++++ .../WatchCalendarSelectionIntent.swift | 44 ++++++ .../WatchComplicationProvider.swift | 62 ++++++++ .../WatchComplicationSupport.swift | 58 +++++++ CalendarrWatchShared/WatchFilterStore.swift | 23 +++ CalendarrWatchShared/WatchL10n.swift | 55 +++++++ .../2026-09-11-apple-watch-app-design.md | 2 +- 23 files changed, 1190 insertions(+), 223 deletions(-) delete mode 100644 CalendarrWatch/ContentView.swift create mode 100644 CalendarrWatch/Views/WatchAgendaView.swift create mode 100644 CalendarrWatch/Views/WatchCalendarFilterView.swift create mode 100644 CalendarrWatch/Views/WatchEventDetailView.swift create mode 100644 CalendarrWatch/Views/WatchEventRow.swift create mode 100644 CalendarrWatch/Views/WatchStatusView.swift create mode 100644 CalendarrWatch/WatchSnapshotModel.swift create mode 100644 CalendarrWatch/WatchSnapshotReceiver.swift delete mode 100644 CalendarrWatchComplications/CalendarrWatch.swift delete mode 100644 CalendarrWatchComplications/CalendarrWatchBundle.swift create mode 100644 CalendarrWatchComplications/CalendarrWatchComplications.swift delete mode 100644 CalendarrWatchComplications/CalendarrWatchControl.swift create mode 100644 CalendarrWatchComplications/CornerComplicationViews.swift create mode 100644 CalendarrWatchComplications/RectangularComplicationViews.swift create mode 100644 CalendarrWatchComplications/SmallComplicationViews.swift create mode 100644 CalendarrWatchComplications/WatchCalendarSelectionIntent.swift create mode 100644 CalendarrWatchComplications/WatchComplicationProvider.swift create mode 100644 CalendarrWatchComplications/WatchComplicationSupport.swift create mode 100644 CalendarrWatchShared/WatchFilterStore.swift create mode 100644 CalendarrWatchShared/WatchL10n.swift diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index bebfaa5..a6d7336 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -10,13 +10,13 @@ 3934CF4630544C2E003958B8 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3934CF4530544C2E003958B8 /* WidgetKit.framework */; }; 3934CF4830544C2E003958B8 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3934CF4730544C2E003958B8 /* SwiftUI.framework */; }; 3934CF5530544C2E003958B8 /* CalendarrWatchComplications.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3934CF4330544C2E003958B8 /* CalendarrWatchComplications.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 39E1B0010001000000000001 /* CalendarrWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 3934CF1A30544C01003958B8 /* CalendarrWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; AA9701000001000000000001 /* AppAuth in Frameworks */ = {isa = PBXBuildFile; productRef = AA9701000002000000000001 /* AppAuth */; }; CA1E4D0A0002000000000001 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000001 /* CalendarrCore */; }; CA1E4D0A0002000000000002 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000002 /* CalendarrCore */; }; CA1E4D0A0002000000000003 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000003 /* CalendarrCore */; }; CA1E4D0A0002000000000004 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000004 /* CalendarrCore */; }; D0001A0CABCDEF0100AB5001 /* CalendarrWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D0001A02ABCDEF0100AB5001 /* CalendarrWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 39E1B0010001000000000001 /* CalendarrWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 3934CF1A30544C01003958B8 /* CalendarrWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -48,13 +48,6 @@ remoteGlobalIDString = 3934CF4230544C2E003958B8; remoteInfo = CalendarrWatchComplications; }; - D0001A0DABCDEF0100AB5001 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = C0000301FB4E10100AB5001 /* Project object */; - proxyType = 1; - remoteGlobalIDString = D0001A01ABCDEF0100AB5001; - remoteInfo = CalendarrWidgets; - }; 39E1B0010003000000000001 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = C0000301FB4E10100AB5001 /* Project object */; @@ -62,6 +55,13 @@ remoteGlobalIDString = 3934CF1930544C01003958B8; remoteInfo = CalendarrWatch; }; + D0001A0DABCDEF0100AB5001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = C0000301FB4E10100AB5001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D0001A01ABCDEF0100AB5001; + remoteInfo = CalendarrWidgets; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -76,17 +76,6 @@ name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; - D0001A0BABCDEF0100AB5001 /* Embed Foundation Extensions */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 13; - files = ( - D0001A0CABCDEF0100AB5001 /* CalendarrWidgets.appex in Embed Foundation Extensions */, - ); - name = "Embed Foundation Extensions"; - runOnlyForDeploymentPostprocessing = 0; - }; 39E1B0010002000000000001 /* Embed Watch Content */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -98,6 +87,17 @@ name = "Embed Watch Content"; runOnlyForDeploymentPostprocessing = 0; }; + D0001A0BABCDEF0100AB5001 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + D0001A0CABCDEF0100AB5001 /* CalendarrWidgets.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -158,6 +158,11 @@ path = CalendarrWatchComplications; sourceTree = ""; }; + 39E1B0010005000000000001 /* CalendarrWatchShared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = CalendarrWatchShared; + sourceTree = ""; + }; C0000D01FC4E10100AB5001 /* Calendarr iOS */ = { isa = PBXFileSystemSynchronizedRootGroup; path = "Calendarr iOS"; @@ -260,6 +265,7 @@ 3934CF4930544C2E003958B8 /* CalendarrWatchComplications */, 3934CF4430544C2E003958B8 /* Frameworks */, C0000C01FB4E10100AB5001 /* Products */, + 39E1B0010005000000000001 /* CalendarrWatchShared */, ); sourceTree = ""; }; @@ -319,6 +325,7 @@ ); fileSystemSynchronizedGroups = ( 3934CF1B30544C01003958B8 /* CalendarrWatch */, + 39E1B0010005000000000001 /* CalendarrWatchShared */, ); name = CalendarrWatch; packageProductDependencies = ( @@ -388,6 +395,7 @@ ); fileSystemSynchronizedGroups = ( 3934CF4930544C2E003958B8 /* CalendarrWatchComplications */, + 39E1B0010005000000000001 /* CalendarrWatchShared */, ); name = CalendarrWatchComplications; packageProductDependencies = ( @@ -641,16 +649,16 @@ target = 3934CF4230544C2E003958B8 /* CalendarrWatchComplications */; targetProxy = 3934CF5330544C2E003958B8 /* PBXContainerItemProxy */; }; - D0001A0EABCDEF0100AB5001 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = D0001A01ABCDEF0100AB5001 /* CalendarrWidgets */; - targetProxy = D0001A0DABCDEF0100AB5001 /* PBXContainerItemProxy */; - }; 39E1B0010004000000000001 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 3934CF1930544C01003958B8 /* CalendarrWatch */; targetProxy = 39E1B0010003000000000001 /* PBXContainerItemProxy */; }; + D0001A0EABCDEF0100AB5001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D0001A01ABCDEF0100AB5001 /* CalendarrWidgets */; + targetProxy = D0001A0DABCDEF0100AB5001 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -709,6 +717,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = CalendarrWatch/CalendarrWatch.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PP34X97WS3; @@ -722,7 +731,6 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - CODE_SIGN_ENTITLEMENTS = CalendarrWatch/CalendarrWatch.entitlements; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -743,6 +751,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = CalendarrWatch/CalendarrWatch.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PP34X97WS3; @@ -756,7 +765,6 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; - CODE_SIGN_ENTITLEMENTS = CalendarrWatch/CalendarrWatch.entitlements; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -867,6 +875,7 @@ buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = CalendarrWatchComplications/CalendarrWatchComplications.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PP34X97WS3; @@ -881,7 +890,6 @@ "@executable_path/../../../../Frameworks", ); MARKETING_VERSION = 1.0; - CODE_SIGN_ENTITLEMENTS = CalendarrWatchComplications/CalendarrWatchComplications.entitlements; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp.complications; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -901,6 +909,7 @@ buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = CalendarrWatchComplications/CalendarrWatchComplications.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = PP34X97WS3; @@ -915,7 +924,6 @@ "@executable_path/../../../../Frameworks", ); MARKETING_VERSION = 1.0; - CODE_SIGN_ENTITLEMENTS = CalendarrWatchComplications/CalendarrWatchComplications.entitlements; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp.complications; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -1329,6 +1337,10 @@ isa = XCSwiftPackageProductDependency; productName = CalendarrCore; }; + CA1E4D0A0001000000000002 /* CalendarrCore */ = { + isa = XCSwiftPackageProductDependency; + productName = CalendarrCore; + }; CA1E4D0A0001000000000003 /* CalendarrCore */ = { isa = XCSwiftPackageProductDependency; productName = CalendarrCore; @@ -1337,10 +1349,6 @@ isa = XCSwiftPackageProductDependency; productName = CalendarrCore; }; - CA1E4D0A0001000000000002 /* CalendarrCore */ = { - isa = XCSwiftPackageProductDependency; - productName = CalendarrCore; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = C0000301FB4E10100AB5001 /* Project object */; diff --git a/CalendarrWatch/CalendarrWatchApp.swift b/CalendarrWatch/CalendarrWatchApp.swift index d059cc7..325d550 100644 --- a/CalendarrWatch/CalendarrWatchApp.swift +++ b/CalendarrWatch/CalendarrWatchApp.swift @@ -1,17 +1,19 @@ -// -// CalendarrWatchApp.swift -// CalendarrWatch Watch App -// -// Created by Guido Schmit on 11.09.2026. -// - import SwiftUI @main -struct CalendarrWatch_Watch_AppApp: App { +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 { - ContentView() + WatchAgendaView() + .environment(model) } } } diff --git a/CalendarrWatch/ContentView.swift b/CalendarrWatch/ContentView.swift deleted file mode 100644 index 01101ce..0000000 --- a/CalendarrWatch/ContentView.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// ContentView.swift -// CalendarrWatch Watch App -// -// Created by Guido Schmit on 11.09.2026. -// - -import SwiftUI - -struct ContentView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") - } - .padding() - } -} - -#Preview { - ContentView() -} diff --git a/CalendarrWatch/Views/WatchAgendaView.swift b/CalendarrWatch/Views/WatchAgendaView.swift new file mode 100644 index 0000000..bb36979 --- /dev/null +++ b/CalendarrWatch/Views/WatchAgendaView.swift @@ -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 } + } +} diff --git a/CalendarrWatch/Views/WatchCalendarFilterView.swift b/CalendarrWatch/Views/WatchCalendarFilterView.swift new file mode 100644 index 0000000..83f267e --- /dev/null +++ b/CalendarrWatch/Views/WatchCalendarFilterView.swift @@ -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] : []) + } +} diff --git a/CalendarrWatch/Views/WatchEventDetailView.swift b/CalendarrWatch/Views/WatchEventDetailView.swift new file mode 100644 index 0000000..c0d5ec1 --- /dev/null +++ b/CalendarrWatch/Views/WatchEventDetailView.swift @@ -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) + } +} diff --git a/CalendarrWatch/Views/WatchEventRow.swift b/CalendarrWatch/Views/WatchEventRow.swift new file mode 100644 index 0000000..aff41da --- /dev/null +++ b/CalendarrWatch/Views/WatchEventRow.swift @@ -0,0 +1,76 @@ +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) -> String { + if event.isAllDay { return WatchL10n.t("watch.allday", language) } + let formatter = DateFormatter() + formatter.locale = WatchL10n.locale(language) + formatter.dateFormat = "HH:mm" + let start = formatter.string(from: event.start) + guard event.end > event.start else { return start } + return "\(start) – \(formatter.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) + } +} diff --git a/CalendarrWatch/Views/WatchStatusView.swift b/CalendarrWatch/Views/WatchStatusView.swift new file mode 100644 index 0000000..574a784 --- /dev/null +++ b/CalendarrWatch/Views/WatchStatusView.swift @@ -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) + } + } +} diff --git a/CalendarrWatch/WatchSnapshotModel.swift b/CalendarrWatch/WatchSnapshotModel.swift new file mode 100644 index 0000000..342d815 --- /dev/null +++ b/CalendarrWatch/WatchSnapshotModel.swift @@ -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 = 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() + } +} diff --git a/CalendarrWatch/WatchSnapshotReceiver.swift b/CalendarrWatch/WatchSnapshotReceiver.swift new file mode 100644 index 0000000..0260fee --- /dev/null +++ b/CalendarrWatch/WatchSnapshotReceiver.swift @@ -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) } + } +} diff --git a/CalendarrWatchComplications/CalendarrWatch.swift b/CalendarrWatchComplications/CalendarrWatch.swift deleted file mode 100644 index ac6c300..0000000 --- a/CalendarrWatchComplications/CalendarrWatch.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// CalendarrWatch.swift -// CalendarrWatch -// -// Created by Guido Schmit on 11.09.2026. -// - -import WidgetKit -import SwiftUI - -struct Provider: TimelineProvider { - func placeholder(in context: Context) -> SimpleEntry { - SimpleEntry(date: Date(), emoji: "😀") - } - - func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) { - let entry = SimpleEntry(date: Date(), emoji: "😀") - completion(entry) - } - - func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { - var entries: [SimpleEntry] = [] - - // Generate a timeline consisting of five entries an hour apart, starting from the current date. - let currentDate = Date() - for hourOffset in 0 ..< 5 { - let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! - let entry = SimpleEntry(date: entryDate, emoji: "😀") - entries.append(entry) - } - - let timeline = Timeline(entries: entries, policy: .atEnd) - completion(timeline) - } - -// func relevances() async -> WidgetRelevances { -// // Generate a list containing the contexts this widget is relevant in. -// } -} - -struct SimpleEntry: TimelineEntry { - let date: Date - let emoji: String -} - -struct CalendarrWatchEntryView : View { - var entry: Provider.Entry - - var body: some View { - VStack { - HStack { - Text("Time:") - Text(entry.date, style: .time) - } - - Text("Emoji:") - Text(entry.emoji) - } - } -} - -struct CalendarrWatch: Widget { - let kind: String = "CalendarrWatch" - - var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: Provider()) { entry in - if #available(watchOS 10.0, *) { - CalendarrWatchEntryView(entry: entry) - .containerBackground(.fill.tertiary, for: .widget) - } else { - CalendarrWatchEntryView(entry: entry) - .padding() - .background() - } - } - .configurationDisplayName("My Widget") - .description("This is an example widget.") - } -} - -#Preview(as: .accessoryRectangular) { - CalendarrWatch() -} timeline: { - SimpleEntry(date: .now, emoji: "😀") - SimpleEntry(date: .now, emoji: "🤩") -} diff --git a/CalendarrWatchComplications/CalendarrWatchBundle.swift b/CalendarrWatchComplications/CalendarrWatchBundle.swift deleted file mode 100644 index e9dbbf2..0000000 --- a/CalendarrWatchComplications/CalendarrWatchBundle.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// CalendarrWatchBundle.swift -// CalendarrWatch -// -// Created by Guido Schmit on 11.09.2026. -// - -import WidgetKit -import SwiftUI - -@main -struct CalendarrWatchBundle: WidgetBundle { - var body: some Widget { - CalendarrWatch() - CalendarrWatchControl() - } -} diff --git a/CalendarrWatchComplications/CalendarrWatchComplications.swift b/CalendarrWatchComplications/CalendarrWatchComplications.swift new file mode 100644 index 0000000..f789503 --- /dev/null +++ b/CalendarrWatchComplications/CalendarrWatchComplications.swift @@ -0,0 +1,145 @@ +import WidgetKit +import SwiftUI + +@main +struct CalendarrWatchComplicationBundle: WidgetBundle { + var body: some Widget { + WatchNextEventWidget() + WatchCornerCountdownWidget() + 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 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]) + } +} diff --git a/CalendarrWatchComplications/CalendarrWatchControl.swift b/CalendarrWatchComplications/CalendarrWatchControl.swift deleted file mode 100644 index 64e323e..0000000 --- a/CalendarrWatchComplications/CalendarrWatchControl.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// CalendarrWatchControl.swift -// CalendarrWatch -// -// Created by Guido Schmit on 11.09.2026. -// - -import AppIntents -import SwiftUI -import WidgetKit - -struct CalendarrWatchControl: ControlWidget { - var body: some ControlWidgetConfiguration { - StaticControlConfiguration( - kind: ".watchkitapp.CalendarrWatch", - provider: Provider() - ) { value in - ControlWidgetToggle( - "Start Timer", - isOn: value, - action: StartTimerIntent() - ) { isRunning in - Label(isRunning ? "On" : "Off", systemImage: "timer") - } - } - .displayName("Timer") - .description("A an example control that runs a timer.") - } -} - -extension CalendarrWatchControl { - struct Provider: ControlValueProvider { - var previewValue: Bool { - false - } - - func currentValue() async throws -> Bool { - let isRunning = true // Check if the timer is running - return isRunning - } - } -} - -struct StartTimerIntent: SetValueIntent { - static let title: LocalizedStringResource = "Start a timer" - - @Parameter(title: "Timer is running") - var value: Bool - - func perform() async throws -> some IntentResult { - // Start / stop the timer based on `value`. - return .result() - } -} diff --git a/CalendarrWatchComplications/CornerComplicationViews.swift b/CalendarrWatchComplications/CornerComplicationViews.swift new file mode 100644 index 0000000..b96bb86 --- /dev/null +++ b/CalendarrWatchComplications/CornerComplicationViews.swift @@ -0,0 +1,49 @@ +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.countdownTarget(next, at: entry.date), style: .timer) + .widgetAccentable() + } else { + Image(systemName: "calendar") + } + } + .widgetLabel { + Text(WatchComplicationSupport.cornerLabel(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)) + .widgetAccentable() + } else { + Image(systemName: "calendar") + } + } + .widgetLabel { + Text(next?.title ?? WatchL10n.t("watch.no_events", entry.language)) + } + } +} diff --git a/CalendarrWatchComplications/RectangularComplicationViews.swift b/CalendarrWatchComplications/RectangularComplicationViews.swift new file mode 100644 index 0000000..4c4b13e --- /dev/null +++ b/CalendarrWatchComplications/RectangularComplicationViews.swift @@ -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() + } +} diff --git a/CalendarrWatchComplications/SmallComplicationViews.swift b/CalendarrWatchComplications/SmallComplicationViews.swift new file mode 100644 index 0000000..b06eb57 --- /dev/null +++ b/CalendarrWatchComplications/SmallComplicationViews.swift @@ -0,0 +1,113 @@ +import SwiftUI +import WidgetKit +import CalendarrCore + +/// 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(target, style: .timer) + .font(.system(size: 12)) + } + .gaugeStyle(.accessoryCircularCapacity) + .widgetAccentable() + } 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") + } + } +} + +/// Day number and month. No appointment information — a date replacement. +struct DateComplicationView: View { + let entry: WatchComplicationEntry + @Environment(\.widgetFamily) private var family + + private var day: Int { + Calendar(identifier: .gregorian).component(.day, from: entry.date) + } + + var body: some View { + switch family { + case .accessoryInline: + Label("\(WatchComplicationSupport.monthAbbreviation(entry.date, language: entry.language)) \(day)", + systemImage: "calendar") + default: + ZStack { + AccessoryWidgetBackground() + VStack(spacing: 0) { + Text("\(day)") + .font(.system(size: 22, weight: .bold)) + .minimumScaleFactor(0.7) + .widgetAccentable() + Text(WatchComplicationSupport.monthAbbreviation(entry.date, language: entry.language)) + .font(.system(size: 8, weight: .semibold)) + } + } + } + } +} + +/// 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: + ZStack { + AccessoryWidgetBackground() + VStack(spacing: 0) { + Text("\(count)") + .font(.system(size: 22, weight: .bold)) + .widgetAccentable() + Text(WatchL10n.t("watch.events_today", entry.language).uppercased()) + .font(.system(size: 7, weight: .semibold)) + .lineLimit(1) + .minimumScaleFactor(0.6) + } + } + } + } +} diff --git a/CalendarrWatchComplications/WatchCalendarSelectionIntent.swift b/CalendarrWatchComplications/WatchCalendarSelectionIntent.swift new file mode 100644 index 0000000..35c4116 --- /dev/null +++ b/CalendarrWatchComplications/WatchCalendarSelectionIntent.swift @@ -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]? +} diff --git a/CalendarrWatchComplications/WatchComplicationProvider.swift b/CalendarrWatchComplications/WatchComplicationProvider.swift new file mode 100644 index 0000000..cd5abf9 --- /dev/null +++ b/CalendarrWatchComplications/WatchComplicationProvider.swift @@ -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 { + 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] { + [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))) + } +} diff --git a/CalendarrWatchComplications/WatchComplicationSupport.swift b/CalendarrWatchComplications/WatchComplicationSupport.swift new file mode 100644 index 0000000..db566b6 --- /dev/null +++ b/CalendarrWatchComplications/WatchComplicationSupport.swift @@ -0,0 +1,58 @@ +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 + } + + /// 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 + } + + /// 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)) + } + + 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 bezel label for a corner complication: time plus title, or a + /// plain "no events". + static func cornerLabel(_ 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)" + } +} diff --git a/CalendarrWatchShared/WatchFilterStore.swift b/CalendarrWatchShared/WatchFilterStore.swift new file mode 100644 index 0000000..f1ecb58 --- /dev/null +++ b/CalendarrWatchShared/WatchFilterStore.swift @@ -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 { + Set(defaults?.stringArray(forKey: key) ?? []) + } + + static func save(_ keys: Set) { + defaults?.set(keys.sorted(), forKey: key) + } +} diff --git a/CalendarrWatchShared/WatchL10n.swift b/CalendarrWatchShared/WatchL10n.swift new file mode 100644 index 0000000..5e53409 --- /dev/null +++ b/CalendarrWatchShared/WatchL10n.swift @@ -0,0 +1,55 @@ +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.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.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", + ], + ] +} diff --git a/docs/superpowers/specs/2026-09-11-apple-watch-app-design.md b/docs/superpowers/specs/2026-09-11-apple-watch-app-design.md index e553ab4..15b0b27 100644 --- a/docs/superpowers/specs/2026-09-11-apple-watch-app-design.md +++ b/docs/superpowers/specs/2026-09-11-apple-watch-app-design.md @@ -1,7 +1,7 @@ # Apple Watch app and watch-face complications Date: 2026-09-11 -Status: approved — ready for implementation planning +Status: implemented — pending on-device verification ## Goal From d0cff20be89e1093ded799a182046e61f6124262 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 11 Sep 2026 17:08:44 +0200 Subject: [PATCH 80/90] Note the watch plan as implemented Co-Authored-By: Claude Opus 5 --- docs/superpowers/plans/2026-09-11-apple-watch-app.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/superpowers/plans/2026-09-11-apple-watch-app.md b/docs/superpowers/plans/2026-09-11-apple-watch-app.md index d5f7bc0..5783883 100644 --- a/docs/superpowers/plans/2026-09-11-apple-watch-app.md +++ b/docs/superpowers/plans/2026-09-11-apple-watch-app.md @@ -2,6 +2,8 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +**Status:** All 17 tasks implemented and build-verified as of 2026-09-11. On-device behaviour is still unverified — see the handoff list in Task 17. + **Goal:** Ship a read-only Apple Watch app plus eight watch-face complications that show the next Calendarr appointment, fed from the iPhone over WatchConnectivity. **Architecture:** The watch is a read-only consumer of the phone's snapshot. Every piece of logic that can be tested lives in `CalendarrCore` (testable with `swift test` on the dev machine); the watch targets hold only thin SwiftUI and WCSession glue, which can only be build-verified here. The phone trims its 42-day snapshot to a 14-day watch payload and sends it; the watch writes it into its *own* App Group container through the same `SnapshotStore`, so there is one wire format and one reader. From e7fbe5c0926123fb035d0323bb4a36d4ef5a8cdf Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 11 Sep 2026 17:16:08 +0200 Subject: [PATCH 81/90] Give the watch app an icon and the right name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The target template left AppIcon.appiconset with an entry carrying no filename — an empty slot. watchOS refuses to install an app without a valid icon, which is why the Watch app's Install button kept coming back after ten seconds with no error anywhere. Reuses the iOS 1024 icon, which is already alpha-free. Both watch targets also called themselves "CalendarrWatch", which is what would have appeared under the icon and in the complication gallery. Co-Authored-By: Claude Opus 5 --- Calendarr iOS.xcodeproj/project.pbxproj | 8 ++++---- .../AppIcon.appiconset/AppIcon-1024.png | Bin 0 -> 22168 bytes .../AppIcon.appiconset/Contents.json | 1 + 3 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 CalendarrWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index a6d7336..cd21abc 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -723,7 +723,7 @@ DEVELOPMENT_TEAM = PP34X97WS3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = CalendarrWatch; + INFOPLIST_KEY_CFBundleDisplayName = Calendarr; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.scarriffleservices.calendarr.ios; LD_RUNPATH_SEARCH_PATHS = ( @@ -757,7 +757,7 @@ DEVELOPMENT_TEAM = PP34X97WS3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = CalendarrWatch; + INFOPLIST_KEY_CFBundleDisplayName = Calendarr; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.scarriffleservices.calendarr.ios; LD_RUNPATH_SEARCH_PATHS = ( @@ -881,7 +881,7 @@ DEVELOPMENT_TEAM = PP34X97WS3; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = CalendarrWatchComplications/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = CalendarrWatch; + INFOPLIST_KEY_CFBundleDisplayName = Calendarr; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -915,7 +915,7 @@ DEVELOPMENT_TEAM = PP34X97WS3; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = CalendarrWatchComplications/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = CalendarrWatch; + INFOPLIST_KEY_CFBundleDisplayName = Calendarr; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/CalendarrWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/CalendarrWatch/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..a88fa5b32c2e7115677de60876983f7c8fb4d3cc GIT binary patch literal 22168 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 Date: Fri, 11 Sep 2026 17:21:56 +0200 Subject: [PATCH 82/90] Match the watch targets' version to the iOS app A companion watch app has to carry the same CFBundleShortVersionString and CFBundleVersion as the app it ships inside. The template left the watch at 1.0 (1) against the app's 3.5 (7), and watchOS answers that by refusing the install without reporting anything: the Install button simply comes back. Same class of drift as c9bd42a, which unified the app and widget versions. Co-Authored-By: Claude Opus 5 --- Calendarr iOS.xcodeproj/project.pbxproj | 32 ++++++++++++------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index cd21abc..ee60bd5 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -719,7 +719,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = CalendarrWatch/CalendarrWatch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = PP34X97WS3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -730,7 +730,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 3.5; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -753,7 +753,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = CalendarrWatch/CalendarrWatch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = PP34X97WS3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -764,7 +764,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 3.5; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -785,10 +785,10 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = PP34X97WS3; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 3.5; PRODUCT_BUNDLE_IDENTIFIER = "com.scarriffleservices.calendarr.ios.watchkitapp.CalendarrWatch-Watch-AppTests"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -808,10 +808,10 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = PP34X97WS3; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 3.5; PRODUCT_BUNDLE_IDENTIFIER = "com.scarriffleservices.calendarr.ios.watchkitapp.CalendarrWatch-Watch-AppTests"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -830,10 +830,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = PP34X97WS3; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 3.5; PRODUCT_BUNDLE_IDENTIFIER = "com.scarriffleservices.calendarr.ios.watchkitapp.CalendarrWatch-Watch-AppUITests"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -852,10 +852,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = PP34X97WS3; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 3.5; PRODUCT_BUNDLE_IDENTIFIER = "com.scarriffleservices.calendarr.ios.watchkitapp.CalendarrWatch-Watch-AppUITests"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -877,7 +877,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = CalendarrWatchComplications/CalendarrWatchComplications.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = PP34X97WS3; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = CalendarrWatchComplications/Info.plist; @@ -889,7 +889,7 @@ "@executable_path/../../Frameworks", "@executable_path/../../../../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 3.5; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp.complications; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -911,7 +911,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = CalendarrWatchComplications/CalendarrWatchComplications.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = PP34X97WS3; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = CalendarrWatchComplications/Info.plist; @@ -923,7 +923,7 @@ "@executable_path/../../Frameworks", "@executable_path/../../../../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 3.5; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.watchkitapp.complications; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; From b489f10c28b520d904a574acee63bb1540c3b0f1 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 11 Sep 2026 18:33:38 +0200 Subject: [PATCH 83/90] Make the countdown narrow enough for the slot it sits in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text(style: .timer) renders 1:23:45 once more than an hour out. An accessoryCorner has room for three or four characters beside its curved label, and a circular gauge for about the same, so the live timer overlapped what it sat next to. Replaced with a compact value — "23m", "2h", "3d" — recomputed per timeline entry, which is why the timeline now carries a mark every minute for the first hour. Both corner kinds and the gauge also get lineLimit(1) and a minimum scale factor, so an unexpectedly wide value shrinks instead of colliding. Co-Authored-By: Claude Opus 5 --- .../CornerComplicationViews.swift | 9 ++++++++- .../SmallComplicationViews.swift | 7 +++++-- .../WatchComplicationSupport.swift | 16 ++++++++++++++++ CalendarrWatchShared/WatchL10n.swift | 2 ++ 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/CalendarrWatchComplications/CornerComplicationViews.swift b/CalendarrWatchComplications/CornerComplicationViews.swift index b96bb86..07ad46e 100644 --- a/CalendarrWatchComplications/CornerComplicationViews.swift +++ b/CalendarrWatchComplications/CornerComplicationViews.swift @@ -14,7 +14,12 @@ struct CornerCountdownComplicationView: View { let next = WatchComplicationSupport.nextEvent(in: entry.snapshot, at: entry.date) Group { if let next { - Text(WatchComplicationSupport.countdownTarget(next, at: entry.date), style: .timer) + 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") @@ -37,6 +42,8 @@ struct CornerTimeComplicationView: View { 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") diff --git a/CalendarrWatchComplications/SmallComplicationViews.swift b/CalendarrWatchComplications/SmallComplicationViews.swift index b06eb57..fe099ce 100644 --- a/CalendarrWatchComplications/SmallComplicationViews.swift +++ b/CalendarrWatchComplications/SmallComplicationViews.swift @@ -24,8 +24,11 @@ struct CountdownComplicationView: View { Gauge(value: WatchComplicationSupport.gaugeFraction(to: target, at: entry.date)) { Image(systemName: "calendar") } currentValueLabel: { - Text(target, style: .timer) - .font(.system(size: 12)) + Text(WatchComplicationSupport.compactCountdown(to: target, at: entry.date, + language: entry.language)) + .font(.system(size: 13)) + .lineLimit(1) + .minimumScaleFactor(0.6) } .gaugeStyle(.accessoryCircularCapacity) .widgetAccentable() diff --git a/CalendarrWatchComplications/WatchComplicationSupport.swift b/CalendarrWatchComplications/WatchComplicationSupport.swift index db566b6..169b494 100644 --- a/CalendarrWatchComplications/WatchComplicationSupport.swift +++ b/CalendarrWatchComplications/WatchComplicationSupport.swift @@ -28,6 +28,22 @@ enum WatchComplicationSupport { 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 diff --git a/CalendarrWatchShared/WatchL10n.swift b/CalendarrWatchShared/WatchL10n.swift index 5e53409..ea11014 100644 --- a/CalendarrWatchShared/WatchL10n.swift +++ b/CalendarrWatchShared/WatchL10n.swift @@ -26,6 +26,7 @@ enum WatchL10n { "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", @@ -41,6 +42,7 @@ enum WatchL10n { "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", From 938ca7ca98242dc646d4a974d3541cb98e746cbd Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 11 Sep 2026 18:38:54 +0200 Subject: [PATCH 84/90] Render the circular complications as edge-to-edge closed gauges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Date and "events today" filled their slot with AccessoryWidgetBackground and centred text, which reads as a small element with space around it. Both are now Gauges in .accessoryCircularCapacity — the WidgetKit equivalent of ClockKit's ClosedGauge templates, which no longer exist and cannot be reached from a WidgetKit complication at all. The rings carry meaning rather than decorating: the date's drains through the day, and the count's drains as today's appointments are worked through. Both drain, matching the countdown rather than inventing a second direction. An empty day leaves the ring empty instead of full, so it does not read as an untouched one. Co-Authored-By: Claude Opus 5 --- .../SmallComplicationViews.swift | 43 ++++++++++--------- .../WatchComplicationSupport.swift | 25 +++++++++++ 2 files changed, 47 insertions(+), 21 deletions(-) diff --git a/CalendarrWatchComplications/SmallComplicationViews.swift b/CalendarrWatchComplications/SmallComplicationViews.swift index fe099ce..e80ce1a 100644 --- a/CalendarrWatchComplications/SmallComplicationViews.swift +++ b/CalendarrWatchComplications/SmallComplicationViews.swift @@ -69,17 +69,19 @@ struct DateComplicationView: View { Label("\(WatchComplicationSupport.monthAbbreviation(entry.date, language: entry.language)) \(day)", systemImage: "calendar") default: - ZStack { - AccessoryWidgetBackground() - VStack(spacing: 0) { - Text("\(day)") - .font(.system(size: 22, weight: .bold)) - .minimumScaleFactor(0.7) - .widgetAccentable() - Text(WatchComplicationSupport.monthAbbreviation(entry.date, language: entry.language)) - .font(.system(size: 8, weight: .semibold)) - } + // A closed gauge, filling the slot edge to edge — the WidgetKit + // equivalent of ClockKit's ClosedGaugeText, which no longer exists. + // The ring is not decoration: it drains as the day does. + Gauge(value: WatchComplicationSupport.dayRemainingFraction(at: entry.date)) { + Text(WatchComplicationSupport.monthAbbreviation(entry.date, language: entry.language)) + } currentValueLabel: { + Text("\(day)") + .font(.system(size: 20, weight: .semibold)) + .lineLimit(1) + .minimumScaleFactor(0.6) } + .gaugeStyle(.accessoryCircularCapacity) + .widgetAccentable() } } } @@ -99,18 +101,17 @@ struct TodayCountComplicationView: View { Label("\(count) \(WatchL10n.t("watch.events_today", entry.language))", systemImage: "calendar") default: - ZStack { - AccessoryWidgetBackground() - VStack(spacing: 0) { - Text("\(count)") - .font(.system(size: 22, weight: .bold)) - .widgetAccentable() - Text(WatchL10n.t("watch.events_today", entry.language).uppercased()) - .font(.system(size: 7, weight: .semibold)) - .lineLimit(1) - .minimumScaleFactor(0.6) - } + 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() } } } diff --git a/CalendarrWatchComplications/WatchComplicationSupport.swift b/CalendarrWatchComplications/WatchComplicationSupport.swift index 169b494..65f141a 100644 --- a/CalendarrWatchComplications/WatchComplicationSupport.swift +++ b/CalendarrWatchComplications/WatchComplicationSupport.swift @@ -50,6 +50,31 @@ enum WatchComplicationSupport { 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) From 2c26be2ab5eb3129d547f51a3da44cd393d17c1d Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 11 Sep 2026 18:53:17 +0200 Subject: [PATCH 85/90] Add a corner complication with the appointment's name as the value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requested shape: the event title as the text, the time small alongside. This inverts how accessoryCorner is built — the curved bezel is where a corner has room for long text and the inner area holds three or four characters — so the title shrinks and truncates. Kept alongside WatchCornerTime, which carries the same information with the roles the other way round, so both can be compared on the face rather than argued about. Co-Authored-By: Claude Opus 5 --- .../CalendarrWatchComplications.swift | 16 +++++++++ .../CornerComplicationViews.swift | 34 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/CalendarrWatchComplications/CalendarrWatchComplications.swift b/CalendarrWatchComplications/CalendarrWatchComplications.swift index f789503..3f2b64f 100644 --- a/CalendarrWatchComplications/CalendarrWatchComplications.swift +++ b/CalendarrWatchComplications/CalendarrWatchComplications.swift @@ -6,6 +6,7 @@ struct CalendarrWatchComplicationBundle: WidgetBundle { var body: some Widget { WatchNextEventWidget() WatchCornerCountdownWidget() + WatchCornerTitleWidget() WatchCornerTimeWidget() WatchCountdownWidget() WatchDateWidget() @@ -54,6 +55,21 @@ struct WatchCornerCountdownWidget: Widget { } } +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" diff --git a/CalendarrWatchComplications/CornerComplicationViews.swift b/CalendarrWatchComplications/CornerComplicationViews.swift index 07ad46e..2d614ea 100644 --- a/CalendarrWatchComplications/CornerComplicationViews.swift +++ b/CalendarrWatchComplications/CornerComplicationViews.swift @@ -54,3 +54,37 @@ struct CornerTimeComplicationView: View { } } } + +/// 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)) + } + } +} From 1a1230a796fb5bac8b5068492a78fd7ab1f8c63a Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 11 Sep 2026 20:19:21 +0200 Subject: [PATCH 86/90] Put the next appointment on the Infograph bezel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The position asked for is the bezel slot: a circular complication plus a label that arcs across the top, where the tick marks otherwise are. ClockKit called that family graphicBezel; in WidgetKit it is an accessoryCircular widget with .widgetLabel attached. Nothing was attached, so that arc stayed empty while Apple's Calendar fills it — which is the difference that was visible on the watch and invisible in every check made here. All three circular kinds now carry it. Faces without a bezel slot ignore the label, so there is no cost to always providing it. cornerLabel becomes eventLabel: the same text serves the corner edge and the bezel arc, and the old name claimed otherwise. Co-Authored-By: Claude Opus 5 --- Calendarr iOS.xcodeproj/project.pbxproj | 2 + .../xcschemes/CalendarrWatch.xcscheme | 111 ++++++++++++++++ .../CalendarrWatchComplications.xcscheme | 122 ++++++++++++++++++ .../xcschemes/CalendarrWidgets.xcscheme | 111 ++++++++++++++++ .../CornerComplicationViews.swift | 2 +- .../SmallComplicationViews.swift | 15 +++ .../WatchComplicationSupport.swift | 12 +- 7 files changed, 371 insertions(+), 4 deletions(-) create mode 100644 Calendarr iOS.xcodeproj/xcshareddata/xcschemes/CalendarrWatch.xcscheme create mode 100644 Calendarr iOS.xcodeproj/xcshareddata/xcschemes/CalendarrWatchComplications.xcscheme create mode 100644 Calendarr iOS.xcodeproj/xcshareddata/xcschemes/CalendarrWidgets.xcscheme diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index ee60bd5..042e865 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -1092,6 +1092,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + MARKETING_VERSION = 3.6; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -1142,6 +1143,7 @@ "$(inherited)", "@executable_path/Frameworks", ); + MARKETING_VERSION = 3.6; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/Calendarr iOS.xcodeproj/xcshareddata/xcschemes/CalendarrWatch.xcscheme b/Calendarr iOS.xcodeproj/xcshareddata/xcschemes/CalendarrWatch.xcscheme new file mode 100644 index 0000000..8f43674 --- /dev/null +++ b/Calendarr iOS.xcodeproj/xcshareddata/xcschemes/CalendarrWatch.xcscheme @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Calendarr iOS.xcodeproj/xcshareddata/xcschemes/CalendarrWatchComplications.xcscheme b/Calendarr iOS.xcodeproj/xcshareddata/xcschemes/CalendarrWatchComplications.xcscheme new file mode 100644 index 0000000..a75fd59 --- /dev/null +++ b/Calendarr iOS.xcodeproj/xcshareddata/xcschemes/CalendarrWatchComplications.xcscheme @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Calendarr iOS.xcodeproj/xcshareddata/xcschemes/CalendarrWidgets.xcscheme b/Calendarr iOS.xcodeproj/xcshareddata/xcschemes/CalendarrWidgets.xcscheme new file mode 100644 index 0000000..2f53f09 --- /dev/null +++ b/Calendarr iOS.xcodeproj/xcshareddata/xcschemes/CalendarrWidgets.xcscheme @@ -0,0 +1,111 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CalendarrWatchComplications/CornerComplicationViews.swift b/CalendarrWatchComplications/CornerComplicationViews.swift index 2d614ea..ea0ffe4 100644 --- a/CalendarrWatchComplications/CornerComplicationViews.swift +++ b/CalendarrWatchComplications/CornerComplicationViews.swift @@ -26,7 +26,7 @@ struct CornerCountdownComplicationView: View { } } .widgetLabel { - Text(WatchComplicationSupport.cornerLabel(next, at: entry.date, language: entry.language)) + Text(WatchComplicationSupport.eventLabel(next, at: entry.date, language: entry.language)) } } } diff --git a/CalendarrWatchComplications/SmallComplicationViews.swift b/CalendarrWatchComplications/SmallComplicationViews.swift index e80ce1a..acb7874 100644 --- a/CalendarrWatchComplications/SmallComplicationViews.swift +++ b/CalendarrWatchComplications/SmallComplicationViews.swift @@ -2,6 +2,18 @@ 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 @@ -32,6 +44,7 @@ struct CountdownComplicationView: View { } .gaugeStyle(.accessoryCircularCapacity) .widgetAccentable() + .eventBezelLabel(entry) } else { ZStack { AccessoryWidgetBackground() @@ -82,6 +95,7 @@ struct DateComplicationView: View { } .gaugeStyle(.accessoryCircularCapacity) .widgetAccentable() + .eventBezelLabel(entry) } } } @@ -112,6 +126,7 @@ struct TodayCountComplicationView: View { } .gaugeStyle(.accessoryCircularCapacity) .widgetAccentable() + .eventBezelLabel(entry) } } } diff --git a/CalendarrWatchComplications/WatchComplicationSupport.swift b/CalendarrWatchComplications/WatchComplicationSupport.swift index 65f141a..d507fb6 100644 --- a/CalendarrWatchComplications/WatchComplicationSupport.swift +++ b/CalendarrWatchComplications/WatchComplicationSupport.swift @@ -89,9 +89,15 @@ enum WatchComplicationSupport { return formatter.string(from: date).uppercased() } - /// The curved bezel label for a corner complication: time plus title, or a - /// plain "no events". - static func cornerLabel(_ event: SnapshotEvent?, at now: Date, language: String) -> String { + /// 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)" From 6e91eaaaa21cae470bc748bf2fa2a0a9e3dfa976 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sat, 12 Sep 2026 14:10:21 +0200 Subject: [PATCH 87/90] Read all-day events correctly, and make the date circle say a date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things, one of which is not a watch problem at all. parseDate already treated a date-only string as all-day, but isAllDay came from the JSON flag alone. An event sent as "2026-09-12" with no flag therefore landed at midnight with isAllDay false, and every surface that formats a time put "00:00" in front of it — the widgets as much as the watch. Now derived from both. The date complication showed the day number and nothing else, because .accessoryCircularCapacity renders only currentValueLabel and the month sat in the label closure, where it is never drawn. It now stacks weekday over day number, which is what makes it read as a date rather than a number. The ring only appears while an appointment is running, and then shows how far through it we are. An empty track around an idle day said nothing, so there is no Gauge at all in that case. All-day events do not count as running: one would hold the ring at an arbitrary fraction for twenty-four hours. Co-Authored-By: Claude Opus 5 --- Calendarr iOS/Models/CalEvent.swift | 9 +++- .../SmallComplicationViews.swift | 50 ++++++++++++++----- .../WatchComplicationSupport.swift | 26 ++++++++++ 3 files changed, 71 insertions(+), 14 deletions(-) diff --git a/Calendarr iOS/Models/CalEvent.swift b/Calendarr iOS/Models/CalEvent.swift index 94ae950..b5338d3 100644 --- a/Calendarr iOS/Models/CalEvent.swift +++ b/Calendarr iOS/Models/CalEvent.swift @@ -91,7 +91,14 @@ struct CalEvent: Identifiable, Hashable { else if let n = json["id"] as? Int { id = String(n) } else { return nil } - let isAllDay = json["allDay"] as? Bool ?? false + // 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 isAllDay = (json["allDay"] as? Bool ?? false) || startIsDateOnly let startDate = parseDate(startStr, allDay: isAllDay) let endDate = parseDate(endStr, allDay: isAllDay) guard let s = startDate, let e = endDate else { return nil } diff --git a/CalendarrWatchComplications/SmallComplicationViews.swift b/CalendarrWatchComplications/SmallComplicationViews.swift index acb7874..4ad708a 100644 --- a/CalendarrWatchComplications/SmallComplicationViews.swift +++ b/CalendarrWatchComplications/SmallComplicationViews.swift @@ -67,7 +67,10 @@ struct CountdownComplicationView: View { } } -/// Day number and month. No appointment information — a date replacement. +/// 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 @@ -76,26 +79,47 @@ struct DateComplicationView: View { 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("\(WatchComplicationSupport.monthAbbreviation(entry.date, language: entry.language)) \(day)", - systemImage: "calendar") + Label("\(weekday) \(day)", systemImage: "calendar") default: - // A closed gauge, filling the slot edge to edge — the WidgetKit - // equivalent of ClockKit's ClosedGaugeText, which no longer exists. - // The ring is not decoration: it drains as the day does. - Gauge(value: WatchComplicationSupport.dayRemainingFraction(at: entry.date)) { - Text(WatchComplicationSupport.monthAbbreviation(entry.date, language: entry.language)) + 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: { - Text("\(day)") - .font(.system(size: 20, weight: .semibold)) - .lineLimit(1) - .minimumScaleFactor(0.6) + dateStack } .gaugeStyle(.accessoryCircularCapacity) - .widgetAccentable() .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) } } } diff --git a/CalendarrWatchComplications/WatchComplicationSupport.swift b/CalendarrWatchComplications/WatchComplicationSupport.swift index d507fb6..e2e1efa 100644 --- a/CalendarrWatchComplications/WatchComplicationSupport.swift +++ b/CalendarrWatchComplications/WatchComplicationSupport.swift @@ -22,6 +22,32 @@ enum WatchComplicationSupport { 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 { From 465c465af2c1acbcc608ad515132f25d1962f27e Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sat, 12 Sep 2026 14:49:57 +0200 Subject: [PATCH 88/90] Treat a whole-day span as all-day, whatever the payload claims The previous rule only caught date-only strings. A multi-day event sent with full timestamps and no allDay flag still arrived as a timed event, so a holiday running from the 16th to the 21st rendered as a one-hour slot at midnight. Now an event that starts and ends on a day boundary and covers at least one whole day is all-day regardless. That is a statement about what the data means, not about how any one client formats it, which is why it lives in the parser and not in a view. Co-Authored-By: Claude Opus 5 --- Calendarr iOS/Models/CalEvent.swift | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Calendarr iOS/Models/CalEvent.swift b/Calendarr iOS/Models/CalEvent.swift index b5338d3..59c5cc0 100644 --- a/Calendarr iOS/Models/CalEvent.swift +++ b/Calendarr iOS/Models/CalEvent.swift @@ -98,11 +98,21 @@ struct CalEvent: Identifiable, Hashable { let clean = startStr.trimmingCharacters(in: .whitespaces) return clean.count == 10 && !clean.contains("T") }() - let isAllDay = (json["allDay"] as? Bool ?? false) || startIsDateOnly - let startDate = parseDate(startStr, allDay: isAllDay) - let endDate = parseDate(endStr, allDay: isAllDay) + 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 + let spansWholeDays = e > s + && cal.startOfDay(for: s) == s + && cal.startOfDay(for: e) == e + let isAllDay = declaredAllDay || spansWholeDays + return CalEvent( id: id, url: json["url"] as? String ?? "", From e5b3e549e8111869e8bc961e530b07f4a4cee512 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sat, 12 Sep 2026 14:52:52 +0200 Subject: [PATCH 89/90] Catch multi-day events that do not end on a day boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requiring both ends to sit exactly on midnight was too strict. A holiday stored as the 16th at 00:00 through the 21st at 01:00 slipped through and rendered as a one-hour slot. Starting on a day boundary and running into a later day is enough to call it all-day. The agenda row now names the weekday on both sides when an event crosses a day. Showing only "00:00 – 01:00" for something that runs from Wednesday to Monday is true and useless. The timezone handling above this is deliberately untouched: timed events show the right time, so the UTC assumption for timestamps without an offset is not what produced the odd hour. Co-Authored-By: Claude Opus 5 --- Calendarr iOS/Models/CalEvent.swift | 6 +++++- CalendarrWatch/Views/WatchEventRow.swift | 25 ++++++++++++++++++------ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/Calendarr iOS/Models/CalEvent.swift b/Calendarr iOS/Models/CalEvent.swift index 59c5cc0..db2298f 100644 --- a/Calendarr iOS/Models/CalEvent.swift +++ b/Calendarr iOS/Models/CalEvent.swift @@ -108,9 +108,13 @@ struct CalEvent: Identifiable, Hashable { // 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) == e + && cal.startOfDay(for: e) > cal.startOfDay(for: s) let isAllDay = declaredAllDay || spansWholeDays return CalEvent( diff --git a/CalendarrWatch/Views/WatchEventRow.swift b/CalendarrWatch/Views/WatchEventRow.swift index aff41da..6450f26 100644 --- a/CalendarrWatch/Views/WatchEventRow.swift +++ b/CalendarrWatch/Views/WatchEventRow.swift @@ -41,14 +41,27 @@ extension Color { } enum WatchEventFormat { - static func timeRange(_ event: SnapshotEvent, language: String) -> String { + static func timeRange(_ event: SnapshotEvent, language: String, + calendar: Calendar = Calendar(identifier: .gregorian)) -> String { if event.isAllDay { return WatchL10n.t("watch.allday", language) } - let formatter = DateFormatter() - formatter.locale = WatchL10n.locale(language) - formatter.dateFormat = "HH:mm" - let start = formatter.string(from: event.start) + + 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 } - return "\(start) – \(formatter.string(from: event.end))" + + // 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 { From 31d7a0080e28365bb2ef29e0589fc3af3ff8a3b8 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sat, 12 Sep 2026 14:57:02 +0200 Subject: [PATCH 90/90] Ship the app for iPad, and keep the watch out of the Mac build Device family becomes 1,2 for the app and the widget extension, and the iPad gets all four interface orientations. That is not a preference: App Store Connect rejects an iPad-capable bundle that declares fewer (error 90474), because iPad multitasking can present the app at any size. The iPhone key stays portrait-only. Mac Catalyst still resolves to family 2 alone, which is what 84f5c00 set up and what this had to avoid breaking. Building for Catalyst also turned up a regression from the watch work: the Embed Watch Content phase carried watchOS content into a macOS build, which fails outright. It went unnoticed because no Catalyst build was run after the watch targets were added. Both the copy and its target dependency are now filtered to iOS. Verified on all three destinations: iOS carries Watch/CalendarrWatch.app with its complications, Catalyst carries no Watch folder, and both report the right device family. Co-Authored-By: Claude Opus 5 --- Calendarr iOS.xcodeproj/project.pbxproj | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index 042e865..3a7d006 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -10,7 +10,7 @@ 3934CF4630544C2E003958B8 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3934CF4530544C2E003958B8 /* WidgetKit.framework */; }; 3934CF4830544C2E003958B8 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3934CF4730544C2E003958B8 /* SwiftUI.framework */; }; 3934CF5530544C2E003958B8 /* CalendarrWatchComplications.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3934CF4330544C2E003958B8 /* CalendarrWatchComplications.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 39E1B0010001000000000001 /* CalendarrWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 3934CF1A30544C01003958B8 /* CalendarrWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 39E1B0010001000000000001 /* CalendarrWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 3934CF1A30544C01003958B8 /* CalendarrWatch.app */; platformFilters = (ios, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; AA9701000001000000000001 /* AppAuth in Frameworks */ = {isa = PBXBuildFile; productRef = AA9701000002000000000001 /* AppAuth */; }; CA1E4D0A0002000000000001 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000001 /* CalendarrCore */; }; CA1E4D0A0002000000000002 /* CalendarrCore in Frameworks */ = {isa = PBXBuildFile; productRef = CA1E4D0A0001000000000002 /* CalendarrCore */; }; @@ -651,6 +651,9 @@ }; 39E1B0010004000000000001 /* PBXTargetDependency */ = { isa = PBXTargetDependency; + platformFilters = ( + ios, + ); target = 3934CF1930544C01003958B8 /* CalendarrWatch */; targetProxy = 39E1B0010003000000000001 /* PBXContainerItemProxy */; }; @@ -1087,6 +1090,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -1109,7 +1113,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; "TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2; }; name = Debug; @@ -1138,6 +1142,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -1160,7 +1165,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; "TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2; }; name = Release; @@ -1195,7 +1200,7 @@ SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; "TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2; }; name = Debug; @@ -1229,7 +1234,7 @@ SWIFT_COMPILATION_MODE = wholemodule; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 1; + TARGETED_DEVICE_FAMILY = "1,2"; "TARGETED_DEVICE_FAMILY[sdk=macosx*]" = 2; }; name = Release;