Initial Commit
This commit is contained in:
340
Calendarr iOS.xcodeproj/project.pbxproj
Normal file
340
Calendarr iOS.xcodeproj/project.pbxproj
Normal file
@@ -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 = "<group>";
|
||||
};
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
C0000C01FB4E10100AB5001 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C0000B01FC4E10100AB5001 /* Calendarr iOS.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 */;
|
||||
}
|
||||
7
Calendarr iOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
Calendarr iOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>Calendarr iOS.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
Calendarr iOS/Assets.xcassets/Contents.json
Normal file
6
Calendarr iOS/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
61
Calendarr iOS/CalendarrApp.swift
Normal file
61
Calendarr iOS/CalendarrApp.swift
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
165
Calendarr iOS/Models/AppSettings.swift
Normal file
165
Calendarr iOS/Models/AppSettings.swift
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
115
Calendarr iOS/Models/CalEvent.swift
Normal file
115
Calendarr iOS/Models/CalEvent.swift
Normal file
@@ -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)
|
||||
}
|
||||
248
Calendarr iOS/Models/CalendarStore.swift
Normal file
248
Calendarr iOS/Models/CalendarStore.swift
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
368
Calendarr iOS/Services/CalendarrAPI.swift
Normal file
368
Calendarr iOS/Services/CalendarrAPI.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
489
Calendarr iOS/Views/AccountsView.swift
Normal file
489
Calendarr iOS/Views/AccountsView.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
107
Calendarr iOS/Views/Calendar/AgendaView.swift
Normal file
107
Calendarr iOS/Views/Calendar/AgendaView.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
295
Calendarr iOS/Views/Calendar/CalendarHostView.swift
Normal file
295
Calendarr iOS/Views/Calendar/CalendarHostView.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
125
Calendarr iOS/Views/Calendar/DayView.swift
Normal file
125
Calendarr iOS/Views/Calendar/DayView.swift
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
143
Calendarr iOS/Views/Calendar/EventDetailSheet.swift
Normal file
143
Calendarr iOS/Views/Calendar/EventDetailSheet.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
182
Calendarr iOS/Views/Calendar/EventEditorSheet.swift
Normal file
182
Calendarr iOS/Views/Calendar/EventEditorSheet.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
150
Calendarr iOS/Views/Calendar/MonthView.swift
Normal file
150
Calendarr iOS/Views/Calendar/MonthView.swift
Normal file
@@ -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..<rowCount, id: \.self) { row in
|
||||
HStack(spacing: 0) {
|
||||
ForEach(0..<7, id: \.self) { col in
|
||||
let day = gridDays[row * 7 + col]
|
||||
DayCell(
|
||||
date: day,
|
||||
isCurrentMonth: cal.isDate(day, equalTo: monthStart, toGranularity: .month),
|
||||
isToday: cal.isDateInToday(day),
|
||||
events: store.events(on: day),
|
||||
rowHeight: rowH,
|
||||
onTap: { onDayTap(day) },
|
||||
onEventTap: onEventTap
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(height: rowH)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DayCell: View {
|
||||
let date: Date
|
||||
let isCurrentMonth: Bool
|
||||
let isToday: Bool
|
||||
let events: [CalEvent]
|
||||
let rowHeight: CGFloat
|
||||
let onTap: () -> Void
|
||||
let onEventTap: (CalEvent) -> Void
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
118
Calendarr iOS/Views/Calendar/QuarterView.swift
Normal file
118
Calendarr iOS/Views/Calendar/QuarterView.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
45
Calendarr iOS/Views/Calendar/TimeGridView.swift
Normal file
45
Calendarr iOS/Views/Calendar/TimeGridView.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
196
Calendarr iOS/Views/Calendar/WeekView.swift
Normal file
196
Calendarr iOS/Views/Calendar/WeekView.swift
Normal file
@@ -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
|
||||
}
|
||||
135
Calendarr iOS/Views/LoginView.swift
Normal file
135
Calendarr iOS/Views/LoginView.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
18
Calendarr iOS/Views/MainTabView.swift
Normal file
18
Calendarr iOS/Views/MainTabView.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MainTabView: View {
|
||||
@Environment(AppState.self) var appState
|
||||
@State private var showMenu = false
|
||||
|
||||
var api: CalendarrAPI {
|
||||
CalendarrAPI(baseURL: appState.serverURL, token: appState.authToken)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
CalendarHostView(api: api, showMenu: $showMenu)
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
.sheet(isPresented: $showMenu) {
|
||||
MenuSheet(api: api)
|
||||
}
|
||||
}
|
||||
}
|
||||
91
Calendarr iOS/Views/MenuSheet.swift
Normal file
91
Calendarr iOS/Views/MenuSheet.swift
Normal file
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
306
Calendarr iOS/Views/ProfileView.swift
Normal file
306
Calendarr iOS/Views/ProfileView.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
Calendarr iOS/Views/RootView.swift
Normal file
15
Calendarr iOS/Views/RootView.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
94
Calendarr iOS/Views/ServerSetupView.swift
Normal file
94
Calendarr iOS/Views/ServerSetupView.swift
Normal file
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
168
Calendarr iOS/Views/ServerView.swift
Normal file
168
Calendarr iOS/Views/ServerView.swift
Normal file
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
316
Calendarr iOS/Views/SettingsView.swift
Normal file
316
Calendarr iOS/Views/SettingsView.swift
Normal file
@@ -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<Color> {
|
||||
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<T: Hashable & Equatable>: 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user