commit e5529ca653950fbbce2086600050e90447554fa9 Author: Scarriffle Date: Sun May 17 08:32:34 2026 +0200 Initial Commit 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 0000000..9e7a097 Binary files /dev/null and b/Calendarr iOS/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ 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) + } + } + } +}