diff --git a/Calendarr iOS.xcodeproj/project.pbxproj b/Calendarr iOS.xcodeproj/project.pbxproj index dfdf95f..35473f6 100644 --- a/Calendarr iOS.xcodeproj/project.pbxproj +++ b/Calendarr iOS.xcodeproj/project.pbxproj @@ -6,11 +6,27 @@ objectVersion = 77; objects = { +/* Begin PBXContainerItemProxy section */ + 3927C7C02FB99D0F00EAD8ED /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = C0000301FB4E10100AB5001 /* Project object */; + proxyType = 1; + remoteGlobalIDString = C0000A01FB4E10100AB5001; + remoteInfo = "Calendarr iOS"; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ + 3927C7BC2FB99D0E00EAD8ED /* Calendarr iOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Calendarr iOSTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; C0000B01FC4E10100AB5001 /* Calendarr iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Calendarr iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + 3927C7BD2FB99D0F00EAD8ED /* Calendarr iOSTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "Calendarr iOSTests"; + sourceTree = ""; + }; C0000D01FC4E10100AB5001 /* Calendarr iOS */ = { isa = PBXFileSystemSynchronizedRootGroup; path = "Calendarr iOS"; @@ -19,6 +35,13 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 3927C7B92FB99D0E00EAD8ED /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; C0000801FB4E10100AB5001 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -33,6 +56,7 @@ isa = PBXGroup; children = ( C0000D01FC4E10100AB5001 /* Calendarr iOS */, + 3927C7BD2FB99D0F00EAD8ED /* Calendarr iOSTests */, C0000C01FB4E10100AB5001 /* Products */, ); sourceTree = ""; @@ -41,6 +65,7 @@ isa = PBXGroup; children = ( C0000B01FC4E10100AB5001 /* Calendarr iOS.app */, + 3927C7BC2FB99D0E00EAD8ED /* Calendarr iOSTests.xctest */, ); name = Products; sourceTree = ""; @@ -48,6 +73,29 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 3927C7BB2FB99D0E00EAD8ED /* Calendarr iOSTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3927C7C22FB99D0F00EAD8ED /* Build configuration list for PBXNativeTarget "Calendarr iOSTests" */; + buildPhases = ( + 3927C7B82FB99D0E00EAD8ED /* Sources */, + 3927C7B92FB99D0E00EAD8ED /* Frameworks */, + 3927C7BA2FB99D0E00EAD8ED /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 3927C7C12FB99D0F00EAD8ED /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 3927C7BD2FB99D0F00EAD8ED /* Calendarr iOSTests */, + ); + name = "Calendarr iOSTests"; + packageProductDependencies = ( + ); + productName = "Calendarr iOSTests"; + productReference = 3927C7BC2FB99D0E00EAD8ED /* Calendarr iOSTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; C0000A01FB4E10100AB5001 /* Calendarr iOS */ = { isa = PBXNativeTarget; buildConfigurationList = C0001601FB4E10100AB5001 /* Build configuration list for PBXNativeTarget "Calendarr iOS" */; @@ -77,9 +125,13 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 2640; + LastSwiftUpdateCheck = 2650; LastUpgradeCheck = 2640; TargetAttributes = { + 3927C7BB2FB99D0E00EAD8ED = { + CreatedOnToolsVersion = 26.5; + TestTargetID = C0000A01FB4E10100AB5001; + }; C0000A01FB4E10100AB5001 = { CreatedOnToolsVersion = 26.4.1; }; @@ -101,11 +153,19 @@ projectRoot = ""; targets = ( C0000A01FB4E10100AB5001 /* Calendarr iOS */, + 3927C7BB2FB99D0E00EAD8ED /* Calendarr iOSTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 3927C7BA2FB99D0E00EAD8ED /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; C0000901FB4E10100AB5001 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -116,6 +176,13 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 3927C7B82FB99D0E00EAD8ED /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; C0000701FB4E10100AB5001 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -125,7 +192,67 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 3927C7C12FB99D0F00EAD8ED /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C0000A01FB4E10100AB5001 /* Calendarr iOS */; + targetProxy = 3927C7C02FB99D0F00EAD8ED /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ + 3927C7C32FB99D0F00EAD8ED /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = PP34X97WS3; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + MACOSX_DEPLOYMENT_TARGET = 26.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.local.scarriffle.Calendarr-iOSTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Calendarr iOS.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Calendarr iOS"; + XROS_DEPLOYMENT_TARGET = 26.5; + }; + name = Debug; + }; + 3927C7C42FB99D0F00EAD8ED /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = PP34X97WS3; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.5; + MACOSX_DEPLOYMENT_TARGET = 26.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.local.scarriffle.Calendarr-iOSTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Calendarr iOS.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Calendarr iOS"; + XROS_DEPLOYMENT_TARGET = 26.5; + }; + name = Release; + }; C0001401FB4E10100AB5001 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -254,16 +381,19 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = PP34X97WS3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = Calendarr; INFOPLIST_KEY_CFBundleName = Calendarr; + "INFOPLIST_KEY_ITSAppUsesNonExemptEncryption" = NO; INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -272,11 +402,16 @@ PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; @@ -287,16 +422,19 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = PP34X97WS3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = Calendarr; INFOPLIST_KEY_CFBundleName = Calendarr; + "INFOPLIST_KEY_ITSAppUsesNonExemptEncryption" = NO; INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices"; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -305,17 +443,31 @@ PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios; PRODUCT_NAME = "Calendarr iOS"; STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 3927C7C22FB99D0F00EAD8ED /* Build configuration list for PBXNativeTarget "Calendarr iOSTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 3927C7C32FB99D0F00EAD8ED /* Debug */, + 3927C7C42FB99D0F00EAD8ED /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; C0000601FB4E10100AB5001 /* Build configuration list for PBXProject "Calendarr iOS" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Calendarr iOS.xcodeproj/xcshareddata/xcschemes/Calendarr iOS.xcscheme b/Calendarr iOS.xcodeproj/xcshareddata/xcschemes/Calendarr iOS.xcscheme new file mode 100644 index 0000000..ee8a7c7 --- /dev/null +++ b/Calendarr iOS.xcodeproj/xcshareddata/xcschemes/Calendarr iOS.xcscheme @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist b/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist index 9c5c084..0190ba4 100644 --- a/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Calendarr iOS.xcodeproj/xcuserdata/scarriffle.xcuserdatad/xcschemes/xcschememanagement.plist @@ -10,5 +10,18 @@ 0 + SuppressBuildableAutocreation + + 3927C7BB2FB99D0E00EAD8ED + + primary + + + C0000A01FB4E10100AB5001 + + primary + + + diff --git a/Calendarr iOS/Assets.xcassets/AccentColor.colorset/Contents.json b/Calendarr iOS/Assets.xcassets/AccentColor.colorset/Contents.json index 43b8a78..70d4dc3 100644 --- a/Calendarr iOS/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/Calendarr iOS/Assets.xcassets/AccentColor.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.957", - "green" : "0.522", - "red" : "0.259" + "blue" : "0.314", + "green" : "0.627", + "red" : "0.125" } }, "idiom" : "universal" diff --git a/Calendarr iOS/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/Calendarr iOS/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png index 9e7a097..a88fa5b 100644 Binary files a/Calendarr iOS/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png and b/Calendarr iOS/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ diff --git a/Calendarr iOS/Models/AppSettings.swift b/Calendarr iOS/Models/AppSettings.swift index 2f82346..86f0f37 100644 --- a/Calendarr iOS/Models/AppSettings.swift +++ b/Calendarr iOS/Models/AppSettings.swift @@ -13,6 +13,9 @@ struct AppSettings: Codable { var language: String = "de" var monthDividerColor: String = "#7090c0" var monthLabelColor: String = "#7090c0" + var textColor: String = "#FFFFFF" + var backgroundColor: String = "#000000" + var lineColor: String = "#3A3A3C" enum CodingKeys: String, CodingKey { case defaultView = "default_view" @@ -27,6 +30,9 @@ struct AppSettings: Codable { case language case monthDividerColor = "month_divider_color" case monthLabelColor = "month_label_color" + case textColor = "text_color" + case backgroundColor = "background_color" + case lineColor = "line_color" } } diff --git a/Calendarr iOS/Models/CalendarStore.swift b/Calendarr iOS/Models/CalendarStore.swift index 9dfde14..c3ebf80 100644 --- a/Calendarr iOS/Models/CalendarStore.swift +++ b/Calendarr iOS/Models/CalendarStore.swift @@ -4,13 +4,13 @@ import SwiftUI enum CalViewType: String, CaseIterable { case month, week, day, quarter, agenda - var label: String { + func label(_ lang: String) -> String { switch self { - case .month: return "Monat" - case .week: return "Woche" - case .day: return "Tag" - case .quarter: return "Quartal" - case .agenda: return "Termine" + case .month: return L10n.t("view.month", lang) + case .week: return L10n.t("view.week", lang) + case .day: return L10n.t("view.day", lang) + case .quarter: return L10n.t("view.quarter", lang) + case .agenda: return L10n.t("view.agenda", lang) } } @@ -221,28 +221,29 @@ class CalendarStore { } } - func titleForCurrentView() -> String { + func titleForCurrentView(language: String) -> String { let cal = userCalendar - let fmt = DateFormatter() + let loc = L10n.locale(language) + let fmt = DateFormatter(); fmt.locale = loc switch viewType { case .month: - fmt.dateFormat = "MMMM yyyy" - return fmt.string(from: currentDate) + fmt.dateFormat = "LLLL yyyy" + return fmt.string(from: currentDate).capitalized(with: loc) case .quarter: - fmt.dateFormat = "MMM yyyy" + fmt.dateFormat = "LLL yyyy" let m3 = cal.date(byAdding: .month, value: 2, to: currentDate) ?? currentDate return "\(fmt.string(from: currentDate)) – \(fmt.string(from: m3))" case .week: let weekStart = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: currentDate))! let weekEnd = cal.date(byAdding: .day, value: 6, to: weekStart)! fmt.dateFormat = "d. MMM" - let ef = DateFormatter(); ef.dateFormat = "d. MMM yyyy" + let ef = DateFormatter(); ef.locale = loc; ef.dateFormat = "d. MMM yyyy" return "\(fmt.string(from: weekStart)) – \(ef.string(from: weekEnd))" case .day: fmt.dateFormat = "EEEE, d. MMMM yyyy" return fmt.string(from: currentDate) case .agenda: - return "Termine" + return L10n.t("view.agenda", language) } } } diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift new file mode 100644 index 0000000..5841fad --- /dev/null +++ b/Calendarr iOS/Models/Localization.swift @@ -0,0 +1,520 @@ +import Foundation +import SwiftUI + +enum AppLanguage: String, CaseIterable { + case system, de, en + + var displayKey: String { + switch self { + case .system: return "lang.system" + case .de: return "lang.german" + case .en: return "lang.english" + } + } +} + +enum L10n { + static func resolved(_ stored: String) -> String { + if stored == "de" || stored == "en" { return stored } + let pref = Locale.preferredLanguages.first ?? "en" + return pref.lowercased().hasPrefix("de") ? "de" : "en" + } + + static func t(_ key: String, _ stored: String) -> String { + let lang = resolved(stored) + return strings[lang]?[key] ?? strings["en"]?[key] ?? key + } + + static func locale(_ stored: String) -> Locale { + Locale(identifier: resolved(stored)) + } +} + +private let strings: [String: [String: String]] = [ + "de": [ + // Top bar / navigation + "nav.today": "Heute", + "nav.menu": "Menü", + "nav.done": "Fertig", + + // View types + "view.month": "Monat", + "view.week": "Woche", + "view.day": "Tag", + "view.quarter": "Quartal", + "view.agenda": "Termine", + "view.change": "Ansicht", + + // Calendar misc + "cal.cw": "KW", + "cal.allday": "Ganztägig", + "cal.no_events_title": "Keine Termine", + "cal.no_events_body": "In den nächsten 90 Tagen sind keine Termine vorhanden.", + "cal.loading_more": "Lade weitere Wochen…", + "cal.new_event": "Neues Ereignis", + "cal.show_in_day_view": "In Tagesansicht öffnen", + "cal.show_in_week_view": "In Wochenansicht öffnen", + "cal.show_in_month_view": "In Monatsansicht öffnen", + + // Menu sheet + "menu.section.settings": "Einstellungen", + "menu.profile": "Profil", + "menu.appearance": "Darstellung", + "menu.accounts": "Konten & Kalender", + "menu.server": "Server", + "menu.logout": "Abmelden", + "menu.admin": "Admin", + + // Settings – chrome + "settings.title": "Darstellung", + "settings.loading": "Lade Einstellungen…", + "settings.save": "Speichern", + "settings.saved": "Gespeichert", + + // Settings – sections + "settings.appdesign": "App-Design", + "settings.liquidglass": "Liquid Glass", + "settings.liquidglass.desc": "Verwendet die neue iOS\u{202F}26 Glasoptik mit transparenter Navigationsleiste", + "settings.liquidglass.footer": "Änderung wirkt sofort – kein Neustart nötig.", + + "settings.cache.header": "Vorladen", + "settings.cache.title": "Vorladen", + "settings.cache.desc": "Events werden beim Start im Hintergrund für diesen Zeitraum geladen, danach ist Wischen sofort.", + "settings.cache.range": "Zeitraum", + "settings.cache.1m": "±1 Monat", + "settings.cache.3m": "±3 Monate", + "settings.cache.6m": "±6 Monate", + "settings.cache.1y": "±1 Jahr", + "settings.cache.footer": "Mehr Monate = längerer initialer Ladevorgang, danach komplett ohne Wartezeiten navigierbar.", + + "settings.language": "Sprache", + "lang.system": "Systemstandard", + "lang.german": "Deutsch", + "lang.english": "English", + + "settings.colors": "Farben", + "settings.color.primary": "Primärfarbe", + "settings.color.accent": "Akzentfarbe", + "settings.color.today": "Heutige-Tag-Farbe", + "settings.color.divider": "Monatswechsel-Linie", + "settings.color.label": "Monatskürzel", + "settings.color.text": "Schriftfarbe", + "settings.color.background": "Hintergrundfarbe", + "settings.color.line": "Linienfarbe", + + "settings.textcontrast": "Schriftkontrast", + "settings.textcontrast.desc": "Helligkeit der Beschriftungen und Texte", + "settings.contrast.dark": "Dunkel", + "settings.contrast.medium": "Mittel", + "settings.contrast.bright": "Hell", + "settings.contrast.max": "Maximum", + + "settings.linecontrast": "Linienkontrast", + "settings.linecontrast.desc": "Sichtbarkeit von Trennlinien und Rahmen", + "settings.linecontrast.barely": "Kaum", + "settings.linecontrast.subtle": "Subtil", + "settings.linecontrast.normal": "Normal", + "settings.linecontrast.strong": "Stark", + + "settings.calview": "Kalenderansicht", + "settings.defaultview": "Standardansicht", + "settings.firstweekday": "Erster Wochentag", + "settings.monday": "Montag", + "settings.sunday": "Sonntag", + "settings.dimpast": "Vergangene Termine ausgrauen", + + "settings.hourheight": "Stundenhöhe", + "settings.hourheight.desc": "Platz pro Stunde in der Wochen- & Tagesansicht", + "settings.hourheight.compact": "Kompakt", + "settings.hourheight.normal": "Normal", + "settings.hourheight.comfort": "Komfort", + "settings.hourheight.large": "Gross", + + // Common buttons + "common.cancel": "Abbrechen", + "common.close": "Schliessen", + "common.ok": "OK", + "common.error": "Fehler", + + // Server view + "server.title": "Server", + "server.connected": "Verbundener Server", + "server.switch": "Server wechseln", + "server.switch_msg": "Verbindung zu %@ wird getrennt und alle lokalen Anmeldedaten werden gelöscht.", + "server.logout_title": "Abmelden", + "server.logout_msg": "Du wirst von %@ abgemeldet.", + "server.info": "Info", + "server.imprint": "Impressum", + "server.version": "Version", + + // Imprint + "imprint.company": "Scarriffleservices", + "imprint.role": "Software & Webentwicklung", + "imprint.copyright": "Diese Software wurde von Scarriffleservices mit grösster Sorgfalt entwickelt und bereitgestellt. Alle Rechte vorbehalten © 2026 Scarriffleservices.", + "imprint.storage.title": "Datenspeicherung", + "imprint.storage.body": "Alle Anwendungsdaten werden auf dem Server gespeichert und verarbeitet, auf dem diese Calendarr-Instanz betrieben wird. Der Speicherort hängt damit vom Betreiber des jeweiligen Servers ab. Bei Nutzung der Google Kalender-Anbindung werden Daten über die Google API ausgetauscht; für diese Daten gelten die Datenschutzbestimmungen von Google. Bei Nutzung der Home Assistant-Anbindung werden Daten mit der jeweiligen Home Assistant-Instanz ausgetauscht. Home Assistant ist ein Projekt der Open Home Foundation.", + "imprint.disclaimer.title": "Haftungsausschluss", + "imprint.disclaimer.body": "Trotz sorgfältiger Erstellung wird keine Haftung für die Richtigkeit, Vollständigkeit oder Aktualität der bereitgestellten Inhalte übernommen. Die Nutzung erfolgt auf eigene Verantwortung.", + "imprint.contact.title": "Kontakt", + + // Profile view + "profile.title": "Profil", + "profile.loading": "Lade Profil…", + "profile.account": "Konto", + "profile.username": "Benutzername", + "profile.role": "Rolle", + "profile.role.admin": "Administrator", + "profile.role.user": "Benutzer", + "profile.email": "E-Mail", + "profile.no_email": "Keine E-Mail", + "profile.save_email": "E-Mail speichern", + "profile.email_saved": "E-Mail gespeichert", + "profile.change_password": "Passwort ändern", + "profile.current_password": "Aktuelles Passwort", + "profile.new_password": "Neues Passwort", + "profile.new_password_repeat": "Neues Passwort wiederholen", + "profile.password_mismatch": "Passwörter stimmen nicht überein", + "profile.password_changed": "Passwort geändert", + "profile.twofa": "Zwei-Faktor-Authentifizierung", + "profile.twofa.active": "2FA ist aktiviert", + "profile.twofa.inactive": "2FA ist deaktiviert", + "profile.twofa.enable": "2FA einrichten", + "profile.twofa.disable": "2FA deaktivieren", + "profile.twofa.enabled_toast": "2FA aktiviert", + "profile.twofa.disabled_toast": "2FA deaktiviert", + "twofa.setup_title": "2FA einrichten", + "twofa.scan_hint": "Scanne den QR-Code mit deiner Authenticator-App (z.B. Bitwarden, Google Authenticator).", + "twofa.qr_section": "QR-Code / Manueller Schlüssel", + "twofa.confirmation": "Bestätigung", + "twofa.code_placeholder": "6-stelliger Code", + "twofa.activate": "Aktivieren", + "twofa.disable_title": "2FA deaktivieren", + "twofa.password_section": "Passwort zum Deaktivieren", + "twofa.password_placeholder": "Passwort", + "twofa.disable": "Deaktivieren", + + // Event editor + "event.title_placeholder": "Titel", + "event.allday": "Ganztägig", + "event.start": "Start", + "event.end": "Ende", + "event.location": "Ort", + "event.description": "Beschreibung", + "event.calendar_section": "Kalender", + "event.no_writable": "Keine beschreibbaren Kalender vorhanden", + "event.calendar_picker": "Kalender", + "event.color_section": "Farbe", + "event.color": "Terminfarbe", + "event.reset_color": "Zurücksetzen", + "event.edit_title": "Termin bearbeiten", + "event.new_title": "Neuer Termin", + "event.save": "Sichern", + "event.add": "Hinzufügen", + + // Accounts + "accounts.title": "Konten", + "accounts.loading": "Lade Konten…", + "accounts.add.caldav": "CalDAV-Konto", + "accounts.add.local": "Lokaler Kalender", + "accounts.add.ical": "iCal-URL abonnieren", + "accounts.add.ha": "Home Assistant", + "accounts.caldav.header": "CalDAV-Konten", + "accounts.caldav.empty": "Keine CalDAV-Konten", + "accounts.caldav.add": "CalDAV hinzufügen", + "accounts.local.header": "Lokale Kalender", + "accounts.local.empty": "Keine lokalen Kalender", + "accounts.local.add": "Lokalen Kalender erstellen", + "accounts.ical.header": "iCal-Abonnements", + "accounts.ical.empty": "Keine Abonnements", + "accounts.ical.every": "Alle %d Min.", + "accounts.ical.add": "iCal-URL abonnieren", + "accounts.google.header": "Google-Konten", + "accounts.google.empty": "Keine Google-Konten", + "accounts.google.hint": "Google-Konten werden über den Browser verknüpft", + "accounts.ha.header": "Home Assistant", + "accounts.ha.empty": "Keine Home Assistant-Konten", + "accounts.ha.add": "Home Assistant hinzufügen", + + // CalDAV add sheet + "caldav.section": "Konto-Details", + "caldav.display_name": "Anzeigename", + "caldav.url": "CalDAV-URL", + "caldav.username": "Benutzername", + "caldav.password": "Passwort", + "caldav.color": "Farbe", + "caldav.color_label": "Farbe", + "caldav.color_section": "Farbe", + "caldav.connect": "Verbinden", + "caldav.title": "CalDAV-Konto", + + // Local cal add sheet + "local.title": "Lokaler Kalender", + "local.name": "Name", + "local.color": "Farbe", + "local.create": "Erstellen", + + // iCal add sheet + "ical.title": "iCal abonnieren", + "ical.subscription": "Abonnement", + "ical.name": "Name", + "ical.url": "iCal-URL", + "ical.color": "Farbe", + "ical.refresh_section": "Aktualisierung", + "ical.interval": "Intervall", + "ical.subscribe": "Abonnieren", + "ical.refresh.15m": "Alle 15 Min.", + "ical.refresh.30m": "Alle 30 Min.", + "ical.refresh.1h": "Stündlich", + "ical.refresh.6h": "Alle 6 Std.", + "ical.refresh.1d": "Täglich", + + // HA add sheet + "ha.section": "Home Assistant", + "ha.display_name": "Anzeigename", + "ha.url_placeholder": "URL (z.B. http://homeassistant.local:8123)", + "ha.auth_section": "Authentifizierung", + "ha.token": "Long-Lived Access Token", + "ha.token_hint": "Token erstellen unter: Profil → Sicherheit → Long-Lived Access Tokens", + "ha.connect": "Verbinden" + ], + "en": [ + "nav.today": "Today", + "nav.menu": "Menu", + "nav.done": "Done", + + "view.month": "Month", + "view.week": "Week", + "view.day": "Day", + "view.quarter": "Quarter", + "view.agenda": "Agenda", + "view.change": "View", + + "cal.cw": "W", + "cal.allday": "All-day", + "cal.no_events_title": "No events", + "cal.no_events_body": "No events in the next 90 days.", + "cal.loading_more": "Loading more weeks…", + "cal.new_event": "New event", + "cal.show_in_day_view": "Open in day view", + "cal.show_in_week_view": "Open in week view", + "cal.show_in_month_view": "Open in month view", + + "menu.section.settings": "Settings", + "menu.profile": "Profile", + "menu.appearance": "Appearance", + "menu.accounts": "Accounts & Calendars", + "menu.server": "Server", + "menu.logout": "Sign out", + "menu.admin": "Admin", + + "settings.title": "Appearance", + "settings.loading": "Loading settings…", + "settings.save": "Save", + "settings.saved": "Saved", + + "settings.appdesign": "App design", + "settings.liquidglass": "Liquid Glass", + "settings.liquidglass.desc": "Uses the new iOS\u{202F}26 glass look with a translucent navigation bar", + "settings.liquidglass.footer": "Takes effect immediately – no restart required.", + + "settings.cache.header": "Preloading", + "settings.cache.title": "Preloading", + "settings.cache.desc": "Events are loaded in the background for this range on launch, so swiping is instant afterwards.", + "settings.cache.range": "Range", + "settings.cache.1m": "±1 month", + "settings.cache.3m": "±3 months", + "settings.cache.6m": "±6 months", + "settings.cache.1y": "±1 year", + "settings.cache.footer": "More months = longer initial load, but then fully wait-free navigation.", + + "settings.language": "Language", + "lang.system": "System default", + "lang.german": "Deutsch", + "lang.english": "English", + + "settings.colors": "Colors", + "settings.color.primary": "Primary color", + "settings.color.accent": "Accent color", + "settings.color.today": "Today color", + "settings.color.divider": "Month divider line", + "settings.color.label": "Month abbreviation", + "settings.color.text": "Text color", + "settings.color.background": "Background color", + "settings.color.line": "Line color", + + "settings.textcontrast": "Text contrast", + "settings.textcontrast.desc": "Brightness of labels and text", + "settings.contrast.dark": "Dark", + "settings.contrast.medium": "Medium", + "settings.contrast.bright": "Bright", + "settings.contrast.max": "Maximum", + + "settings.linecontrast": "Line contrast", + "settings.linecontrast.desc": "Visibility of dividers and borders", + "settings.linecontrast.barely": "Barely", + "settings.linecontrast.subtle": "Subtle", + "settings.linecontrast.normal": "Normal", + "settings.linecontrast.strong": "Strong", + + "settings.calview": "Calendar view", + "settings.defaultview": "Default view", + "settings.firstweekday": "First day of week", + "settings.monday": "Monday", + "settings.sunday": "Sunday", + "settings.dimpast": "Dim past events", + + "settings.hourheight": "Hour height", + "settings.hourheight.desc": "Space per hour in week & day view", + "settings.hourheight.compact": "Compact", + "settings.hourheight.normal": "Normal", + "settings.hourheight.comfort": "Comfort", + "settings.hourheight.large": "Large", + + // Common buttons + "common.cancel": "Cancel", + "common.close": "Close", + "common.ok": "OK", + "common.error": "Error", + + // Server view + "server.title": "Server", + "server.connected": "Connected server", + "server.switch": "Switch server", + "server.switch_msg": "The connection to %@ will be closed and all local credentials will be removed.", + "server.logout_title": "Sign out", + "server.logout_msg": "You will be signed out of %@.", + "server.info": "Info", + "server.imprint": "Legal notice", + "server.version": "Version", + + // Imprint + "imprint.company": "Scarriffleservices", + "imprint.role": "Software & web development", + "imprint.copyright": "This software was carefully developed and provided by Scarriffleservices. All rights reserved © 2026 Scarriffleservices.", + "imprint.storage.title": "Data storage", + "imprint.storage.body": "All application data is stored and processed on the server on which this Calendarr instance is hosted. The storage location therefore depends on the operator of the respective server. When using the Google Calendar integration, data is exchanged via the Google API; Google's privacy policy applies to that data. When using the Home Assistant integration, data is exchanged with the respective Home Assistant instance. Home Assistant is a project of the Open Home Foundation.", + "imprint.disclaimer.title": "Disclaimer", + "imprint.disclaimer.body": "Despite careful preparation, no liability is assumed for the accuracy, completeness or topicality of the content provided. Use is at your own risk.", + "imprint.contact.title": "Contact", + + // Profile view + "profile.title": "Profile", + "profile.loading": "Loading profile…", + "profile.account": "Account", + "profile.username": "Username", + "profile.role": "Role", + "profile.role.admin": "Administrator", + "profile.role.user": "User", + "profile.email": "Email", + "profile.no_email": "No email", + "profile.save_email": "Save email", + "profile.email_saved": "Email saved", + "profile.change_password": "Change password", + "profile.current_password": "Current password", + "profile.new_password": "New password", + "profile.new_password_repeat": "Repeat new password", + "profile.password_mismatch": "Passwords don't match", + "profile.password_changed": "Password changed", + "profile.twofa": "Two-factor authentication", + "profile.twofa.active": "2FA is enabled", + "profile.twofa.inactive": "2FA is disabled", + "profile.twofa.enable": "Set up 2FA", + "profile.twofa.disable": "Disable 2FA", + "profile.twofa.enabled_toast": "2FA enabled", + "profile.twofa.disabled_toast": "2FA disabled", + "twofa.setup_title": "Set up 2FA", + "twofa.scan_hint": "Scan the QR code with your authenticator app (e.g. Bitwarden, Google Authenticator).", + "twofa.qr_section": "QR code / Manual key", + "twofa.confirmation": "Verification", + "twofa.code_placeholder": "6-digit code", + "twofa.activate": "Activate", + "twofa.disable_title": "Disable 2FA", + "twofa.password_section": "Password to disable", + "twofa.password_placeholder": "Password", + "twofa.disable": "Disable", + + // Event editor + "event.title_placeholder": "Title", + "event.allday": "All-day", + "event.start": "Start", + "event.end": "End", + "event.location": "Location", + "event.description": "Description", + "event.calendar_section": "Calendar", + "event.no_writable": "No writable calendars available", + "event.calendar_picker": "Calendar", + "event.color_section": "Color", + "event.color": "Event color", + "event.reset_color": "Reset", + "event.edit_title": "Edit event", + "event.new_title": "New event", + "event.save": "Save", + "event.add": "Add", + + // Accounts + "accounts.title": "Accounts", + "accounts.loading": "Loading accounts…", + "accounts.add.caldav": "CalDAV account", + "accounts.add.local": "Local calendar", + "accounts.add.ical": "Subscribe to iCal URL", + "accounts.add.ha": "Home Assistant", + "accounts.caldav.header": "CalDAV accounts", + "accounts.caldav.empty": "No CalDAV accounts", + "accounts.caldav.add": "Add CalDAV", + "accounts.local.header": "Local calendars", + "accounts.local.empty": "No local calendars", + "accounts.local.add": "Create local calendar", + "accounts.ical.header": "iCal subscriptions", + "accounts.ical.empty": "No subscriptions", + "accounts.ical.every": "Every %d min", + "accounts.ical.add": "Subscribe to iCal URL", + "accounts.google.header": "Google accounts", + "accounts.google.empty": "No Google accounts", + "accounts.google.hint": "Google accounts are linked via the browser", + "accounts.ha.header": "Home Assistant", + "accounts.ha.empty": "No Home Assistant accounts", + "accounts.ha.add": "Add Home Assistant", + + // CalDAV add sheet + "caldav.section": "Account details", + "caldav.display_name": "Display name", + "caldav.url": "CalDAV URL", + "caldav.username": "Username", + "caldav.password": "Password", + "caldav.color": "Color", + "caldav.color_label": "Color", + "caldav.color_section": "Color", + "caldav.connect": "Connect", + "caldav.title": "CalDAV account", + + // Local cal add sheet + "local.title": "Local calendar", + "local.name": "Name", + "local.color": "Color", + "local.create": "Create", + + // iCal add sheet + "ical.title": "Subscribe to iCal", + "ical.subscription": "Subscription", + "ical.name": "Name", + "ical.url": "iCal URL", + "ical.color": "Color", + "ical.refresh_section": "Refresh", + "ical.interval": "Interval", + "ical.subscribe": "Subscribe", + "ical.refresh.15m": "Every 15 min", + "ical.refresh.30m": "Every 30 min", + "ical.refresh.1h": "Hourly", + "ical.refresh.6h": "Every 6 hours", + "ical.refresh.1d": "Daily", + + // HA add sheet + "ha.section": "Home Assistant", + "ha.display_name": "Display name", + "ha.url_placeholder": "URL (e.g. http://homeassistant.local:8123)", + "ha.auth_section": "Authentication", + "ha.token": "Long-Lived Access Token", + "ha.token_hint": "Create a token under: Profile → Security → Long-Lived Access Tokens", + "ha.connect": "Connect" + ] +] diff --git a/Calendarr iOS/Views/AccountsView.swift b/Calendarr iOS/Views/AccountsView.swift index 874b05d..9e9bf35 100644 --- a/Calendarr iOS/Views/AccountsView.swift +++ b/Calendarr iOS/Views/AccountsView.swift @@ -15,11 +15,13 @@ struct AccountsView: View { @State private var showAddHA = false @State private var errorAlert: String? + @AppStorage("appLanguage") private var appLang = "system" + var body: some View { NavigationStack { Group { if isLoading { - ProgressView("Lade Konten…") + ProgressView(L10n.t("accounts.loading", appLang)) } else { List { caldavSection @@ -30,15 +32,15 @@ struct AccountsView: View { } } } - .navigationTitle("Konten") + .navigationTitle(L10n.t("accounts.title", appLang)) .navigationBarTitleDisplayMode(.large) .toolbar { ToolbarItem(placement: .primaryAction) { Menu { - Button("CalDAV-Konto") { showAddCalDAV = true } - Button("Lokaler Kalender") { showAddLocal = true } - Button("iCal-URL abonnieren") { showAddICal = true } - Button("Home Assistant") { showAddHA = true } + Button(L10n.t("accounts.add.caldav", appLang)) { showAddCalDAV = true } + Button(L10n.t("accounts.add.local", appLang)) { showAddLocal = true } + Button(L10n.t("accounts.add.ical", appLang)) { showAddICal = true } + Button(L10n.t("accounts.add.ha", appLang)) { showAddHA = true } } label: { Image(systemName: "plus") } @@ -56,8 +58,8 @@ struct AccountsView: View { .sheet(isPresented: $showAddHA) { AddHASheet(api: api) { await load() } } - .alert("Fehler", isPresented: .constant(errorAlert != nil), actions: { - Button("OK") { errorAlert = nil } + .alert(L10n.t("common.error", appLang), isPresented: .constant(errorAlert != nil), actions: { + Button(L10n.t("common.ok", appLang)) { errorAlert = nil } }, message: { Text(errorAlert ?? "") }) @@ -70,7 +72,7 @@ struct AccountsView: View { var caldavSection: some View { Section { if caldavAccounts.isEmpty { - Text("Keine CalDAV-Konten") + Text(L10n.t("accounts.caldav.empty", appLang)) .foregroundStyle(.secondary) } else { ForEach(caldavAccounts) { acc in @@ -91,17 +93,17 @@ struct AccountsView: View { Task { await deleteCalDAV(offsets: offsets) } } } - Button("CalDAV hinzufügen") { showAddCalDAV = true } + Button(L10n.t("accounts.caldav.add", appLang)) { showAddCalDAV = true } .foregroundStyle(Color.accentColor) } header: { - Text("CalDAV-Konten") + Text(L10n.t("accounts.caldav.header", appLang)) } } var localSection: some View { Section { if localCalendars.isEmpty { - Text("Keine lokalen Kalender") + Text(L10n.t("accounts.local.empty", appLang)) .foregroundStyle(.secondary) } else { ForEach(localCalendars) { cal in @@ -116,17 +118,17 @@ struct AccountsView: View { Task { await deleteLocal(offsets: offsets) } } } - Button("Lokalen Kalender erstellen") { showAddLocal = true } + Button(L10n.t("accounts.local.add", appLang)) { showAddLocal = true } .foregroundStyle(Color.accentColor) } header: { - Text("Lokale Kalender") + Text(L10n.t("accounts.local.header", appLang)) } } var icalSection: some View { Section { if icalSubs.isEmpty { - Text("Keine Abonnements") + Text(L10n.t("accounts.ical.empty", appLang)) .foregroundStyle(.secondary) } else { ForEach(icalSubs) { sub in @@ -136,7 +138,7 @@ struct AccountsView: View { .frame(width: 12, height: 12) VStack(alignment: .leading, spacing: 2) { Text(sub.name).font(.body) - Text("Alle \(sub.refreshMinutes) Min.") + Text(String(format: L10n.t("accounts.ical.every", appLang), sub.refreshMinutes)) .font(.caption) .foregroundStyle(.secondary) } @@ -146,17 +148,17 @@ struct AccountsView: View { Task { await deleteICal(offsets: offsets) } } } - Button("iCal-URL abonnieren") { showAddICal = true } + Button(L10n.t("accounts.ical.add", appLang)) { showAddICal = true } .foregroundStyle(Color.accentColor) } header: { - Text("iCal-Abonnements") + Text(L10n.t("accounts.ical.header", appLang)) } } var googleSection: some View { Section { if googleAccounts.isEmpty { - Text("Keine Google-Konten") + Text(L10n.t("accounts.google.empty", appLang)) .foregroundStyle(.secondary) } else { ForEach(googleAccounts) { acc in @@ -170,18 +172,18 @@ struct AccountsView: View { Task { await deleteGoogle(offsets: offsets) } } } - Text("Google-Konten werden über den Browser verknüpft") + Text(L10n.t("accounts.google.hint", appLang)) .font(.caption) .foregroundStyle(.secondary) } header: { - Text("Google-Konten") + Text(L10n.t("accounts.google.header", appLang)) } } var haSection: some View { Section { if haAccounts.isEmpty { - Text("Keine Home Assistant-Konten") + Text(L10n.t("accounts.ha.empty", appLang)) .foregroundStyle(.secondary) } else { ForEach(haAccounts) { acc in @@ -197,10 +199,10 @@ struct AccountsView: View { Task { await deleteHA(offsets: offsets) } } } - Button("Home Assistant hinzufügen") { showAddHA = true } + Button(L10n.t("accounts.ha.add", appLang)) { showAddHA = true } .foregroundStyle(Color.accentColor) } header: { - Text("Home Assistant") + Text(L10n.t("accounts.ha.header", appLang)) } } @@ -259,6 +261,7 @@ struct AddCalDAVSheet: View { let api: CalendarrAPI let onDone: () async -> Void @Environment(\.dismiss) var dismiss + @AppStorage("appLanguage") private var appLang = "system" @State private var name = "" @State private var url = "" @@ -271,19 +274,19 @@ struct AddCalDAVSheet: View { var body: some View { NavigationStack { Form { - Section("Konto-Details") { - TextField("Anzeigename", text: $name) - TextField("CalDAV-URL", text: $url) + Section(L10n.t("caldav.section", appLang)) { + TextField(L10n.t("caldav.display_name", appLang), text: $name) + TextField(L10n.t("caldav.url", appLang), text: $url) .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.URL) - TextField("Benutzername", text: $username) + TextField(L10n.t("caldav.username", appLang), text: $username) .textInputAutocapitalization(.never) .autocorrectionDisabled() - SecureField("Passwort", text: $password) + SecureField(L10n.t("caldav.password", appLang), text: $password) } - Section("Farbe") { - ColorPicker("Farbe", selection: $color, supportsOpacity: false) + Section(L10n.t("caldav.color_section", appLang)) { + ColorPicker(L10n.t("caldav.color_label", appLang), selection: $color, supportsOpacity: false) } if !error.isEmpty { Section { @@ -291,14 +294,14 @@ struct AddCalDAVSheet: View { } } } - .navigationTitle("CalDAV-Konto") + .navigationTitle(L10n.t("caldav.title", appLang)) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button("Abbrechen") { dismiss() } + Button(L10n.t("common.cancel", appLang)) { dismiss() } } ToolbarItem(placement: .primaryAction) { - Button("Verbinden") { + Button(L10n.t("caldav.connect", appLang)) { Task { await save() } } .bold() @@ -326,6 +329,7 @@ struct AddLocalCalSheet: View { let api: CalendarrAPI let onDone: () async -> Void @Environment(\.dismiss) var dismiss + @AppStorage("appLanguage") private var appLang = "system" @State private var name = "" @State private var color = Color(hex: "#34a853") @@ -336,19 +340,19 @@ struct AddLocalCalSheet: View { NavigationStack { Form { Section { - TextField("Name", text: $name) - ColorPicker("Farbe", selection: $color, supportsOpacity: false) + TextField(L10n.t("local.name", appLang), text: $name) + ColorPicker(L10n.t("local.color", appLang), selection: $color, supportsOpacity: false) } if !error.isEmpty { Section { Text(error).foregroundStyle(.red) } } } - .navigationTitle("Lokaler Kalender") + .navigationTitle(L10n.t("local.title", appLang)) .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } } + ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } } ToolbarItem(placement: .primaryAction) { - Button("Erstellen") { + Button(L10n.t("local.create", appLang)) { Task { await save() } } .bold() @@ -373,6 +377,7 @@ struct AddICalSheet: View { let api: CalendarrAPI let onDone: () async -> Void @Environment(\.dismiss) var dismiss + @AppStorage("appLanguage") private var appLang = "system" @State private var name = "" @State private var url = "" @@ -381,21 +386,27 @@ struct AddICalSheet: View { @State private var isLoading = false @State private var error = "" - let refreshOptions = [(15, "Alle 15 Min."), (30, "Alle 30 Min."), (60, "Stündlich"), (360, "Alle 6 Std."), (1440, "Täglich")] + private var refreshOptions: [(Int, String)] { + [(15, L10n.t("ical.refresh.15m", appLang)), + (30, L10n.t("ical.refresh.30m", appLang)), + (60, L10n.t("ical.refresh.1h", appLang)), + (360, L10n.t("ical.refresh.6h", appLang)), + (1440, L10n.t("ical.refresh.1d", appLang))] + } var body: some View { NavigationStack { Form { - Section("Abonnement") { - TextField("Name", text: $name) - TextField("iCal-URL", text: $url) + Section(L10n.t("ical.subscription", appLang)) { + TextField(L10n.t("ical.name", appLang), text: $name) + TextField(L10n.t("ical.url", appLang), text: $url) .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.URL) - ColorPicker("Farbe", selection: $color, supportsOpacity: false) + ColorPicker(L10n.t("ical.color", appLang), selection: $color, supportsOpacity: false) } - Section("Aktualisierung") { - Picker("Intervall", selection: $refreshMinutes) { + Section(L10n.t("ical.refresh_section", appLang)) { + Picker(L10n.t("ical.interval", appLang), selection: $refreshMinutes) { ForEach(refreshOptions, id: \.0) { opt in Text(opt.1).tag(opt.0) } @@ -405,12 +416,12 @@ struct AddICalSheet: View { Section { Text(error).foregroundStyle(.red) } } } - .navigationTitle("iCal abonnieren") + .navigationTitle(L10n.t("ical.title", appLang)) .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } } + ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } } ToolbarItem(placement: .primaryAction) { - Button("Abonnieren") { + Button(L10n.t("ical.subscribe", appLang)) { Task { await save() } } .bold() @@ -435,6 +446,7 @@ struct AddHASheet: View { let api: CalendarrAPI let onDone: () async -> Void @Environment(\.dismiss) var dismiss + @AppStorage("appLanguage") private var appLang = "system" @State private var name = "" @State private var url = "" @@ -445,16 +457,16 @@ struct AddHASheet: View { var body: some View { NavigationStack { Form { - Section("Home Assistant") { - TextField("Anzeigename", text: $name) - TextField("URL (z.B. http://homeassistant.local:8123)", text: $url) + Section(L10n.t("ha.section", appLang)) { + TextField(L10n.t("ha.display_name", appLang), text: $name) + TextField(L10n.t("ha.url_placeholder", appLang), text: $url) .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.URL) } - Section("Authentifizierung") { - SecureField("Long-Lived Access Token", text: $token) - Text("Token erstellen unter: Profil → Sicherheit → Long-Lived Access Tokens") + Section(L10n.t("ha.auth_section", appLang)) { + SecureField(L10n.t("ha.token", appLang), text: $token) + Text(L10n.t("ha.token_hint", appLang)) .font(.caption) .foregroundStyle(.secondary) } @@ -462,12 +474,12 @@ struct AddHASheet: View { Section { Text(error).foregroundStyle(.red) } } } - .navigationTitle("Home Assistant") + .navigationTitle(L10n.t("accounts.add.ha", appLang)) .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } } + ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } } ToolbarItem(placement: .primaryAction) { - Button("Verbinden") { + Button(L10n.t("ha.connect", appLang)) { Task { await save() } } .bold() diff --git a/Calendarr iOS/Views/Calendar/AgendaView.swift b/Calendarr iOS/Views/Calendar/AgendaView.swift index 5230bfb..081650d 100644 --- a/Calendarr iOS/Views/Calendar/AgendaView.swift +++ b/Calendarr iOS/Views/Calendar/AgendaView.swift @@ -3,6 +3,7 @@ import SwiftUI struct AgendaView: View { let store: CalendarStore let onEventTap: (CalEvent) -> Void + @AppStorage("appLanguage") private var appLang = "system" private var cal: Calendar { store.userCalendar } @@ -17,25 +18,27 @@ struct AgendaView: View { return dict.keys.sorted().map { ($0, dict[$0]!.sorted { $0.startDate < $1.startDate }) } } - private let dayFmt: DateFormatter = { + private var dayFmt: DateFormatter { let f = DateFormatter() + f.locale = L10n.locale(appLang) f.dateFormat = "EEEE, d. MMMM yyyy" return f - }() + } - private let timeFmt: DateFormatter = { + private var timeFmt: DateFormatter { let f = DateFormatter() + f.locale = L10n.locale(appLang) f.timeStyle = .short f.dateStyle = .none return f - }() + } var body: some View { if grouped.isEmpty { ContentUnavailableView( - "Keine Termine", + L10n.t("cal.no_events_title", appLang), systemImage: "calendar", - description: Text("In den nächsten 90 Tagen sind keine Termine vorhanden.") + description: Text(L10n.t("cal.no_events_body", appLang)) ) } else { List { @@ -43,7 +46,7 @@ struct AgendaView: View { Section { ForEach(evs) { ev in Button { onEventTap(ev) } label: { - AgendaEventRow(event: ev, timeFmt: timeFmt) + AgendaEventRow(event: ev, timeFmt: timeFmt, allDayLabel: L10n.t("cal.allday", appLang)) } .buttonStyle(.plain) } @@ -62,9 +65,10 @@ struct AgendaView: View { private struct AgendaEventRow: View { let event: CalEvent let timeFmt: DateFormatter + let allDayLabel: String var timeString: String { - if event.isAllDay { return "Ganztägig" } + if event.isAllDay { return allDayLabel } return timeFmt.string(from: event.startDate) } diff --git a/Calendarr iOS/Views/Calendar/CalendarHostView.swift b/Calendarr iOS/Views/Calendar/CalendarHostView.swift index f91ddff..3fb638e 100644 --- a/Calendarr iOS/Views/Calendar/CalendarHostView.swift +++ b/Calendarr iOS/Views/Calendar/CalendarHostView.swift @@ -6,12 +6,25 @@ struct CalendarHostView: View { @AppStorage("liquidGlass") private var liquidGlass = false @AppStorage("cacheMonths") private var cacheMonths = 3 + @AppStorage("appLanguage") private var appLang = "system" + @AppStorage("backgroundColor") private var bgHex = "#000000" @State private var store = CalendarStore() @State private var showEditor = false @State private var editorDate: Date = .now @State private var editingEvent: CalEvent? = nil @State private var selectedEvent: CalEvent? = nil + @State private var visibleMonth: Date = .now + + private var titleString: String { + if store.viewType == .month { + let f = DateFormatter() + f.locale = L10n.locale(appLang) + f.dateFormat = "LLLL yyyy" + return f.string(from: visibleMonth).capitalized(with: L10n.locale(appLang)) + } + return store.titleForCurrentView(language: appLang) + } var body: some View { if liquidGlass { @@ -30,6 +43,7 @@ struct CalendarHostView: View { errorBanner calendarContent .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(hex: bgHex)) .overlay(alignment: .top) { if store.isLoading { ProgressView().padding(.top, 10).transition(.opacity) @@ -52,6 +66,7 @@ struct CalendarHostView: View { .onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } } .onChange(of: store.viewType) { _, _ in Task { await onNavigate() } } .onChange(of: cacheMonths) { _, _ in Task { await recache() } } + .onChange(of: visibleMonth) { _, new in Task { await ensureLoaded(around: new) } } } // MARK: – Liquid Glass variant @@ -60,6 +75,7 @@ struct CalendarHostView: View { NavigationStack { calendarContent .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(hex: bgHex)) .overlay(alignment: .top) { if store.isLoading { ProgressView().padding(.top, 10).transition(.opacity) @@ -74,12 +90,20 @@ struct CalendarHostView: View { HStack(spacing: 2) { Button { store.navigatePrev() } label: { Image(systemName: "chevron.left") } Button { store.navigateNext() } label: { Image(systemName: "chevron.right") } - Button("Heute") { store.moveToToday() }.font(.callout) + Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout) } } - ToolbarItem(placement: .principal) { viewPickerMenu } + ToolbarItem(placement: .principal) { + Text(titleString) + .font(.headline) + .lineLimit(1) + .minimumScaleFactor(0.7) + } ToolbarItem(placement: .navigationBarTrailing) { - Button { showMenu = true } label: { Image(systemName: "line.3.horizontal") } + HStack(spacing: 8) { + viewPickerMenu + Button { showMenu = true } label: { Image(systemName: "line.3.horizontal") } + } } } } @@ -89,6 +113,7 @@ struct CalendarHostView: View { .onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } } .onChange(of: store.viewType) { _, _ in Task { await onNavigate() } } .onChange(of: cacheMonths) { _, _ in Task { await recache() } } + .onChange(of: visibleMonth) { _, new in Task { await ensureLoaded(around: new) } } } // MARK: – Top bar (flat mode) @@ -106,17 +131,21 @@ struct CalendarHostView: View { .font(.system(size: 17, weight: .medium)) .frame(width: 36, height: 36) } - Button("Heute") { store.moveToToday() } + Button(L10n.t("nav.today", appLang)) { store.moveToToday() } .font(.callout).padding(.horizontal, 6) } .padding(.leading, 8) - Spacer() + Spacer(minLength: 8) + Text(titleString) + .font(.headline) + .lineLimit(1) + .minimumScaleFactor(0.7) + Spacer(minLength: 8) viewPickerMenu - Spacer() Button { showMenu = true } label: { Image(systemName: "line.3.horizontal") .font(.system(size: 18, weight: .medium)) - .frame(width: 44, height: 44) + .frame(width: 40, height: 40) } .padding(.trailing, 4) } @@ -128,18 +157,16 @@ struct CalendarHostView: View { Menu { ForEach(CalViewType.allCases, id: \.self) { vt in Button { store.viewType = vt } label: { - Label(vt.label, systemImage: vt.systemImage) + Label(vt.label(appLang), systemImage: vt.systemImage) } } } label: { - HStack(spacing: 4) { - Text(store.viewType.label).font(.headline) - Image(systemName: "chevron.down").font(.caption2.weight(.semibold)) - } - .foregroundStyle(.primary) - .padding(.horizontal, 12).padding(.vertical, 7) - .background(.quaternary, in: Capsule()) + Image(systemName: store.viewType.systemImage) + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(.primary) + .frame(width: 40, height: 40) } + .accessibilityLabel(L10n.t("view.change", appLang)) } // MARK: – Error banner @@ -165,24 +192,60 @@ struct CalendarHostView: View { @ViewBuilder private var calendarContent: some View { - let swipe = DragGesture(minimumDistance: 35, coordinateSpace: .global) + let swipe = DragGesture(minimumDistance: 14, coordinateSpace: .local) .onEnded { val in let h = val.translation.width let v = val.translation.height - guard abs(h) > abs(v) * 1.1, abs(h) > 50 else { return } + guard abs(h) > abs(v) * 1.2, abs(h) > 28 else { return } withAnimation(.easeInOut(duration: 0.2)) { if h < 0 { store.navigateNext() } else { store.navigatePrev() } } } switch store.viewType { case .month: - MonthView(store: store, onDayTap: { editorDate = $0 }, onEventTap: { selectedEvent = $0 }) - .simultaneousGesture(swipe) + // Month view uses vertical scroll – no horizontal swipe. + MonthView(store: store, + onDayTap: { editorDate = $0 }, + onEventTap: { selectedEvent = $0 }, + onCreateEvent: { day in + editingEvent = nil + editorDate = day + showEditor = true + }, + onShowWeek: { day in + store.currentDate = day + store.viewType = .week + }, + onShowDay: { day in + store.currentDate = day + store.viewType = .day + }, + visibleMonth: $visibleMonth) case .week: - WeekView(store: store, onEventTap: { selectedEvent = $0 }, onTimeTap: { editorDate = $0 }) + WeekView(store: store, + onEventTap: { selectedEvent = $0 }, + onCreateEvent: { date in + editingEvent = nil + editorDate = date + showEditor = true + }, + onShowMonth: { date in + store.currentDate = date + store.viewType = .month + }, + onShowDay: { date in + store.currentDate = date + store.viewType = .day + }) .simultaneousGesture(swipe) case .day: - DayView(store: store, onEventTap: { selectedEvent = $0 }, onTimeTap: { editorDate = $0 }) + DayView(store: store, + onEventTap: { selectedEvent = $0 }, + onCreateEvent: { date in + editingEvent = nil + editorDate = date + showEditor = true + }) .simultaneousGesture(swipe) case .quarter: QuarterView(store: store, onEventTap: { selectedEvent = $0 }) @@ -263,6 +326,16 @@ struct CalendarHostView: View { store.invalidateCache() await startup() } + + /// Called when the user scrolls into a new month – fetches a ±1 month window + /// around it on demand. `loadEvents` skips the network if cached. + private func ensureLoaded(around month: Date) async { + let cal = store.userCalendar + let monthStart = cal.date(from: cal.dateComponents([.year, .month], from: month)) ?? month + let s = cal.date(byAdding: .month, value: -1, to: monthStart) ?? monthStart + let e = cal.date(byAdding: .month, value: 2, to: monthStart) ?? monthStart + await store.loadEvents(api: api, start: s, end: e) + } } // MARK: – Shared sheet modifier diff --git a/Calendarr iOS/Views/Calendar/DayView.swift b/Calendarr iOS/Views/Calendar/DayView.swift index 630dbe1..7e9c25e 100644 --- a/Calendarr iOS/Views/Calendar/DayView.swift +++ b/Calendarr iOS/Views/Calendar/DayView.swift @@ -3,7 +3,12 @@ import SwiftUI struct DayView: View { let store: CalendarStore let onEventTap: (CalEvent) -> Void - let onTimeTap: (Date) -> Void + let onCreateEvent: (Date) -> Void + + @AppStorage("appLanguage") private var appLang = "system" + @AppStorage("todayColor") private var todayHex = "#4285f4" + @AppStorage("textColor") private var textHex = "#FFFFFF" + @AppStorage("lineColor") private var lineHex = "#3A3A3C" private var cal: Calendar { store.userCalendar } private var allDayEvents: [CalEvent] { store.events(on: store.currentDate).filter(\.isAllDay) } @@ -17,25 +22,18 @@ struct DayView: View { ScrollViewReader { proxy in ScrollView { ZStack(alignment: .topLeading) { - // Background grid + // Background grid with per-hour context menus HStack(alignment: .top, spacing: 0) { timeLabels VStack(spacing: 0) { - ForEach(hours, id: \.self) { _ in - Rectangle() - .fill(Color(.separator).opacity(0.4)) - .frame(height: 0.5) - Color.clear.frame(height: hourHeight - 0.5) + ForEach(hours, id: \.self) { hour in + DayHourSlot(day: store.currentDate, hour: hour, + hourHeight: hourHeight, + language: appLang, + onCreateEvent: onCreateEvent) } } .frame(width: geo.size.width - timeColumnWidth) - .contentShape(Rectangle()) - .onTapGesture { loc in - let h = Int(loc.y / hourHeight) - let m = Int((loc.y.truncatingRemainder(dividingBy: hourHeight)) / hourHeight * 60) - let date = cal.date(bySettingHour: h, minute: m, second: 0, of: store.currentDate) ?? store.currentDate - onTimeTap(date) - } } // Events @@ -52,10 +50,11 @@ struct DayView: View { // Current time if cal.isDateInToday(store.currentDate) { let lineY = nowLineY() + let nowColor = Color(hex: todayHex) HStack(spacing: 0) { Spacer().frame(width: timeColumnWidth - 4) - Circle().fill(Color.red).frame(width: 8, height: 8) - Rectangle().fill(Color.red) + Circle().fill(nowColor).frame(width: 8, height: 8) + Rectangle().fill(nowColor) .frame(width: geo.size.width - timeColumnWidth - 4, height: 1.5) } .offset(y: lineY - 0.75) @@ -98,7 +97,7 @@ struct DayView: View { Color.clear.frame(height: hourHeight) Text(String(format: "%02d:00", h)) .font(.system(size: 10)) - .foregroundStyle(.secondary) + .foregroundStyle(Color(hex: textHex).opacity(0.6)) .offset(y: -6) } } @@ -123,3 +122,31 @@ struct DayView: View { } } } + +// One-hour slot for the single-column day view. +private struct DayHourSlot: View { + let day: Date + let hour: Int + let hourHeight: CGFloat + let language: String + let onCreateEvent: (Date) -> Void + + @AppStorage("lineColor") private var lineHex = "#3A3A3C" + + private var date: Date { + Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day + } + + var body: some View { + VStack(spacing: 0) { + Rectangle().fill(Color(hex: lineHex).opacity(0.4)).frame(height: 0.5) + Color.clear.frame(height: hourHeight - 0.5) + } + .contentShape(Rectangle()) + .contextMenu { + Button { onCreateEvent(date) } label: { + Label(L10n.t("cal.new_event", language), systemImage: "plus") + } + } + } +} diff --git a/Calendarr iOS/Views/Calendar/EventEditorSheet.swift b/Calendarr iOS/Views/Calendar/EventEditorSheet.swift index c3b8117..1cf0f51 100644 --- a/Calendarr iOS/Views/Calendar/EventEditorSheet.swift +++ b/Calendarr iOS/Views/Calendar/EventEditorSheet.swift @@ -8,6 +8,7 @@ struct EventEditorSheet: View { let onSaved: () async -> Void @Environment(\.dismiss) var dismiss + @AppStorage("appLanguage") private var appLang = "system" @State private var title = "" @State private var isAllDay = false @State private var startDate = Date() @@ -29,36 +30,36 @@ struct EventEditorSheet: View { NavigationStack { Form { Section { - TextField("Titel", text: $title) + TextField(L10n.t("event.title_placeholder", appLang), text: $title) .font(.body.weight(.medium)) } Section { - Toggle("Ganztägig", isOn: $isAllDay.animation()) + Toggle(L10n.t("event.allday", appLang), isOn: $isAllDay.animation()) .tint(Color.accentColor) if isAllDay { - DatePicker("Start", selection: $startDate, displayedComponents: .date) - DatePicker("Ende", selection: $endDate, displayedComponents: .date) + DatePicker(L10n.t("event.start", appLang), selection: $startDate, displayedComponents: .date) + DatePicker(L10n.t("event.end", appLang), selection: $endDate, displayedComponents: .date) } else { - DatePicker("Start", selection: $startDate) - DatePicker("Ende", selection: $endDate) + DatePicker(L10n.t("event.start", appLang), selection: $startDate) + DatePicker(L10n.t("event.end", appLang), selection: $endDate) } } Section { - TextField("Ort", text: $location) - TextField("Beschreibung", text: $notes, axis: .vertical) + TextField(L10n.t("event.location", appLang), text: $location) + TextField(L10n.t("event.description", appLang), text: $notes, axis: .vertical) .lineLimit(3...6) } - Section("Kalender") { + Section(L10n.t("event.calendar_section", appLang)) { if store.writableCalendars.isEmpty { - Text("Keine beschreibbaren Kalender vorhanden") + Text(L10n.t("event.no_writable", appLang)) .foregroundStyle(.secondary) .font(.callout) } else { - Picker("Kalender", selection: $selectedCalendarId) { + Picker(L10n.t("event.calendar_picker", appLang), selection: $selectedCalendarId) { ForEach(store.writableCalendars) { cal in HStack { Circle() @@ -72,9 +73,9 @@ struct EventEditorSheet: View { } } - Section("Farbe") { + Section(L10n.t("event.color_section", appLang)) { HStack { - Text("Terminfarbe") + Text(L10n.t("event.color", appLang)) Spacer() ColorPicker("", selection: Binding( get: { Color(hex: color.isEmpty ? (selectedCal?.color ?? "#4285f4") : color) }, @@ -82,7 +83,7 @@ struct EventEditorSheet: View { ), supportsOpacity: false) .labelsHidden() if !color.isEmpty { - Button("Zurücksetzen") { color = "" } + Button(L10n.t("event.reset_color", appLang)) { color = "" } .font(.caption) .foregroundStyle(.secondary) } @@ -95,14 +96,18 @@ struct EventEditorSheet: View { } } } - .navigationTitle(isEditing ? "Termin bearbeiten" : "Neuer Termin") + .navigationTitle(isEditing + ? L10n.t("event.edit_title", appLang) + : L10n.t("event.new_title", appLang)) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button("Abbrechen") { dismiss() } + Button(L10n.t("common.cancel", appLang)) { dismiss() } } ToolbarItem(placement: .primaryAction) { - Button(isEditing ? "Sichern" : "Hinzufügen") { + Button(isEditing + ? L10n.t("event.save", appLang) + : L10n.t("event.add", appLang)) { Task { await save() } } .bold() diff --git a/Calendarr iOS/Views/Calendar/MonthView.swift b/Calendarr iOS/Views/Calendar/MonthView.swift index 4b49812..8413407 100644 --- a/Calendarr iOS/Views/Calendar/MonthView.swift +++ b/Calendarr iOS/Views/Calendar/MonthView.swift @@ -1,150 +1,388 @@ import SwiftUI +private let weeksBack = 104 +private let weeksAhead = 104 +private let weekdayHeaderHeight: CGFloat = 28 +private let dayNumberRowHeight: CGFloat = 22 +private let laneHeight: CGFloat = 16 +private let laneSpacing: CGFloat = 2 +private let maxLanesPerWeek = 5 + +private enum DividerEdge { case none, topHighlight, bottomHighlight } + struct MonthView: View { let store: CalendarStore let onDayTap: (Date) -> Void let onEventTap: (CalEvent) -> Void + let onCreateEvent: (Date) -> Void + let onShowWeek: (Date) -> Void + let onShowDay: (Date) -> Void + @Binding var visibleMonth: Date + + @AppStorage("appLanguage") private var appLang = "system" + @AppStorage("monthDividerColor") private var dividerHex = "#7090c0" + @AppStorage("monthLabelColor") private var labelHex = "#7090c0" + @AppStorage("textColor") private var textHex = "#FFFFFF" + @AppStorage("lineColor") private var lineHex = "#3A3A3C" + + @State private var scrolledWeek: Date? = nil + @State private var didInitialScroll = false private var cal: Calendar { store.userCalendar } - private var monthStart: Date { - cal.date(from: cal.dateComponents([.year, .month], from: store.currentDate))! + private var weekStarts: [Date] { + let today = cal.startOfDay(for: .now) + let thisWeek = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: today))! + return (-weeksBack...weeksAhead).compactMap { + cal.date(byAdding: .weekOfYear, value: $0, to: thisWeek) + } } - private var gridDays: [Date] { - let firstWeekday = cal.firstWeekday - let weekday = cal.component(.weekday, from: monthStart) - let offset = ((weekday - firstWeekday) + 7) % 7 - let gridStart = cal.date(byAdding: .day, value: -offset, to: monthStart)! - return (0..<42).compactMap { cal.date(byAdding: .day, value: $0, to: gridStart) } - } - - private var rowCount: Int { gridDays.count / 7 } // always 6 - private var weekdayHeaders: [String] { - let symbols = cal.shortWeekdaySymbols + let fmt = DateFormatter(); fmt.locale = L10n.locale(appLang) + let symbols = fmt.shortWeekdaySymbols ?? cal.shortWeekdaySymbols let start = cal.firstWeekday - 1 - return (0..<7).map { String(symbols[(start + $0) % 7].prefix(2)) } + return (0..<7).map { i in String(symbols[(start + i) % 7].prefix(2)) } } var body: some View { VStack(spacing: 0) { - // Day-of-week header row (fixed height) - HStack(spacing: 0) { - ForEach(weekdayHeaders, id: \.self) { d in - Text(d) - .font(.caption2.weight(.semibold)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, minHeight: 28) + headerRow + Divider() + ScrollView { + LazyVStack(spacing: 0) { + ForEach(weekStarts, id: \.self) { ws in + WeekRow(weekStart: ws, + store: store, + dividerColor: Color(hex: dividerHex), + labelColor: Color(hex: labelHex), + textColor: Color(hex: textHex), + lineColor: Color(hex: lineHex), + language: appLang, + onDayTap: onDayTap, + onEventTap: onEventTap, + onCreateEvent: onCreateEvent, + onShowWeek: onShowWeek, + onShowDay: onShowDay) + .id(ws) + } + } + .scrollTargetLayout() + } + .scrollPosition(id: $scrolledWeek, anchor: .top) + .onAppear { + if !didInitialScroll { + didInitialScroll = true + scrolledWeek = weekStart(for: store.currentDate) + publishVisibleMonth(from: scrolledWeek) } } - Divider() - - // Grid fills all remaining space using GeometryReader - GeometryReader { geo in - let rowH = geo.size.height / CGFloat(rowCount) - VStack(spacing: 0) { - ForEach(0.. Date { + cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date))! + } + + /// Treat the visible month as the one that "owns" Thursday of the current week — + /// matches ISO week-month conventions and avoids flicker on month boundaries. + private func publishVisibleMonth(from week: Date?) { + guard let w = week else { return } + let thursday = cal.date(byAdding: .day, value: 3, to: w) ?? w + let m = cal.date(from: cal.dateComponents([.year, .month], from: thursday)) ?? thursday + if visibleMonth != m { visibleMonth = m } + } } -private struct DayCell: View { - let date: Date - let isCurrentMonth: Bool - let isToday: Bool - let events: [CalEvent] - let rowHeight: CGFloat - let onTap: () -> Void - let onEventTap: (CalEvent) -> Void +// MARK: – Week Row - private var maxVisible: Int { - max(1, Int((rowHeight - 32) / 16)) +private struct WeekRow: View { + let weekStart: Date + let store: CalendarStore + let dividerColor: Color + let labelColor: Color + let textColor: Color + let lineColor: Color + let language: String + let onDayTap: (Date) -> Void + let onEventTap: (CalEvent) -> Void + let onCreateEvent: (Date) -> Void + let onShowWeek: (Date) -> Void + let onShowDay: (Date) -> Void + + private var cal: Calendar { store.userCalendar } + + private var days: [Date] { + (0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: weekStart) } + } + + private var weekNumber: Int { cal.component(.weekOfYear, from: weekStart) } + + private func columnRange(for ev: CalEvent) -> (startCol: Int, span: Int) { + let weekEnd = cal.date(byAdding: .day, value: 7, to: weekStart)! + let evStart = max(cal.startOfDay(for: ev.startDate), weekStart) + // All-day end is already exclusive; timed end-of-day-on-same-day shouldn't add a column. + let rawEnd: Date + if ev.isAllDay { + rawEnd = ev.endDate + } else { + // Treat timed events as occupying days from start up to and including the day of end. + rawEnd = cal.date(byAdding: .day, value: 1, to: cal.startOfDay(for: ev.endDate))! + } + let evEnd = min(rawEnd, weekEnd) + let sc = max(0, cal.dateComponents([.day], from: weekStart, to: evStart).day ?? 0) + let lastIncl = (cal.dateComponents([.day], from: weekStart, to: evEnd).day ?? 0) - 1 + let ec = min(6, lastIncl) + return (sc, max(1, ec - sc + 1)) + } + + /// Greedy lane packing for events overlapping this week. + private func packEvents() -> (placed: [(event: CalEvent, lane: Int, startCol: Int, span: Int)], + extraPerCol: [Int]) { + let weekEndExclusive = cal.date(byAdding: .day, value: 7, to: weekStart)! + let evs = store.events(in: weekStart, end: weekEndExclusive) + .sorted { a, b in + if a.startDate != b.startDate { return a.startDate < b.startDate } + return a.endDate > b.endDate + } + var laneLastEnd: [Int] = [] + var placed: [(CalEvent, Int, Int, Int)] = [] + var overflowPerCol = [Int](repeating: 0, count: 7) + + for ev in evs { + let (sc, sp) = columnRange(for: ev) + var assigned: Int? = nil + for laneIdx in 0.. Void + let onCreateEvent: () -> Void + let onShowWeek: () -> Void + let onShowDay: () -> Void + + private var cal: Calendar { Calendar.current } + private var dayNum: Int { cal.component(.day, from: date) } + private var isFirstOfMonth: Bool { dayNum == 1 } + + private var monthAbbrev: String { + let f = DateFormatter() + f.locale = L10n.locale(language) + f.dateFormat = "LLL" + return f.string(from: date).uppercased() + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { Button(action: onTap) { - Text("\(Calendar.current.component(.day, from: date))") - .font(.system(size: 13, weight: isToday ? .bold : .regular)) - .foregroundStyle( - isToday ? Color.white : - isCurrentMonth ? Color.primary : Color.secondary.opacity(0.4) - ) - .frame(width: 26, height: 26) - .background(isToday ? Color.accentColor : Color.clear) - .clipShape(Circle()) + HStack(spacing: 4) { + Text("\(dayNum)") + .font(.system(size: 13, weight: isToday ? .bold : .regular)) + .foregroundStyle(isToday ? Color.white : textColor) + .frame(width: 22, height: 22) + .background(isToday ? Color.accentColor : Color.clear) + .clipShape(Circle()) + if isFirstOfMonth { + Text(monthAbbrev) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(monthLabelColor) + .lineLimit(1) + .fixedSize() + } + Spacer(minLength: 0) + } + .padding(.leading, 4) + .padding(.top, 2) } .buttonStyle(.plain) - .padding(.leading, 4) - .padding(.top, 2) - - // Events - ForEach(events.prefix(maxVisible)) { ev in - Button { onEventTap(ev) } label: { - EventChip(event: ev) - } - .buttonStyle(.plain) - } - - if events.count > maxVisible { - Text("+\(events.count - maxVisible)") - .font(.system(size: 9, weight: .medium)) - .foregroundStyle(.secondary) - .padding(.leading, 4) - } - Spacer(minLength: 0) + HStack(spacing: 0) { + if extraCount > 0 { + Text("+\(extraCount)") + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(textColor.opacity(0.6)) + .padding(.leading, 4) + } + Spacer(minLength: 0) + if let wn = weekNumber { + Text("\(cwLabel) \(wn)") + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(textColor.opacity(0.6)) + .padding(.trailing, 4) + } + } + .padding(.bottom, 1) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .overlay(alignment: .trailing) { - Rectangle().fill(Color(.separator)).frame(width: 0.5) + Rectangle().fill(lineColor.opacity(0.4)).frame(width: 0.5) + } + .overlay(alignment: .top) { + Rectangle() + .fill(edge == .topHighlight ? dividerColor : lineColor.opacity(0.3)) + .frame(height: edge == .topHighlight ? 1.5 : 0.5) } .overlay(alignment: .bottom) { - Rectangle().fill(Color(.separator)).frame(height: 0.5) + if edge == .bottomHighlight { + Rectangle().fill(dividerColor).frame(height: 1.5) + } + } + .contentShape(Rectangle()) + .contextMenu { + Button { onCreateEvent() } label: { + Label(L10n.t("cal.new_event", language), systemImage: "plus") + } + Button { onShowWeek() } label: { + Label(L10n.t("cal.show_in_week_view", language), + systemImage: "calendar.day.timeline.leading") + } + Button { onShowDay() } label: { + Label(L10n.t("cal.show_in_day_view", language), systemImage: "sun.max") + } } } } -private struct EventChip: View { +// MARK: – Event Bar + +private struct EventBar: View { let event: CalEvent var body: some View { HStack(spacing: 3) { - if !event.isAllDay { - Circle() - .fill(Color(hex: event.effectiveColor)) - .frame(width: 6, height: 6) - } Text(event.title) .font(.system(size: 10, weight: .medium)) .lineLimit(1) - .foregroundStyle(event.isAllDay ? .white : .primary) + .foregroundStyle(.white) + .padding(.leading, 4) + Spacer(minLength: 0) } - .padding(.horizontal, event.isAllDay ? 4 : 2) - .padding(.vertical, 1) .frame(maxWidth: .infinity, alignment: .leading) - .background(event.isAllDay ? Color(hex: event.effectiveColor) : Color.clear) + .background(Color(hex: event.effectiveColor)) .clipShape(RoundedRectangle(cornerRadius: 3)) - .padding(.horizontal, 2) } } diff --git a/Calendarr iOS/Views/Calendar/WeekView.swift b/Calendarr iOS/Views/Calendar/WeekView.swift index 613f4b7..fb0d0d1 100644 --- a/Calendarr iOS/Views/Calendar/WeekView.swift +++ b/Calendarr iOS/Views/Calendar/WeekView.swift @@ -3,7 +3,14 @@ import SwiftUI struct WeekView: View { let store: CalendarStore let onEventTap: (CalEvent) -> Void - let onTimeTap: (Date) -> Void + let onCreateEvent: (Date) -> Void + let onShowMonth: (Date) -> Void + let onShowDay: (Date) -> Void + + @AppStorage("appLanguage") private var appLang = "system" + @AppStorage("todayColor") private var todayHex = "#4285f4" + @AppStorage("textColor") private var textHex = "#FFFFFF" + @AppStorage("lineColor") private var lineHex = "#3A3A3C" private var cal: Calendar { store.userCalendar } @@ -49,10 +56,10 @@ struct WeekView: View { ForEach(weekDays, id: \.self) { day in Text(headerFmt.string(from: day).uppercased()) .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(cal.isDateInToday(day) ? Color.accentColor : .secondary) + .foregroundStyle(cal.isDateInToday(day) ? Color.accentColor : Color(hex: textHex).opacity(0.7)) .frame(maxWidth: .infinity, minHeight: 36) .overlay(alignment: .trailing) { - Rectangle().fill(Color(.separator)).frame(width: 0.5) + Rectangle().fill(Color(hex: lineHex)).frame(width: 0.5) } } } @@ -104,28 +111,23 @@ struct WeekView: View { ScrollViewReader { proxy in ScrollView { ZStack(alignment: .topLeading) { - // Background: time labels + vertical grid lines + // Background: time labels + per-hour cells per day HStack(alignment: .top, spacing: 0) { timeLabels ForEach(Array(weekDays.enumerated()), id: \.offset) { _, day in VStack(spacing: 0) { - ForEach(hours, id: \.self) { _ in - Rectangle() - .fill(Color(.separator).opacity(0.4)) - .frame(height: 0.5) - Color.clear.frame(height: hourHeight - 0.5) + ForEach(hours, id: \.self) { hour in + HourSlot(day: day, hour: hour, + hourHeight: hourHeight, + language: appLang, + onCreateEvent: onCreateEvent, + onShowMonth: onShowMonth, + onShowDay: onShowDay) } } .frame(width: colW) - .contentShape(Rectangle()) - .onTapGesture { loc in - let h = Int(loc.y / hourHeight) - let m = Int((loc.y.truncatingRemainder(dividingBy: hourHeight)) / hourHeight * 60) - let date = cal.date(bySettingHour: h, minute: m, second: 0, of: day) ?? day - onTimeTap(date) - } .overlay(alignment: .trailing) { - Rectangle().fill(Color(.separator)).frame(width: 0.5) + Rectangle().fill(Color(hex: lineHex)).frame(width: 0.5) } } } @@ -144,10 +146,11 @@ struct WeekView: View { // Current time line if let ti = todayIndex { let lineY = eventTop(Date.now) + let nowColor = Color(hex: todayHex) HStack(spacing: 0) { Spacer().frame(width: timeColumnWidth + CGFloat(ti) * colW - 4) - Circle().fill(Color.red).frame(width: 8, height: 8) - Rectangle().fill(Color.red).frame(width: colW - 4, height: 1.5) + Circle().fill(nowColor).frame(width: 8, height: 8) + Rectangle().fill(nowColor).frame(width: colW - 4, height: 1.5) } .offset(y: lineY - 0.75) } @@ -168,7 +171,7 @@ struct WeekView: View { Color.clear.frame(height: hourHeight) Text(String(format: "%02d:00", h)) .font(.system(size: 10)) - .foregroundStyle(.secondary) + .foregroundStyle(Color(hex: textHex).opacity(0.6)) .offset(y: -6) } } @@ -194,3 +197,39 @@ private func eventTop(_ date: Date) -> CGFloat { let m = CGFloat(cal.component(.minute, from: date)) return h * hourHeight + m * hourHeight / 60 } + +// One-hour slot with native long-press context menu. +struct HourSlot: View { + let day: Date + let hour: Int + let hourHeight: CGFloat + let language: String + let onCreateEvent: (Date) -> Void + let onShowMonth: (Date) -> Void + let onShowDay: (Date) -> Void + + @AppStorage("lineColor") private var lineHex = "#3A3A3C" + + private var date: Date { + Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day + } + + var body: some View { + VStack(spacing: 0) { + Rectangle().fill(Color(hex: lineHex).opacity(0.4)).frame(height: 0.5) + Color.clear.frame(height: hourHeight - 0.5) + } + .contentShape(Rectangle()) + .contextMenu { + Button { onCreateEvent(date) } label: { + Label(L10n.t("cal.new_event", language), systemImage: "plus") + } + Button { onShowMonth(date) } label: { + Label(L10n.t("cal.show_in_month_view", language), systemImage: "calendar") + } + Button { onShowDay(date) } label: { + Label(L10n.t("cal.show_in_day_view", language), systemImage: "sun.max") + } + } + } +} diff --git a/Calendarr iOS/Views/MenuSheet.swift b/Calendarr iOS/Views/MenuSheet.swift index 3187db1..1bb6a7f 100644 --- a/Calendarr iOS/Views/MenuSheet.swift +++ b/Calendarr iOS/Views/MenuSheet.swift @@ -4,6 +4,7 @@ struct MenuSheet: View { let api: CalendarrAPI @Environment(AppState.self) var appState @Environment(\.dismiss) var dismiss + @AppStorage("appLanguage") private var appLang = "system" var body: some View { NavigationStack { @@ -30,7 +31,7 @@ struct MenuSheet: View { } if appState.isAdmin { Spacer() - Text("Admin") + Text(L10n.t("menu.admin", appLang)) .font(.caption2.weight(.semibold)) .padding(.horizontal, 8).padding(.vertical, 3) .background(Color.accentColor.opacity(0.15)) @@ -41,30 +42,29 @@ struct MenuSheet: View { .padding(.vertical, 4) } - // Navigation links – direct destination syntax (no value-based nav) - Section("Einstellungen") { + Section(L10n.t("menu.section.settings", appLang)) { NavigationLink { ProfileView(api: api) } label: { - Label("Profil", systemImage: "person.circle") + Label(L10n.t("menu.profile", appLang), systemImage: "person.circle") } NavigationLink { SettingsView(api: api) } label: { - Label("Darstellung", systemImage: "paintpalette") + Label(L10n.t("menu.appearance", appLang), systemImage: "paintpalette") } NavigationLink { AccountsView(api: api) } label: { - Label("Konten & Kalender", systemImage: "tray.2") + Label(L10n.t("menu.accounts", appLang), systemImage: "tray.2") } NavigationLink { ServerView() } label: { - Label("Server", systemImage: "server.rack") + Label(L10n.t("menu.server", appLang), systemImage: "server.rack") } } @@ -75,15 +75,15 @@ struct MenuSheet: View { appState.logout() } } label: { - Label("Abmelden", systemImage: "rectangle.portrait.and.arrow.right") + Label(L10n.t("menu.logout", appLang), systemImage: "rectangle.portrait.and.arrow.right") } } } - .navigationTitle("Menü") + .navigationTitle(L10n.t("nav.menu", appLang)) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .primaryAction) { - Button("Fertig") { dismiss() } + Button(L10n.t("nav.done", appLang)) { dismiss() } } } } diff --git a/Calendarr iOS/Views/ProfileView.swift b/Calendarr iOS/Views/ProfileView.swift index f6aa5ad..a03417b 100644 --- a/Calendarr iOS/Views/ProfileView.swift +++ b/Calendarr iOS/Views/ProfileView.swift @@ -21,11 +21,13 @@ struct ProfileView: View { @State private var disablePW = "" @State private var isSaving2FA = false + @AppStorage("appLanguage") private var appLang = "system" + var body: some View { NavigationStack { Group { if isLoading { - ProgressView("Lade Profil…") + ProgressView(L10n.t("profile.loading", appLang)) } else if let profile { Form { kontoSection(profile: profile) @@ -34,7 +36,7 @@ struct ProfileView: View { } } } - .navigationTitle("Profil") + .navigationTitle(L10n.t("profile.title", appLang)) .navigationBarTitleDisplayMode(.large) .overlay(alignment: .bottom) { if showToast { @@ -68,29 +70,31 @@ struct ProfileView: View { } func kontoSection(profile: UserProfile) -> some View { - Section("Konto") { + Section(L10n.t("profile.account", appLang)) { HStack { - Text("Benutzername") + Text(L10n.t("profile.username", appLang)) Spacer() Text(profile.username) .foregroundStyle(.secondary) } HStack { - Text("Rolle") + Text(L10n.t("profile.role", appLang)) Spacer() - Text(profile.isAdmin ? "Administrator" : "Benutzer") + Text(profile.isAdmin + ? L10n.t("profile.role.admin", appLang) + : L10n.t("profile.role.user", appLang)) .foregroundStyle(.secondary) } HStack { - Text("E-Mail") + Text(L10n.t("profile.email", appLang)) Spacer() - TextField("Keine E-Mail", text: $newEmail) + TextField(L10n.t("profile.no_email", appLang), text: $newEmail) .multilineTextAlignment(.trailing) .foregroundStyle(.secondary) .keyboardType(.emailAddress) .textInputAutocapitalization(.never) } - Button("E-Mail speichern") { + Button(L10n.t("profile.save_email", appLang)) { Task { await saveEmail() } } .foregroundStyle(Color.accentColor) @@ -98,11 +102,11 @@ struct ProfileView: View { } var passwordSection: some View { - Section("Passwort ändern") { - SecureField("Aktuelles Passwort", text: $currentPW) - SecureField("Neues Passwort", text: $newPW) - SecureField("Neues Passwort wiederholen", text: $confirmPW) - Button("Passwort ändern") { + Section(L10n.t("profile.change_password", appLang)) { + SecureField(L10n.t("profile.current_password", appLang), text: $currentPW) + SecureField(L10n.t("profile.new_password", appLang), text: $newPW) + SecureField(L10n.t("profile.new_password_repeat", appLang), text: $confirmPW) + Button(L10n.t("profile.change_password", appLang)) { Task { await changePassword() } } .foregroundStyle(Color.accentColor) @@ -111,14 +115,14 @@ struct ProfileView: View { } func twoFASection(profile: UserProfile) -> some View { - Section("Zwei-Faktor-Authentifizierung") { + Section(L10n.t("profile.twofa", appLang)) { if profile.totpEnabled { HStack { Image(systemName: "checkmark.shield.fill") .foregroundStyle(.green) - Text("2FA ist aktiviert") + Text(L10n.t("profile.twofa.active", appLang)) } - Button("2FA deaktivieren") { + Button(L10n.t("profile.twofa.disable", appLang)) { show2FADisable = true } .foregroundStyle(.red) @@ -126,10 +130,10 @@ struct ProfileView: View { HStack { Image(systemName: "shield") .foregroundStyle(.secondary) - Text("2FA ist deaktiviert") + Text(L10n.t("profile.twofa.inactive", appLang)) .foregroundStyle(.secondary) } - Button("2FA einrichten") { + Button(L10n.t("profile.twofa.enable", appLang)) { Task { await setup2FA() } } .foregroundStyle(Color.accentColor) @@ -149,7 +153,7 @@ struct ProfileView: View { private func saveEmail() async { do { try await api.updateEmail(newEmail) - showNotice("E-Mail gespeichert") + showNotice(L10n.t("profile.email_saved", appLang)) } catch { showNotice(error.localizedDescription) } @@ -157,13 +161,13 @@ struct ProfileView: View { private func changePassword() async { guard newPW == confirmPW else { - showNotice("Passwörter stimmen nicht überein") + showNotice(L10n.t("profile.password_mismatch", appLang)) return } do { try await api.changePassword(current: currentPW, new: newPW) currentPW = ""; newPW = ""; confirmPW = "" - showNotice("Passwort geändert") + showNotice(L10n.t("profile.password_changed", appLang)) } catch { showNotice(error.localizedDescription) } @@ -186,7 +190,7 @@ struct ProfileView: View { do { try await api.enable2FA(code: totpCode) show2FASetup = false - showNotice("2FA aktiviert") + showNotice(L10n.t("profile.twofa.enabled_toast", appLang)) await load() } catch { showNotice(error.localizedDescription) @@ -198,7 +202,7 @@ struct ProfileView: View { do { try await api.disable2FA(password: disablePW) show2FADisable = false - showNotice("2FA deaktiviert") + showNotice(L10n.t("profile.twofa.disabled_toast", appLang)) await load() } catch { showNotice(error.localizedDescription) @@ -222,15 +226,16 @@ struct TwoFASetupSheet: View { let isSaving: Bool let onEnable: () -> Void @Environment(\.dismiss) var dismiss + @AppStorage("appLanguage") private var appLang = "system" var body: some View { NavigationStack { Form { Section { - Text("Scanne den QR-Code mit deiner Authenticator-App (z.B. Bitwarden, Google Authenticator).") + Text(L10n.t("twofa.scan_hint", appLang)) .font(.body) } - Section("QR-Code / Manueller Schlüssel") { + Section(L10n.t("twofa.qr_section", appLang)) { if let url = URL(string: qrURL) { AsyncImage(url: url) { phase in switch phase { @@ -259,17 +264,17 @@ struct TwoFASetupSheet: View { .foregroundStyle(Color.accentColor) } } - Section("Bestätigung") { - TextField("6-stelliger Code", text: $code) + Section(L10n.t("twofa.confirmation", appLang)) { + TextField(L10n.t("twofa.code_placeholder", appLang), text: $code) .keyboardType(.numberPad) } } - .navigationTitle("2FA einrichten") + .navigationTitle(L10n.t("twofa.setup_title", appLang)) .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } } + ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } } ToolbarItem(placement: .primaryAction) { - Button("Aktivieren") { onEnable() } + Button(L10n.t("twofa.activate", appLang)) { onEnable() } .bold() .disabled(code.count < 6 || isSaving) } @@ -282,20 +287,21 @@ struct TwoFADisableSheet: View { @Binding var password: String let onDisable: () -> Void @Environment(\.dismiss) var dismiss + @AppStorage("appLanguage") private var appLang = "system" var body: some View { NavigationStack { Form { - Section("Passwort zum Deaktivieren") { - SecureField("Passwort", text: $password) + Section(L10n.t("twofa.password_section", appLang)) { + SecureField(L10n.t("twofa.password_placeholder", appLang), text: $password) } } - .navigationTitle("2FA deaktivieren") + .navigationTitle(L10n.t("twofa.disable_title", appLang)) .navigationBarTitleDisplayMode(.inline) .toolbar { - ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } } + ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } } ToolbarItem(placement: .primaryAction) { - Button("Deaktivieren") { onDisable() } + Button(L10n.t("twofa.disable", appLang)) { onDisable() } .bold() .foregroundStyle(.red) .disabled(password.isEmpty) diff --git a/Calendarr iOS/Views/ServerView.swift b/Calendarr iOS/Views/ServerView.swift index 33a5bc5..746e775 100644 --- a/Calendarr iOS/Views/ServerView.swift +++ b/Calendarr iOS/Views/ServerView.swift @@ -5,6 +5,7 @@ struct ServerView: View { @State private var showLogoutConfirm = false @State private var showChangeServer = false @State private var showImpressum = false + @AppStorage("appLanguage") private var appLang = "system" var serverHost: String { appState.serverURL @@ -15,7 +16,7 @@ struct ServerView: View { var body: some View { NavigationStack { Form { - Section("Verbundener Server") { + Section(L10n.t("server.connected", appLang)) { HStack { Image(systemName: "server.rack") .foregroundStyle(Color.accentColor) @@ -36,7 +37,7 @@ struct ServerView: View { Text(appState.username) if appState.isAdmin { Spacer() - Text("Admin") + Text(L10n.t("menu.admin", appLang)) .font(.caption.weight(.semibold)) .padding(.horizontal, 8) .padding(.vertical, 3) @@ -54,7 +55,7 @@ struct ServerView: View { HStack { Image(systemName: "arrow.triangle.swap") .frame(width: 28) - Text("Server wechseln") + Text(L10n.t("server.switch", appLang)) } } .foregroundStyle(.primary) @@ -65,48 +66,48 @@ struct ServerView: View { HStack { Image(systemName: "rectangle.portrait.and.arrow.right") .frame(width: 28) - Text("Abmelden") + Text(L10n.t("menu.logout", appLang)) } } } - Section("Info") { + Section(L10n.t("server.info", appLang)) { Button { showImpressum = true } label: { HStack { Image(systemName: "info.circle") .frame(width: 28) - Text("Impressum") + Text(L10n.t("server.imprint", appLang)) } } .foregroundStyle(.secondary) HStack { - Text("Version") + Text(L10n.t("server.version", appLang)) Spacer() Text("1.0") .foregroundStyle(.secondary) } } } - .navigationTitle("Server") + .navigationTitle(L10n.t("server.title", appLang)) .navigationBarTitleDisplayMode(.large) - .confirmationDialog("Abmelden", isPresented: $showLogoutConfirm, titleVisibility: .visible) { - Button("Abmelden", role: .destructive) { + .confirmationDialog(L10n.t("server.logout_title", appLang), isPresented: $showLogoutConfirm, titleVisibility: .visible) { + Button(L10n.t("menu.logout", appLang), role: .destructive) { appState.logout() } - Button("Abbrechen", role: .cancel) {} + Button(L10n.t("common.cancel", appLang), role: .cancel) {} } message: { - Text("Du wirst von \(serverHost) abgemeldet.") + Text(String(format: L10n.t("server.logout_msg", appLang), serverHost)) } - .confirmationDialog("Server wechseln", isPresented: $showChangeServer, titleVisibility: .visible) { - Button("Server wechseln", role: .destructive) { + .confirmationDialog(L10n.t("server.switch", appLang), isPresented: $showChangeServer, titleVisibility: .visible) { + Button(L10n.t("server.switch", appLang), role: .destructive) { appState.resetServer() } - Button("Abbrechen", role: .cancel) {} + Button(L10n.t("common.cancel", appLang), role: .cancel) {} } message: { - Text("Verbindung zu \(serverHost) wird getrennt und alle lokalen Anmeldedaten werden gelöscht.") + Text(String(format: L10n.t("server.switch_msg", appLang), serverHost)) } .sheet(isPresented: $showImpressum) { ImpressumView() @@ -117,34 +118,35 @@ struct ServerView: View { struct ImpressumView: View { @Environment(\.dismiss) var dismiss + @AppStorage("appLanguage") private var appLang = "system" var body: some View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 16) { Group { - Text("Scarriffleservices") + Text(L10n.t("imprint.company", appLang)) .font(.title2.bold()) - Text("Software & Webentwicklung") + Text(L10n.t("imprint.role", appLang)) .foregroundStyle(.secondary) } Divider() - Text("Diese Software wurde von Scarriffleservices mit grösster Sorgfalt entwickelt und bereitgestellt. Alle Rechte vorbehalten © 2026 Scarriffleservices.") + Text(L10n.t("imprint.copyright", appLang)) VStack(alignment: .leading, spacing: 6) { - Text("Datenspeicherung").font(.headline) - Text("Alle Anwendungsdaten werden auf dem Server gespeichert und verarbeitet, auf dem diese Calendarr-Instanz betrieben wird. Der Speicherort hängt damit vom Betreiber des jeweiligen Servers ab. Bei Nutzung der Google Kalender-Anbindung werden Daten über die Google API ausgetauscht; für diese Daten gelten die Datenschutzbestimmungen von Google. Bei Nutzung der Home Assistant-Anbindung werden Daten mit der jeweiligen Home Assistant-Instanz ausgetauscht. Home Assistant ist ein Projekt der Open Home Foundation.") + Text(L10n.t("imprint.storage.title", appLang)).font(.headline) + Text(L10n.t("imprint.storage.body", appLang)) } VStack(alignment: .leading, spacing: 6) { - Text("Haftungsausschluss").font(.headline) - Text("Trotz sorgfältiger Erstellung wird keine Haftung für die Richtigkeit, Vollständigkeit oder Aktualität der bereitgestellten Inhalte übernommen. Die Nutzung erfolgt auf eigene Verantwortung.") + Text(L10n.t("imprint.disclaimer.title", appLang)).font(.headline) + Text(L10n.t("imprint.disclaimer.body", appLang)) } VStack(alignment: .leading, spacing: 6) { - Text("Kontakt").font(.headline) + Text(L10n.t("imprint.contact.title", appLang)).font(.headline) Link("scarriffleservices@gmail.com", destination: URL(string: "mailto:scarriffleservices@gmail.com")!) } @@ -156,11 +158,11 @@ struct ImpressumView: View { } .padding(24) } - .navigationTitle("Impressum") + .navigationTitle(L10n.t("server.imprint", appLang)) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .primaryAction) { - Button("Schliessen") { dismiss() } + Button(L10n.t("common.close", appLang)) { dismiss() } } } } diff --git a/Calendarr iOS/Views/SettingsView.swift b/Calendarr iOS/Views/SettingsView.swift index 646e4de..3aaf2c7 100644 --- a/Calendarr iOS/Views/SettingsView.swift +++ b/Calendarr iOS/Views/SettingsView.swift @@ -7,14 +7,21 @@ struct SettingsView: View { @State private var isSaving = false @State private var toast = "" @State private var showToast = false - @AppStorage("liquidGlass") private var liquidGlass = false - @AppStorage("cacheMonths") private var cacheMonths = 3 + @AppStorage("liquidGlass") private var liquidGlass = false + @AppStorage("cacheMonths") private var cacheMonths = 3 + @AppStorage("appLanguage") private var appLang = "system" + @AppStorage("monthDividerColor") private var dividerHex = "#7090c0" + @AppStorage("monthLabelColor") private var labelHex = "#7090c0" + @AppStorage("todayColor") private var todayHex = "#4285f4" + @AppStorage("textColor") private var textHex = "#FFFFFF" + @AppStorage("backgroundColor") private var bgHex = "#000000" + @AppStorage("lineColor") private var lineHex = "#3A3A3C" var body: some View { NavigationStack { Group { if isLoading { - ProgressView("Lade Einstellungen…") + ProgressView(L10n.t("settings.loading", appLang)) } else { Form { liquidGlassSection @@ -28,7 +35,7 @@ struct SettingsView: View { } } } - .navigationTitle("Darstellung") + .navigationTitle(L10n.t("settings.title", appLang)) .navigationBarTitleDisplayMode(.large) .toolbar { ToolbarItem(placement: .primaryAction) { @@ -38,7 +45,7 @@ struct SettingsView: View { if isSaving { ProgressView() } else { - Text("Speichern").bold() + Text(L10n.t("settings.save", appLang)).bold() } } .disabled(isSaving) @@ -67,8 +74,8 @@ struct SettingsView: View { Toggle(isOn: $liquidGlass) { Label { VStack(alignment: .leading, spacing: 2) { - Text("Liquid Glass") - Text("Verwendet die neue iOS\u{202F}26 Glasoptik mit transparenter Navigationsleiste") + Text(L10n.t("settings.liquidglass", appLang)) + Text(L10n.t("settings.liquidglass.desc", appLang)) .font(.caption) .foregroundStyle(.secondary) } @@ -79,9 +86,9 @@ struct SettingsView: View { } .tint(Color.accentColor) } header: { - Text("App-Design") + Text(L10n.t("settings.appdesign", appLang)) } footer: { - Text("Änderung wirkt sofort – kein Neustart nötig.") + Text(L10n.t("settings.liquidglass.footer", appLang)) .font(.caption) } } @@ -93,8 +100,8 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 10) { Label { VStack(alignment: .leading, spacing: 2) { - Text("Vorladen") - Text("Events werden beim Start im Hintergrund für diesen Zeitraum geladen, danach ist Wischen sofort.") + Text(L10n.t("settings.cache.title", appLang)) + Text(L10n.t("settings.cache.desc", appLang)) .font(.caption) .foregroundStyle(.secondary) } @@ -103,19 +110,19 @@ struct SettingsView: View { .foregroundStyle(.green) } - Picker("Zeitraum", selection: $cacheMonths) { - Text("±1 Monat").tag(1) - Text("±3 Monate").tag(3) - Text("±6 Monate").tag(6) - Text("±1 Jahr").tag(12) + Picker(L10n.t("settings.cache.range", appLang), selection: $cacheMonths) { + Text(L10n.t("settings.cache.1m", appLang)).tag(1) + Text(L10n.t("settings.cache.3m", appLang)).tag(3) + Text(L10n.t("settings.cache.6m", appLang)).tag(6) + Text(L10n.t("settings.cache.1y", appLang)).tag(12) } .pickerStyle(.segmented) } .padding(.vertical, 4) } header: { - Text("Vorladen") + Text(L10n.t("settings.cache.header", appLang)) } footer: { - Text("Mehr Monate = längerer initialer Ladevorgang, danach komplett ohne Wartezeiten navigierbar.") + Text(L10n.t("settings.cache.footer", appLang)) .font(.caption) } } @@ -123,10 +130,11 @@ struct SettingsView: View { // MARK: – Sprache var spracheSection: some View { - Section("Sprache") { - Picker("Sprache", selection: $settings.language) { - Text("Deutsch").tag("de") - Text("English").tag("en") + Section(L10n.t("settings.language", appLang)) { + Picker(L10n.t("settings.language", appLang), selection: $appLang) { + Text(L10n.t("lang.system", appLang)).tag("system") + Text(L10n.t("lang.german", appLang)).tag("de") + Text(L10n.t("lang.english", appLang)).tag("en") } } } @@ -134,12 +142,15 @@ struct SettingsView: View { // MARK: – Farben var farbenSection: some View { - Section("Farben") { - ColorPickerRow(label: "Primärfarbe", hex: $settings.primaryColor) - ColorPickerRow(label: "Akzentfarbe", hex: $settings.accentColor) - ColorPickerRow(label: "Heutige-Tag-Farbe", hex: $settings.todayColor) - ColorPickerRow(label: "Monatswechsel-Linie", hex: $settings.monthDividerColor) - ColorPickerRow(label: "Monatskürzel", hex: $settings.monthLabelColor) + Section(L10n.t("settings.colors", appLang)) { + ColorPickerRow(label: L10n.t("settings.color.primary", appLang), hex: $settings.primaryColor) + ColorPickerRow(label: L10n.t("settings.color.accent", appLang), hex: $settings.accentColor) + ColorPickerRow(label: L10n.t("settings.color.today", appLang), hex: $todayHex) + ColorPickerRow(label: L10n.t("settings.color.text", appLang), hex: $textHex) + ColorPickerRow(label: L10n.t("settings.color.background", appLang), hex: $bgHex) + ColorPickerRow(label: L10n.t("settings.color.line", appLang), hex: $lineHex) + ColorPickerRow(label: L10n.t("settings.color.divider", appLang), hex: $dividerHex) + ColorPickerRow(label: L10n.t("settings.color.label", appLang), hex: $labelHex) } } @@ -148,18 +159,18 @@ struct SettingsView: View { var schriftSection: some View { Section { VStack(alignment: .leading, spacing: 10) { - Text("Schriftkontrast") + Text(L10n.t("settings.textcontrast", appLang)) .font(.headline) - Text("Helligkeit der Beschriftungen und Texte") + Text(L10n.t("settings.textcontrast.desc", appLang)) .font(.caption) .foregroundStyle(.secondary) ContrastSelector( value: $settings.textContrast, options: [ - (1, "Dunkel"), - (2, "Mittel"), - (3, "Hell"), - (4, "Maximum") + (1, L10n.t("settings.contrast.dark", appLang)), + (2, L10n.t("settings.contrast.medium", appLang)), + (3, L10n.t("settings.contrast.bright", appLang)), + (4, L10n.t("settings.contrast.max", appLang)) ] ) } @@ -172,18 +183,18 @@ struct SettingsView: View { var linienSection: some View { Section { VStack(alignment: .leading, spacing: 10) { - Text("Linienkontrast") + Text(L10n.t("settings.linecontrast", appLang)) .font(.headline) - Text("Sichtbarkeit von Trennlinien und Rahmen") + Text(L10n.t("settings.linecontrast.desc", appLang)) .font(.caption) .foregroundStyle(.secondary) ContrastSelector( value: $settings.lineContrast, options: [ - (1, "Kaum"), - (2, "Subtil"), - (3, "Normal"), - (4, "Stark") + (1, L10n.t("settings.linecontrast.barely", appLang)), + (2, L10n.t("settings.linecontrast.subtle", appLang)), + (3, L10n.t("settings.linecontrast.normal", appLang)), + (4, L10n.t("settings.linecontrast.strong", appLang)) ] ) } @@ -194,19 +205,19 @@ struct SettingsView: View { // MARK: – Ansicht var ansichtSection: some View { - Section("Kalenderansicht") { - Picker("Standardansicht", selection: $settings.defaultView) { - Text("Monat").tag("month") - Text("Woche").tag("week") - Text("Tag").tag("day") - Text("Quartal").tag("quarter") - Text("Termine").tag("agenda") + Section(L10n.t("settings.calview", appLang)) { + Picker(L10n.t("settings.defaultview", appLang), selection: $settings.defaultView) { + Text(L10n.t("view.month", appLang)).tag("month") + Text(L10n.t("view.week", appLang)).tag("week") + Text(L10n.t("view.day", appLang)).tag("day") + Text(L10n.t("view.quarter", appLang)).tag("quarter") + Text(L10n.t("view.agenda", appLang)).tag("agenda") } - Picker("Erster Wochentag", selection: $settings.weekStartDay) { - Text("Montag").tag("monday") - Text("Sonntag").tag("sunday") + Picker(L10n.t("settings.firstweekday", appLang), selection: $settings.weekStartDay) { + Text(L10n.t("settings.monday", appLang)).tag("monday") + Text(L10n.t("settings.sunday", appLang)).tag("sunday") } - Toggle("Vergangene Termine ausgrauen", isOn: $settings.dimPastEvents) + Toggle(L10n.t("settings.dimpast", appLang), isOn: $settings.dimPastEvents) .tint(Color.accentColor) } } @@ -216,18 +227,18 @@ struct SettingsView: View { var stundenSection: some View { Section { VStack(alignment: .leading, spacing: 10) { - Text("Stundenhöhe") + Text(L10n.t("settings.hourheight", appLang)) .font(.headline) - Text("Platz pro Stunde in der Wochen- & Tagesansicht") + Text(L10n.t("settings.hourheight.desc", appLang)) .font(.caption) .foregroundStyle(.secondary) ContrastSelector( value: $settings.hourHeight, options: [ - (28, "Kompakt"), - (44, "Normal"), - (60, "Komfort"), - (80, "Gross") + (28, L10n.t("settings.hourheight.compact", appLang)), + (44, L10n.t("settings.hourheight.normal", appLang)), + (60, L10n.t("settings.hourheight.comfort", appLang)), + (80, L10n.t("settings.hourheight.large", appLang)) ] ) } @@ -240,15 +251,31 @@ struct SettingsView: View { private func load() async { isLoading = true defer { isLoading = false } - if let s = try? await api.getSettings() { settings = s } + if let s = try? await api.getSettings() { + settings = s + // Mirror server-side color settings so calendar views (which read AppStorage) see them. + dividerHex = s.monthDividerColor + labelHex = s.monthLabelColor + todayHex = s.todayColor + textHex = s.textColor + bgHex = s.backgroundColor + lineHex = s.lineColor + } } private func save() async { isSaving = true defer { isSaving = false } + // Push local AppStorage colors back into the settings struct before saving. + settings.monthDividerColor = dividerHex + settings.monthLabelColor = labelHex + settings.todayColor = todayHex + settings.textColor = textHex + settings.backgroundColor = bgHex + settings.lineColor = lineHex do { try await api.updateSettings(settings) - showNotice("Gespeichert") + showNotice(L10n.t("settings.saved", appLang)) } catch { showNotice(error.localizedDescription) } diff --git a/Calendarr iOSTests/Calendarr_iOSTests.swift b/Calendarr iOSTests/Calendarr_iOSTests.swift new file mode 100644 index 0000000..cf2ca3f --- /dev/null +++ b/Calendarr iOSTests/Calendarr_iOSTests.swift @@ -0,0 +1,18 @@ +// +// Calendarr_iOSTests.swift +// Calendarr iOSTests +// +// Created by Guido Schmit on 17.05.2026. +// + +import Testing + +struct Calendarr_iOSTests { + + @Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + // Swift Testing Documentation + // https://developer.apple.com/documentation/testing + } + +}