From e5529ca653950fbbce2086600050e90447554fa9 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 17 May 2026 08:32:34 +0200 Subject: [PATCH] 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) + } + } + } +}