Compare commits
27 Commits
d1004a9111
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59a879ea23 | ||
|
|
f480b438cb | ||
|
|
587a0e65fa | ||
|
|
e7d8effb47 | ||
|
|
68349d36e5 | ||
|
|
451d3d4d6b | ||
|
|
51218b9aa3 | ||
|
|
b61a90d960 | ||
|
|
b9547c15f9 | ||
|
|
8521a28520 | ||
|
|
7f76df2600 | ||
|
|
852e46fcf8 | ||
|
|
a62b200dfa | ||
|
|
c6f9981a54 | ||
|
|
815f2cf01a | ||
|
|
6dc8724a9a | ||
|
|
c9803d80a3 | ||
|
|
9fac13f99c | ||
|
|
da2e39911c | ||
|
|
023f90be3b | ||
|
|
e7e4998fb9 | ||
|
|
b1e0cf1fdc | ||
|
|
e71fd7512f | ||
|
|
4125bfc728 | ||
|
|
07a9e9eb7f | ||
|
|
1395aaa0c0 | ||
|
|
6c506770ba |
@@ -491,13 +491,14 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
|
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
|
||||||
INFOPLIST_KEY_CFBundleName = Calendarr;
|
INFOPLIST_KEY_CFBundleName = Calendarr;
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES;
|
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
@@ -509,7 +510,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1;
|
MARKETING_VERSION = 2.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
||||||
PRODUCT_NAME = "Calendarr iOS";
|
PRODUCT_NAME = "Calendarr iOS";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -533,13 +534,14 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
|
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
|
||||||
INFOPLIST_KEY_CFBundleName = Calendarr;
|
INFOPLIST_KEY_CFBundleName = Calendarr;
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES;
|
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
@@ -551,7 +553,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1;
|
MARKETING_VERSION = 2.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
||||||
PRODUCT_NAME = "Calendarr iOS";
|
PRODUCT_NAME = "Calendarr iOS";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
|||||||
@@ -9,6 +9,11 @@
|
|||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>0</integer>
|
<integer>0</integer>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>CalendarrWidgets.xcscheme_^#shared#^_</key>
|
||||||
|
<dict>
|
||||||
|
<key>orderHint</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
<key>SuppressBuildableAutocreation</key>
|
<key>SuppressBuildableAutocreation</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
|||||||
10
Calendarr iOS/Calendarr iOS.entitlements
Normal file
10
Calendarr iOS/Calendarr iOS.entitlements
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.com.scarriffleservices.calendarr</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -16,6 +16,9 @@ struct AppSettings: Codable {
|
|||||||
var textColor: String = "#FFFFFF"
|
var textColor: String = "#FFFFFF"
|
||||||
var backgroundColor: String = "#000000"
|
var backgroundColor: String = "#000000"
|
||||||
var lineColor: String = "#3A3A3C"
|
var lineColor: String = "#3A3A3C"
|
||||||
|
var privateEventVisibility: String = "busy" // 'hidden' | 'busy'
|
||||||
|
var groupVisibleCalendarId: Int? = nil
|
||||||
|
var defaultReminderMinutes: Int? = nil // minutes before start; nil = off
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case defaultView = "default_view"
|
case defaultView = "default_view"
|
||||||
@@ -33,6 +36,39 @@ struct AppSettings: Codable {
|
|||||||
case textColor = "text_color"
|
case textColor = "text_color"
|
||||||
case backgroundColor = "background_color"
|
case backgroundColor = "background_color"
|
||||||
case lineColor = "line_color"
|
case lineColor = "line_color"
|
||||||
|
case privateEventVisibility = "private_event_visibility"
|
||||||
|
case groupVisibleCalendarId = "group_visible_calendar_id"
|
||||||
|
case defaultReminderMinutes = "default_reminder_minutes"
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
/// Resilient decoding: the server only stores a subset of these fields
|
||||||
|
/// (e.g. it has no `text_color`/`background_color`/`line_color`, which are
|
||||||
|
/// iOS-only). Using `decodeIfPresent` with the property defaults means a
|
||||||
|
/// missing key no longer aborts the whole decode — otherwise the entire
|
||||||
|
/// settings sync silently breaks.
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
let d = AppSettings()
|
||||||
|
defaultView = try c.decodeIfPresent(String.self, forKey: .defaultView) ?? d.defaultView
|
||||||
|
weekStartDay = try c.decodeIfPresent(String.self, forKey: .weekStartDay) ?? d.weekStartDay
|
||||||
|
primaryColor = try c.decodeIfPresent(String.self, forKey: .primaryColor) ?? d.primaryColor
|
||||||
|
accentColor = try c.decodeIfPresent(String.self, forKey: .accentColor) ?? d.accentColor
|
||||||
|
todayColor = try c.decodeIfPresent(String.self, forKey: .todayColor) ?? d.todayColor
|
||||||
|
dimPastEvents = try c.decodeIfPresent(Bool.self, forKey: .dimPastEvents) ?? d.dimPastEvents
|
||||||
|
textContrast = try c.decodeIfPresent(Int.self, forKey: .textContrast) ?? d.textContrast
|
||||||
|
lineContrast = try c.decodeIfPresent(Int.self, forKey: .lineContrast) ?? d.lineContrast
|
||||||
|
hourHeight = try c.decodeIfPresent(Int.self, forKey: .hourHeight) ?? d.hourHeight
|
||||||
|
language = try c.decodeIfPresent(String.self, forKey: .language) ?? d.language
|
||||||
|
monthDividerColor = try c.decodeIfPresent(String.self, forKey: .monthDividerColor) ?? d.monthDividerColor
|
||||||
|
monthLabelColor = try c.decodeIfPresent(String.self, forKey: .monthLabelColor) ?? d.monthLabelColor
|
||||||
|
textColor = try c.decodeIfPresent(String.self, forKey: .textColor) ?? d.textColor
|
||||||
|
backgroundColor = try c.decodeIfPresent(String.self, forKey: .backgroundColor) ?? d.backgroundColor
|
||||||
|
lineColor = try c.decodeIfPresent(String.self, forKey: .lineColor) ?? d.lineColor
|
||||||
|
privateEventVisibility = try c.decodeIfPresent(String.self, forKey: .privateEventVisibility) ?? d.privateEventVisibility
|
||||||
|
groupVisibleCalendarId = try c.decodeIfPresent(Int.self, forKey: .groupVisibleCalendarId)
|
||||||
|
defaultReminderMinutes = try c.decodeIfPresent(Int.self, forKey: .defaultReminderMinutes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +104,27 @@ struct LocalCalendar: Codable, Identifiable {
|
|||||||
var name: String
|
var name: String
|
||||||
var color: String
|
var color: String
|
||||||
var enabled: Bool
|
var enabled: Bool
|
||||||
|
var owned: Bool = true
|
||||||
|
var sharedBy: String? = nil
|
||||||
|
var permission: String? = nil
|
||||||
|
var group: Bool = false
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, color, enabled, owned, permission, group
|
||||||
|
case sharedBy = "shared_by"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(Int.self, forKey: .id)
|
||||||
|
name = try c.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||||
|
color = try c.decodeIfPresent(String.self, forKey: .color) ?? "#34a853"
|
||||||
|
enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? true
|
||||||
|
owned = try c.decodeIfPresent(Bool.self, forKey: .owned) ?? true
|
||||||
|
sharedBy = try c.decodeIfPresent(String.self, forKey: .sharedBy)
|
||||||
|
permission = try c.decodeIfPresent(String.self, forKey: .permission)
|
||||||
|
group = try c.decodeIfPresent(Bool.self, forKey: .group) ?? false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ICalSubscription: Codable, Identifiable {
|
struct ICalSubscription: Codable, Identifiable {
|
||||||
@@ -124,16 +181,29 @@ struct HACalendar: Codable, Identifiable {
|
|||||||
var entityId: String
|
var entityId: String
|
||||||
var color: String?
|
var color: String?
|
||||||
var enabled: Bool
|
var enabled: Bool
|
||||||
|
var sidebarHidden: Bool
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, name, color, enabled
|
case id, name, color, enabled
|
||||||
case entityId = "entity_id"
|
case entityId = "entity_id"
|
||||||
|
case sidebarHidden = "sidebar_hidden"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(Int.self, forKey: .id)
|
||||||
|
name = try c.decode(String.self, forKey: .name)
|
||||||
|
entityId = try c.decodeIfPresent(String.self, forKey: .entityId) ?? ""
|
||||||
|
color = try c.decodeIfPresent(String.self, forKey: .color)
|
||||||
|
enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? true
|
||||||
|
sidebarHidden = try c.decodeIfPresent(Bool.self, forKey: .sidebarHidden) ?? false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct UserProfile: Codable {
|
struct UserProfile: Codable {
|
||||||
let id: Int
|
let id: Int
|
||||||
let username: String
|
let username: String
|
||||||
|
var displayName: String?
|
||||||
var email: String?
|
var email: String?
|
||||||
let isAdmin: Bool
|
let isAdmin: Bool
|
||||||
let hasAvatar: Bool
|
let hasAvatar: Bool
|
||||||
@@ -141,12 +211,61 @@ struct UserProfile: Codable {
|
|||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, username, email
|
case id, username, email
|
||||||
|
case displayName = "display_name"
|
||||||
case isAdmin = "is_admin"
|
case isAdmin = "is_admin"
|
||||||
case hasAvatar = "has_avatar"
|
case hasAvatar = "has_avatar"
|
||||||
case totpEnabled = "totp_enabled"
|
case totpEnabled = "totp_enabled"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Sharing & groups
|
||||||
|
|
||||||
|
struct DirectoryUser: Codable, Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let displayName: String
|
||||||
|
enum CodingKeys: String, CodingKey { case id; case displayName = "display_name" }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CalendarShare: Codable, Identifiable {
|
||||||
|
let userId: Int
|
||||||
|
let displayName: String?
|
||||||
|
var permission: String
|
||||||
|
var id: Int { userId }
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case userId = "user_id"
|
||||||
|
case displayName = "display_name"
|
||||||
|
case permission
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GroupMember: Codable, Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let displayName: String?
|
||||||
|
var role: String
|
||||||
|
var color: String?
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, role, color
|
||||||
|
case displayName = "display_name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CalGroup: Codable, Identifiable {
|
||||||
|
let id: Int
|
||||||
|
var name: String
|
||||||
|
var icon: String?
|
||||||
|
var role: String?
|
||||||
|
var memberCount: Int?
|
||||||
|
var groupCalendarId: Int?
|
||||||
|
var groupCalendarColor: String?
|
||||||
|
var members: [GroupMember]?
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, icon, role, members
|
||||||
|
case memberCount = "member_count"
|
||||||
|
case groupCalendarId = "group_calendar_id"
|
||||||
|
case groupCalendarColor = "group_calendar_color"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension Color {
|
extension Color {
|
||||||
init(hex: String) {
|
init(hex: String) {
|
||||||
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||||
|
|||||||
@@ -1,6 +1,23 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Creator (or owner, in the group combined view) of an event.
|
||||||
|
/// `id` is nil for imported events.
|
||||||
|
struct EventPerson: Hashable {
|
||||||
|
let id: Int?
|
||||||
|
let displayName: String
|
||||||
|
|
||||||
|
static func from(_ json: Any?) -> EventPerson? {
|
||||||
|
guard let obj = json as? [String: Any],
|
||||||
|
let name = obj["display_name"] as? String, !name.isEmpty else { return nil }
|
||||||
|
let id: Int?
|
||||||
|
if let n = obj["id"] as? Int { id = n }
|
||||||
|
else if let s = obj["id"] as? String { id = Int(s) }
|
||||||
|
else { id = nil }
|
||||||
|
return EventPerson(id: id, displayName: name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct CalEvent: Identifiable, Hashable {
|
struct CalEvent: Identifiable, Hashable {
|
||||||
let id: String
|
let id: String
|
||||||
let url: String
|
let url: String
|
||||||
@@ -15,8 +32,20 @@ struct CalEvent: Identifiable, Hashable {
|
|||||||
var calendarName: String
|
var calendarName: String
|
||||||
var calendarColor: String
|
var calendarColor: String
|
||||||
var source: String
|
var source: String
|
||||||
|
var creator: EventPerson? = nil
|
||||||
|
var isPrivate: Bool = false
|
||||||
|
// Only set in the group combined view:
|
||||||
|
var owner: EventPerson? = nil
|
||||||
|
var isGroupEvent: Bool = false
|
||||||
|
var displayColor: String? = nil
|
||||||
|
// Server-decorated title for the group combined view (group icon / owner
|
||||||
|
// prefix); rendered in group mode while `title` stays raw for editing.
|
||||||
|
var displayTitle: String? = nil
|
||||||
|
// Reminder offsets in minutes-before-start (0 = at start). Local events only.
|
||||||
|
var reminders: [Int] = []
|
||||||
|
|
||||||
var effectiveColor: String { color ?? calendarColor }
|
// Group view supplies a server-resolved colour; otherwise per-event then calendar colour.
|
||||||
|
var effectiveColor: String { displayColor ?? color ?? calendarColor }
|
||||||
|
|
||||||
static func from(json: [String: Any]) -> CalEvent? {
|
static func from(json: [String: Any]) -> CalEvent? {
|
||||||
guard
|
guard
|
||||||
@@ -49,7 +78,14 @@ struct CalEvent: Identifiable, Hashable {
|
|||||||
calendarId: json["calendar_id"].map { "\($0)" } ?? "",
|
calendarId: json["calendar_id"].map { "\($0)" } ?? "",
|
||||||
calendarName: json["calendar_name"] as? String ?? "",
|
calendarName: json["calendar_name"] as? String ?? "",
|
||||||
calendarColor: json["calendarColor"] as? String ?? "#4285f4",
|
calendarColor: json["calendarColor"] as? String ?? "#4285f4",
|
||||||
source: json["source"] as? String ?? "local"
|
source: json["source"] as? String ?? "local",
|
||||||
|
creator: EventPerson.from(json["creator"]),
|
||||||
|
isPrivate: json["private"] as? Bool ?? false,
|
||||||
|
owner: EventPerson.from(json["owner"]),
|
||||||
|
isGroupEvent: json["is_group_event"] as? Bool ?? false,
|
||||||
|
displayColor: (json["display_color"] as? String).flatMap { $0.isEmpty ? nil : $0 },
|
||||||
|
displayTitle: (json["display_title"] as? String).flatMap { $0.isEmpty ? nil : $0 },
|
||||||
|
reminders: (json["reminders"] as? [Int]) ?? (json["reminders"] as? [Any])?.compactMap { ($0 as? Int) ?? Int("\($0)") } ?? []
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
/// Posted whenever the persistent "banished calendars" set is mutated from
|
||||||
|
/// outside the active `CalendarStore` (e.g. by `AccountsView`). The store
|
||||||
|
/// listens for this in `CalendarHostView` and refreshes its filter.
|
||||||
|
static let banishedCalendarsChanged = Notification.Name("banishedCalendarsChanged")
|
||||||
|
|
||||||
|
/// Posted when the user taps the manual "sync with server" button in the
|
||||||
|
/// menu. `CalendarHostView` responds by invalidating the cache and
|
||||||
|
/// re-fetching events from the server.
|
||||||
|
static let manualSyncRequested = Notification.Name("manualSyncRequested")
|
||||||
|
}
|
||||||
|
|
||||||
enum CalViewType: String, CaseIterable {
|
enum CalViewType: String, CaseIterable {
|
||||||
case month, week, day, quarter, agenda
|
case month, week, day, quarter, agenda
|
||||||
|
|
||||||
@@ -39,17 +51,179 @@ class CalendarStore {
|
|||||||
var events: [CalEvent] = []
|
var events: [CalEvent] = []
|
||||||
var viewType: CalViewType = .month
|
var viewType: CalViewType = .month
|
||||||
var currentDate: Date = .now
|
var currentDate: Date = .now
|
||||||
|
// The month currently scrolled into view (month view). Lives in the store so
|
||||||
|
// the Liquid-Glass navigation title — read in the system toolbar — updates
|
||||||
|
// via @Observable tracking (a plain @State did not refresh the toolbar).
|
||||||
|
var visibleMonth: Date = .now
|
||||||
var isLoading = false
|
var isLoading = false
|
||||||
var isCachingBackground = false
|
var isCachingBackground = false
|
||||||
var lastError: String? = nil
|
var lastError: String? = nil
|
||||||
var weekStartsOnMonday = true
|
var weekStartsOnMonday = true
|
||||||
var writableCalendars: [WritableCalendar] = []
|
var writableCalendars: [WritableCalendar] = []
|
||||||
|
// When set, the calendar shows the group's combined overlay instead of the
|
||||||
|
// user's own events. nil = personal view.
|
||||||
|
var activeGroup: CalGroup? = nil
|
||||||
|
|
||||||
|
/// Set of `"source:calendarId"` keys the user has chosen to hide from the
|
||||||
|
/// calendar views. Persisted in UserDefaults as a JSON array. Events whose
|
||||||
|
/// key matches one of these are filtered out before being rendered.
|
||||||
|
var hiddenCalendarKeys: Set<String> = CalendarStore.loadHiddenKeys()
|
||||||
|
|
||||||
|
/// "Banished" calendars – like `hiddenCalendarKeys` but expressing a
|
||||||
|
/// stronger user intent: the calendar should not even appear in the quick
|
||||||
|
/// show/hide list. Re-activation happens in AccountsView.
|
||||||
|
var banishedCalendarKeys: Set<String> = CalendarStore.loadBanishedKeys()
|
||||||
|
|
||||||
|
/// Group-overlay visibility: which members' calendars (`gm:<userId>`) and the
|
||||||
|
/// group calendar (`gc`) are hidden in the combined view — like hiding
|
||||||
|
/// individual people in Outlook. In-memory; resets when leaving/switching a
|
||||||
|
/// group (the per-calendar hide/banish sets are for the personal view only).
|
||||||
|
var hiddenGroupKeys: Set<String> = []
|
||||||
|
|
||||||
|
static func groupMemberKey(_ ownerId: Int) -> String { "gm:\(ownerId)" }
|
||||||
|
static let groupCalendarKey = "gc"
|
||||||
|
|
||||||
// Cache bookkeeping
|
// Cache bookkeeping
|
||||||
private var cachedStart: Date? = nil
|
private var cachedStart: Date? = nil
|
||||||
private var cachedEnd: Date? = nil
|
private var cachedEnd: Date? = nil
|
||||||
private var allCachedEvents: [CalEvent] = []
|
private var allCachedEvents: [CalEvent] = []
|
||||||
|
|
||||||
|
// MARK: – Hidden-calendar persistence
|
||||||
|
|
||||||
|
private static let hiddenKeysDefaultsKey = "hiddenCalendarKeys"
|
||||||
|
|
||||||
|
private static func loadHiddenKeys() -> Set<String> {
|
||||||
|
guard let raw = UserDefaults.standard.string(forKey: hiddenKeysDefaultsKey),
|
||||||
|
let data = raw.data(using: .utf8),
|
||||||
|
let arr = try? JSONDecoder().decode([String].self, from: data)
|
||||||
|
else { return [] }
|
||||||
|
return Set(arr)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveHiddenKeys() {
|
||||||
|
let arr = Array(hiddenCalendarKeys)
|
||||||
|
if let data = try? JSONEncoder().encode(arr),
|
||||||
|
let s = String(data: data, encoding: .utf8) {
|
||||||
|
UserDefaults.standard.set(s, forKey: Self.hiddenKeysDefaultsKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle visibility of a single calendar and immediately refresh the
|
||||||
|
/// visible event list + widget snapshot.
|
||||||
|
func setCalendarHidden(_ key: String, hidden: Bool) {
|
||||||
|
if hidden { hiddenCalendarKeys.insert(key) } else { hiddenCalendarKeys.remove(key) }
|
||||||
|
saveHiddenKeys()
|
||||||
|
let (s, e) = rangeForCurrentView()
|
||||||
|
refreshFromCache(start: s, end: e)
|
||||||
|
publishWidgetSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the entire set (used by the filter sheet's bulk show/hide).
|
||||||
|
func setHiddenCalendars(_ keys: Set<String>) {
|
||||||
|
hiddenCalendarKeys = keys
|
||||||
|
saveHiddenKeys()
|
||||||
|
let (s, e) = rangeForCurrentView()
|
||||||
|
refreshFromCache(start: s, end: e)
|
||||||
|
publishWidgetSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle / replace group-overlay visibility (members or the group calendar).
|
||||||
|
func setGroupKeyHidden(_ key: String, hidden: Bool) {
|
||||||
|
if hidden { hiddenGroupKeys.insert(key) } else { hiddenGroupKeys.remove(key) }
|
||||||
|
let (s, e) = rangeForCurrentView()
|
||||||
|
refreshFromCache(start: s, end: e)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setHiddenGroupKeys(_ keys: Set<String>) {
|
||||||
|
hiddenGroupKeys = keys
|
||||||
|
let (s, e) = rangeForCurrentView()
|
||||||
|
refreshFromCache(start: s, end: e)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func calendarKey(source: String, calendarId: String) -> String {
|
||||||
|
// The events API returns `calendar_id` inconsistently: a raw numeric for
|
||||||
|
// CalDAV, but "<source>-<id>" for local / ical / google / homeassistant
|
||||||
|
// (e.g. "local-3", "google-5"). The filter UI keys off the numeric DB id,
|
||||||
|
// so strip any leading "<source>-" prefix to make event keys and filter
|
||||||
|
// keys comparable — otherwise local hiding/banishing silently does nothing
|
||||||
|
// for those sources.
|
||||||
|
var id = calendarId
|
||||||
|
let prefix = "\(source)-"
|
||||||
|
if id.hasPrefix(prefix) { id = String(id.dropFirst(prefix.count)) }
|
||||||
|
return "\(source):\(id)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Banished-calendar persistence
|
||||||
|
|
||||||
|
private static let banishedKeysDefaultsKey = "banishedCalendarKeys"
|
||||||
|
|
||||||
|
static func loadBanishedKeys() -> Set<String> {
|
||||||
|
guard let raw = UserDefaults.standard.string(forKey: banishedKeysDefaultsKey),
|
||||||
|
let data = raw.data(using: .utf8),
|
||||||
|
let arr = try? JSONDecoder().decode([String].self, from: data)
|
||||||
|
else { return [] }
|
||||||
|
return Set(arr)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveBanishedKeys(_ keys: Set<String>) {
|
||||||
|
let arr = Array(keys)
|
||||||
|
if let data = try? JSONEncoder().encode(arr),
|
||||||
|
let s = String(data: data, encoding: .utf8) {
|
||||||
|
UserDefaults.standard.set(s, forKey: banishedKeysDefaultsKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Move a calendar to / out of the banished set. Also clears any quick
|
||||||
|
/// hidden flag for that key – once banished, the dual state is redundant.
|
||||||
|
/// Posts `.banishedCalendarsChanged` so other views in the navigation
|
||||||
|
/// stack (e.g. AccountsView) stay in sync.
|
||||||
|
func setCalendarBanished(_ key: String, banished: Bool) {
|
||||||
|
if banished {
|
||||||
|
banishedCalendarKeys.insert(key)
|
||||||
|
hiddenCalendarKeys.remove(key)
|
||||||
|
} else {
|
||||||
|
banishedCalendarKeys.remove(key)
|
||||||
|
}
|
||||||
|
Self.saveBanishedKeys(banishedCalendarKeys)
|
||||||
|
saveHiddenKeys()
|
||||||
|
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
|
||||||
|
let (s, e) = rangeForCurrentView()
|
||||||
|
refreshFromCache(start: s, end: e)
|
||||||
|
publishWidgetSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the whole banished set (used when reconciling with the server's
|
||||||
|
/// `sidebar_hidden` flags). Persists, notifies, refreshes.
|
||||||
|
func setBanishedCalendars(_ keys: Set<String>) {
|
||||||
|
guard keys != banishedCalendarKeys else { return }
|
||||||
|
banishedCalendarKeys = keys
|
||||||
|
Self.saveBanishedKeys(keys)
|
||||||
|
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
|
||||||
|
let (s, e) = rangeForCurrentView()
|
||||||
|
refreshFromCache(start: s, end: e)
|
||||||
|
publishWidgetSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-read the banished set from UserDefaults – called when an external
|
||||||
|
/// view (AccountsView) mutated it. Refreshes visible events + widgets.
|
||||||
|
func syncBanishedFromDefaults() {
|
||||||
|
banishedCalendarKeys = Self.loadBanishedKeys()
|
||||||
|
let (s, e) = rangeForCurrentView()
|
||||||
|
refreshFromCache(start: s, end: e)
|
||||||
|
publishWidgetSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a `"source:calendarId"` key back into its parts.
|
||||||
|
static func parseCalendarKey(_ key: String) -> (source: String, id: Int)? {
|
||||||
|
guard let colon = key.firstIndex(of: ":") else { return nil }
|
||||||
|
let source = String(key[..<colon])
|
||||||
|
guard let id = Int(key[key.index(after: colon)...]) else { return nil }
|
||||||
|
return (source, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sources whose visibility is backed by the server's `sidebar_hidden`.
|
||||||
|
static let serverManagedSources: Set<String> = ["caldav", "google", "homeassistant"]
|
||||||
|
|
||||||
var userCalendar: Calendar {
|
var userCalendar: Calendar {
|
||||||
var cal = Calendar.current
|
var cal = Calendar.current
|
||||||
cal.firstWeekday = weekStartsOnMonday ? 2 : 1
|
cal.firstWeekday = weekStartsOnMonday ? 2 : 1
|
||||||
@@ -63,19 +237,61 @@ class CalendarStore {
|
|||||||
return cs <= start && ce >= end
|
return cs <= start && ce >= end
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fast in-memory refresh of `events` for the current visible range.
|
/// Republish the full cached event set, applying only visibility filters
|
||||||
/// Call this after navigation without hitting the network.
|
/// (hidden + banished). We deliberately do NOT slice by the current view's
|
||||||
|
/// date window: the user's chosen cache range is already loaded, and
|
||||||
|
/// scrolling within it must not make events vanish. Per-day / per-range
|
||||||
|
/// rendering is the responsibility of `events(on:)` / `events(in:)`.
|
||||||
|
/// `start` / `end` are kept in the signature for call-site clarity.
|
||||||
func refreshFromCache(start: Date, end: Date) {
|
func refreshFromCache(start: Date, end: Date) {
|
||||||
events = allCachedEvents.filter { ev in
|
_ = (start, end)
|
||||||
ev.startDate < end && ev.endDate > start
|
// In group overlay mode the per-calendar hide/banish toggles don't apply;
|
||||||
|
// instead honour the per-member / group-calendar toggles (hiddenGroupKeys).
|
||||||
|
if activeGroup != nil {
|
||||||
|
if hiddenGroupKeys.isEmpty {
|
||||||
|
events = allCachedEvents
|
||||||
|
} else {
|
||||||
|
events = allCachedEvents.filter { ev in
|
||||||
|
if ev.isGroupEvent { return !hiddenGroupKeys.contains(Self.groupCalendarKey) }
|
||||||
|
if let o = ev.owner { return !hiddenGroupKeys.contains(Self.groupMemberKey(o.id ?? -1)) }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
events = allCachedEvents.filter { ev in
|
||||||
|
let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId)
|
||||||
|
return !hiddenCalendarKeys.contains(key)
|
||||||
|
&& !banishedCalendarKeys.contains(key)
|
||||||
|
}
|
||||||
|
// Personal events drive local reminder notifications.
|
||||||
|
NotificationScheduler.reschedule(events: allCachedEvents)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recompute scheduled reminder notifications from the personal cache
|
||||||
|
/// (skipped while a group overlay is active).
|
||||||
|
func rescheduleNotifications() {
|
||||||
|
guard activeGroup == nil else { return }
|
||||||
|
NotificationScheduler.reschedule(events: allCachedEvents)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optimistically drop a just-deleted event from the cache so it disappears
|
||||||
|
/// from the UI immediately, without waiting for a server round-trip (HA
|
||||||
|
/// deletes can lag several seconds, and an immediate refetch could even
|
||||||
|
/// re-add it before the source propagated the deletion).
|
||||||
|
func removeCachedEvent(id: String) {
|
||||||
|
allCachedEvents.removeAll { $0.id == id }
|
||||||
|
events.removeAll { $0.id == id }
|
||||||
|
publishWidgetSnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – Network loading
|
// MARK: – Network loading
|
||||||
|
|
||||||
/// Load events for a specific range – skips network if already cached.
|
/// Load events for a specific range. Skips the network if already cached,
|
||||||
func loadEvents(api: CalendarrAPI, start: Date, end: Date) async {
|
/// unless `force` is set (used after create/edit to pull fresh server data
|
||||||
if isCached(start: start, end: end) {
|
/// for the visible range, bypassing the cache).
|
||||||
|
func loadEvents(api: CalendarrAPI, start: Date, end: Date, force: Bool = false) async {
|
||||||
|
if !force, isCached(start: start, end: end) {
|
||||||
refreshFromCache(start: start, end: end)
|
refreshFromCache(start: start, end: end)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -83,7 +299,7 @@ class CalendarStore {
|
|||||||
lastError = nil
|
lastError = nil
|
||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
do {
|
do {
|
||||||
let fetched = try await api.fetchEvents(start: start, end: end)
|
let fetched = try await fetchForMode(api: api, start: start, end: end)
|
||||||
mergeIntoCache(fetched, rangeStart: start, rangeEnd: end)
|
mergeIntoCache(fetched, rangeStart: start, rangeEnd: end)
|
||||||
refreshFromCache(start: start, end: end)
|
refreshFromCache(start: start, end: end)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -91,6 +307,41 @@ class CalendarStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch events for the current mode (personal vs. group overlay). Group
|
||||||
|
/// events go through the same cache/prefetch/refresh path as personal ones,
|
||||||
|
/// so the whole visible grid is covered (no "only the middle weeks" gaps).
|
||||||
|
private func fetchForMode(api: CalendarrAPI, start: Date, end: Date) async throws -> [CalEvent] {
|
||||||
|
if let g = activeGroup {
|
||||||
|
let combined = try await api.fetchGroupCombined(groupId: g.id, start: start, end: end)
|
||||||
|
return combined.map { decorateGroupEvent($0) }
|
||||||
|
}
|
||||||
|
return try await api.fetchEvents(start: start, end: end)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prefix a combined-view event with its owner (others) or 👥 + creator
|
||||||
|
/// (group calendar). Colour comes from the server's display_color.
|
||||||
|
private func decorateGroupEvent(_ ev: CalEvent) -> CalEvent {
|
||||||
|
// Prefer the server-decorated title (group icon + owner prefix) so web,
|
||||||
|
// iOS and Android render group events identically. `title` stays raw.
|
||||||
|
if let dt = ev.displayTitle, !dt.isEmpty {
|
||||||
|
var e = ev
|
||||||
|
e.title = dt
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
// Fallback for older servers without display_title.
|
||||||
|
var e = ev
|
||||||
|
let me = UserDefaults.standard.integer(forKey: "userId")
|
||||||
|
let groupIcon = activeGroup?.icon ?? "👥"
|
||||||
|
func first(_ s: String) -> String { s.split(separator: " ").first.map(String.init) ?? s }
|
||||||
|
if ev.isGroupEvent {
|
||||||
|
if let c = ev.creator, c.id != me { e.title = "\(groupIcon) \(first(c.displayName)): \(ev.title)" }
|
||||||
|
else { e.title = "\(groupIcon) \(ev.title)" }
|
||||||
|
} else if let o = ev.owner, o.id != me {
|
||||||
|
e.title = "\(first(o.displayName)): \(ev.title)"
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
/// Background prefetch for ±months around today – called once on startup.
|
/// Background prefetch for ±months around today – called once on startup.
|
||||||
func prefetchBackground(api: CalendarrAPI, months: Int) async {
|
func prefetchBackground(api: CalendarrAPI, months: Int) async {
|
||||||
let cal = userCalendar
|
let cal = userCalendar
|
||||||
@@ -102,7 +353,7 @@ class CalendarStore {
|
|||||||
isCachingBackground = true
|
isCachingBackground = true
|
||||||
defer { isCachingBackground = false }
|
defer { isCachingBackground = false }
|
||||||
do {
|
do {
|
||||||
let fetched = try await api.fetchEvents(start: start, end: end)
|
let fetched = try await fetchForMode(api: api, start: start, end: end)
|
||||||
mergeIntoCache(fetched, rangeStart: start, rangeEnd: end)
|
mergeIntoCache(fetched, rangeStart: start, rangeEnd: end)
|
||||||
// Refresh visible range from newly expanded cache
|
// Refresh visible range from newly expanded cache
|
||||||
let (vs, ve) = rangeForCurrentView()
|
let (vs, ve) = rangeForCurrentView()
|
||||||
@@ -113,11 +364,13 @@ class CalendarStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Trigger a full cache reload (e.g. when cache-range setting changes).
|
/// Trigger a full cache reload (e.g. when cache-range setting changes).
|
||||||
|
/// Intentionally keeps `events` intact so the UI stays populated while
|
||||||
|
/// the network fetch runs; `refreshFromCache` will swap in fresh data
|
||||||
|
/// atomically once it arrives.
|
||||||
func invalidateCache() {
|
func invalidateCache() {
|
||||||
cachedStart = nil
|
cachedStart = nil
|
||||||
cachedEnd = nil
|
cachedEnd = nil
|
||||||
allCachedEvents = []
|
allCachedEvents = []
|
||||||
events = []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func mergeIntoCache(_ newEvents: [CalEvent], rangeStart: Date, rangeEnd: Date) {
|
private func mergeIntoCache(_ newEvents: [CalEvent], rangeStart: Date, rangeEnd: Date) {
|
||||||
@@ -135,6 +388,53 @@ class CalendarStore {
|
|||||||
cachedStart = rangeStart
|
cachedStart = rangeStart
|
||||||
cachedEnd = rangeEnd
|
cachedEnd = rangeEnd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
publishWidgetSnapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a slim snapshot of the next ~6 weeks into the App-Group container
|
||||||
|
/// so the widget extension can render without a network call. 42 days
|
||||||
|
/// covers the worst-case month grid (6 rows × 7 cols) for the calendar
|
||||||
|
/// widget. Also asks the system to refresh the widget timeline.
|
||||||
|
private func publishWidgetSnapshot() {
|
||||||
|
let cal = userCalendar
|
||||||
|
let now = Date()
|
||||||
|
// Include the week before today so widgets that show the current week
|
||||||
|
// (e.g. "This Week", "Up Next + Calendar") have data for Monday–today.
|
||||||
|
let from = cal.date(byAdding: .day, value: -7, to: cal.startOfDay(for: now)) ?? now
|
||||||
|
let to = cal.date(byAdding: .day, value: 42, to: cal.startOfDay(for: now)) ?? from
|
||||||
|
let visible = allCachedEvents
|
||||||
|
.filter { ev in
|
||||||
|
let key = Self.calendarKey(source: ev.source, calendarId: ev.calendarId)
|
||||||
|
return ev.startDate < to && ev.endDate > from
|
||||||
|
&& !hiddenCalendarKeys.contains(key)
|
||||||
|
&& !banishedCalendarKeys.contains(key)
|
||||||
|
}
|
||||||
|
.sorted { $0.startDate < $1.startDate }
|
||||||
|
.prefix(500)
|
||||||
|
.map { ev in
|
||||||
|
WidgetEvent(id: ev.id,
|
||||||
|
title: ev.title,
|
||||||
|
start: ev.startDate,
|
||||||
|
end: ev.endDate,
|
||||||
|
isAllDay: ev.isAllDay,
|
||||||
|
colorHex: ev.effectiveColor,
|
||||||
|
location: ev.location)
|
||||||
|
}
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
let snap = WidgetSnapshot(
|
||||||
|
writtenAt: now,
|
||||||
|
events: Array(visible),
|
||||||
|
todayColorHex: defaults.string(forKey: "todayColor") ?? "#4285f4",
|
||||||
|
textColorHex: defaults.string(forKey: "textColor") ?? "#FFFFFF",
|
||||||
|
backgroundColorHex: defaults.string(forKey: "backgroundColor") ?? "#000000",
|
||||||
|
lineColorHex: defaults.string(forKey: "lineColor") ?? "#3A3A3C",
|
||||||
|
primaryColorHex: defaults.string(forKey: "primaryColor") ?? "#4285f4",
|
||||||
|
accentColorHex: defaults.string(forKey: "accentColor") ?? "#ea4335",
|
||||||
|
language: defaults.string(forKey: "appLanguage") ?? "system"
|
||||||
|
)
|
||||||
|
WidgetStore.write(snap)
|
||||||
|
WidgetTimelineNotifier.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – Writable calendars
|
// MARK: – Writable calendars
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ private let strings: [String: [String: String]] = [
|
|||||||
"menu.server": "Server",
|
"menu.server": "Server",
|
||||||
"menu.logout": "Abmelden",
|
"menu.logout": "Abmelden",
|
||||||
"menu.admin": "Admin",
|
"menu.admin": "Admin",
|
||||||
|
"menu.sync": "Mit Server synchronisieren",
|
||||||
|
"menu.sync.section": "Synchronisierung",
|
||||||
|
|
||||||
// Settings – chrome
|
// Settings – chrome
|
||||||
"settings.title": "Darstellung",
|
"settings.title": "Darstellung",
|
||||||
@@ -76,6 +78,9 @@ private let strings: [String: [String: String]] = [
|
|||||||
"settings.liquidglass": "Liquid Glass",
|
"settings.liquidglass": "Liquid Glass",
|
||||||
"settings.liquidglass.desc": "Verwendet die neue iOS\u{202F}26 Glasoptik mit transparenter Navigationsleiste",
|
"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.liquidglass.footer": "Änderung wirkt sofort – kein Neustart nötig.",
|
||||||
|
"settings.sync": "Einstellungen synchronisieren",
|
||||||
|
"settings.sync.desc": "Darstellung mit dem Server abgleichen",
|
||||||
|
"settings.sync.footer": "Wenn aktiv, werden Farben, Kontraste und Stundenhöhe mit dem Server abgeglichen (der Server hat Vorrang). Ansicht, erster Wochentag und das Ausgrauen vergangener Termine werden immer synchronisiert – auch wenn der Schalter aus ist.",
|
||||||
|
|
||||||
"settings.cache.header": "Vorladen",
|
"settings.cache.header": "Vorladen",
|
||||||
"settings.cache.title": "Vorladen",
|
"settings.cache.title": "Vorladen",
|
||||||
@@ -122,6 +127,46 @@ private let strings: [String: [String: String]] = [
|
|||||||
"settings.monday": "Montag",
|
"settings.monday": "Montag",
|
||||||
"settings.sunday": "Sonntag",
|
"settings.sunday": "Sonntag",
|
||||||
"settings.dimpast": "Vergangene Termine ausgrauen",
|
"settings.dimpast": "Vergangene Termine ausgrauen",
|
||||||
|
"settings.nav.profile": "Profil",
|
||||||
|
"settings.privacy": "Privatsphäre",
|
||||||
|
"settings.private_visibility": "Private Termine für Gruppen",
|
||||||
|
"settings.private_visibility.desc": "Wie private Termine für andere Gruppenmitglieder erscheinen",
|
||||||
|
"settings.private.busy": "Als „Beschäftigt“",
|
||||||
|
"settings.private.hidden": "Ausblenden",
|
||||||
|
"settings.calendars": "Geteilter Kalender",
|
||||||
|
"settings.group_visible": "Für Gruppen sichtbar",
|
||||||
|
"settings.group_visible.desc": "Wähle, welcher deiner Kalender für Gruppenmitglieder sichtbar ist",
|
||||||
|
"group.visible.none": "Keiner",
|
||||||
|
"profile.display_name": "Anzeigename",
|
||||||
|
"profile.login_name": "Login-Name",
|
||||||
|
"accounts.shared_by": "geteilt von %@",
|
||||||
|
"share.title": "Teilen",
|
||||||
|
"share.current": "Aktuelle Freigaben",
|
||||||
|
"share.none": "Noch nicht geteilt",
|
||||||
|
"share.add": "Benutzer hinzufügen",
|
||||||
|
"share.search": "Benutzer suchen…",
|
||||||
|
"share.permission": "Berechtigung",
|
||||||
|
"perm.read": "Nur lesen",
|
||||||
|
"perm.read_write": "Lesen & schreiben",
|
||||||
|
"ics.import": "Importieren",
|
||||||
|
"ics.export": "Exportieren",
|
||||||
|
"ics.import_result": "%d importiert, %d übersprungen",
|
||||||
|
"common.info": "Info",
|
||||||
|
"common.done": "Fertig",
|
||||||
|
"groups.title": "Gruppen",
|
||||||
|
"groups.personal": "Persönlich",
|
||||||
|
"groups.view_label": "Gruppenansicht",
|
||||||
|
"groups.exit": "Verlassen",
|
||||||
|
"groups.none": "Noch keine Gruppen",
|
||||||
|
"groups.combined_empty": "Keine Termine in diesem Zeitraum",
|
||||||
|
"group.create": "Gruppe erstellen",
|
||||||
|
"group.manage": "Gruppe verwalten",
|
||||||
|
"group.name": "Name",
|
||||||
|
"group.icon": "Icon",
|
||||||
|
"group.members": "Mitglieder",
|
||||||
|
"group.calendar": "Gruppenkalender",
|
||||||
|
"group.member_colors": "Farben der Mitglieder",
|
||||||
|
"group.delete": "Gruppe löschen",
|
||||||
|
|
||||||
"settings.hourheight": "Stundenhöhe",
|
"settings.hourheight": "Stundenhöhe",
|
||||||
"settings.hourheight.desc": "Platz pro Stunde in der Wochen- & Tagesansicht",
|
"settings.hourheight.desc": "Platz pro Stunde in der Wochen- & Tagesansicht",
|
||||||
@@ -196,6 +241,7 @@ private let strings: [String: [String: String]] = [
|
|||||||
// Event editor
|
// Event editor
|
||||||
"event.title_placeholder": "Titel",
|
"event.title_placeholder": "Titel",
|
||||||
"event.allday": "Ganztägig",
|
"event.allday": "Ganztägig",
|
||||||
|
"event.private": "Privat",
|
||||||
"event.start": "Start",
|
"event.start": "Start",
|
||||||
"event.end": "Ende",
|
"event.end": "Ende",
|
||||||
"event.location": "Ort",
|
"event.location": "Ort",
|
||||||
@@ -208,6 +254,8 @@ private let strings: [String: [String: String]] = [
|
|||||||
"event.reset_color": "Zurücksetzen",
|
"event.reset_color": "Zurücksetzen",
|
||||||
"event.edit_title": "Termin bearbeiten",
|
"event.edit_title": "Termin bearbeiten",
|
||||||
"event.new_title": "Neuer Termin",
|
"event.new_title": "Neuer Termin",
|
||||||
|
"event.copy_title": "Termin kopieren",
|
||||||
|
"event.copy_to": "In Kalender kopieren",
|
||||||
"event.save": "Sichern",
|
"event.save": "Sichern",
|
||||||
"event.add": "Hinzufügen",
|
"event.add": "Hinzufügen",
|
||||||
|
|
||||||
@@ -234,6 +282,20 @@ private let strings: [String: [String: String]] = [
|
|||||||
"accounts.ha.header": "Home Assistant",
|
"accounts.ha.header": "Home Assistant",
|
||||||
"accounts.ha.empty": "Keine Home Assistant-Konten",
|
"accounts.ha.empty": "Keine Home Assistant-Konten",
|
||||||
"accounts.ha.add": "Home Assistant hinzufügen",
|
"accounts.ha.add": "Home Assistant hinzufügen",
|
||||||
|
"profile.admin_note": "Hinweis: Die Benutzerverwaltung – sowohl das Erstellen als auch das Löschen von Benutzerkonten – erfolgt ausschließlich durch den Administrator des Servers.",
|
||||||
|
|
||||||
|
// Kalender-Filter (Sidebar)
|
||||||
|
"filter.title": "Kalender",
|
||||||
|
"filter.loading": "Lade Kalender…",
|
||||||
|
"filter.empty": "Keine Kalender vorhanden",
|
||||||
|
"filter.show_all": "Alle anzeigen",
|
||||||
|
"filter.hide_all": "Alle ausblenden",
|
||||||
|
"filter.button": "Kalender ein-/ausblenden",
|
||||||
|
"filter.banish": "Dauerhaft ausblenden",
|
||||||
|
"filter.banished_footer": "Dauerhaft ausgeblendete Kalender erscheinen unter »Konten & Kalender« und können dort wieder eingeblendet werden.",
|
||||||
|
"accounts.banished_header": "Ausgeblendete Kalender",
|
||||||
|
"accounts.banished_unhide": "Wieder einblenden",
|
||||||
|
"accounts.banished_unknown": "Unbekannter Kalender",
|
||||||
|
|
||||||
// CalDAV add sheet
|
// CalDAV add sheet
|
||||||
"caldav.section": "Konto-Details",
|
"caldav.section": "Konto-Details",
|
||||||
@@ -306,6 +368,8 @@ private let strings: [String: [String: String]] = [
|
|||||||
"menu.server": "Server",
|
"menu.server": "Server",
|
||||||
"menu.logout": "Sign out",
|
"menu.logout": "Sign out",
|
||||||
"menu.admin": "Admin",
|
"menu.admin": "Admin",
|
||||||
|
"menu.sync": "Sync with server",
|
||||||
|
"menu.sync.section": "Synchronization",
|
||||||
|
|
||||||
"settings.title": "Appearance",
|
"settings.title": "Appearance",
|
||||||
"settings.loading": "Loading settings…",
|
"settings.loading": "Loading settings…",
|
||||||
@@ -316,6 +380,9 @@ private let strings: [String: [String: String]] = [
|
|||||||
"settings.liquidglass": "Liquid Glass",
|
"settings.liquidglass": "Liquid Glass",
|
||||||
"settings.liquidglass.desc": "Uses the new iOS\u{202F}26 glass look with a translucent navigation bar",
|
"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.liquidglass.footer": "Takes effect immediately – no restart required.",
|
||||||
|
"settings.sync": "Sync settings",
|
||||||
|
"settings.sync.desc": "Keep appearance in sync with the server",
|
||||||
|
"settings.sync.footer": "When on, colors, contrasts and hour height sync with the server (the server wins). View, first weekday and dimming past events always sync – even when the switch is off.",
|
||||||
|
|
||||||
"settings.cache.header": "Preloading",
|
"settings.cache.header": "Preloading",
|
||||||
"settings.cache.title": "Preloading",
|
"settings.cache.title": "Preloading",
|
||||||
@@ -362,6 +429,46 @@ private let strings: [String: [String: String]] = [
|
|||||||
"settings.monday": "Monday",
|
"settings.monday": "Monday",
|
||||||
"settings.sunday": "Sunday",
|
"settings.sunday": "Sunday",
|
||||||
"settings.dimpast": "Dim past events",
|
"settings.dimpast": "Dim past events",
|
||||||
|
"settings.nav.profile": "Profile",
|
||||||
|
"settings.privacy": "Privacy",
|
||||||
|
"settings.private_visibility": "Private events for groups",
|
||||||
|
"settings.private_visibility.desc": "How your private events appear to other group members",
|
||||||
|
"settings.private.busy": "Show as \"Busy\"",
|
||||||
|
"settings.private.hidden": "Hide",
|
||||||
|
"settings.calendars": "Shared calendar",
|
||||||
|
"settings.group_visible": "Visible to groups",
|
||||||
|
"settings.group_visible.desc": "Choose which of your calendars group members can see",
|
||||||
|
"group.visible.none": "None",
|
||||||
|
"profile.display_name": "Display name",
|
||||||
|
"profile.login_name": "Login name",
|
||||||
|
"accounts.shared_by": "shared by %@",
|
||||||
|
"share.title": "Share",
|
||||||
|
"share.current": "Current shares",
|
||||||
|
"share.none": "Not shared yet",
|
||||||
|
"share.add": "Add user",
|
||||||
|
"share.search": "Search users…",
|
||||||
|
"share.permission": "Permission",
|
||||||
|
"perm.read": "Read only",
|
||||||
|
"perm.read_write": "Read & write",
|
||||||
|
"ics.import": "Import",
|
||||||
|
"ics.export": "Export",
|
||||||
|
"ics.import_result": "%d imported, %d skipped",
|
||||||
|
"common.info": "Info",
|
||||||
|
"common.done": "Done",
|
||||||
|
"groups.title": "Groups",
|
||||||
|
"groups.personal": "Personal",
|
||||||
|
"groups.view_label": "Group view",
|
||||||
|
"groups.exit": "Exit",
|
||||||
|
"groups.none": "No groups yet",
|
||||||
|
"groups.combined_empty": "No events in this period",
|
||||||
|
"group.create": "Create group",
|
||||||
|
"group.manage": "Manage group",
|
||||||
|
"group.name": "Name",
|
||||||
|
"group.icon": "Icon",
|
||||||
|
"group.members": "Members",
|
||||||
|
"group.calendar": "Group calendar",
|
||||||
|
"group.member_colors": "Member colours",
|
||||||
|
"group.delete": "Delete group",
|
||||||
|
|
||||||
"settings.hourheight": "Hour height",
|
"settings.hourheight": "Hour height",
|
||||||
"settings.hourheight.desc": "Space per hour in week & day view",
|
"settings.hourheight.desc": "Space per hour in week & day view",
|
||||||
@@ -436,6 +543,7 @@ private let strings: [String: [String: String]] = [
|
|||||||
// Event editor
|
// Event editor
|
||||||
"event.title_placeholder": "Title",
|
"event.title_placeholder": "Title",
|
||||||
"event.allday": "All-day",
|
"event.allday": "All-day",
|
||||||
|
"event.private": "Private",
|
||||||
"event.start": "Start",
|
"event.start": "Start",
|
||||||
"event.end": "End",
|
"event.end": "End",
|
||||||
"event.location": "Location",
|
"event.location": "Location",
|
||||||
@@ -448,6 +556,8 @@ private let strings: [String: [String: String]] = [
|
|||||||
"event.reset_color": "Reset",
|
"event.reset_color": "Reset",
|
||||||
"event.edit_title": "Edit event",
|
"event.edit_title": "Edit event",
|
||||||
"event.new_title": "New event",
|
"event.new_title": "New event",
|
||||||
|
"event.copy_title": "Copy event",
|
||||||
|
"event.copy_to": "Copy to calendar",
|
||||||
"event.save": "Save",
|
"event.save": "Save",
|
||||||
"event.add": "Add",
|
"event.add": "Add",
|
||||||
|
|
||||||
@@ -474,6 +584,20 @@ private let strings: [String: [String: String]] = [
|
|||||||
"accounts.ha.header": "Home Assistant",
|
"accounts.ha.header": "Home Assistant",
|
||||||
"accounts.ha.empty": "No Home Assistant accounts",
|
"accounts.ha.empty": "No Home Assistant accounts",
|
||||||
"accounts.ha.add": "Add Home Assistant",
|
"accounts.ha.add": "Add Home Assistant",
|
||||||
|
"profile.admin_note": "Note: User management — both the creation and deletion of user accounts — is handled exclusively by the server administrator.",
|
||||||
|
|
||||||
|
// Calendar filter (sidebar)
|
||||||
|
"filter.title": "Calendars",
|
||||||
|
"filter.loading": "Loading calendars…",
|
||||||
|
"filter.empty": "No calendars available",
|
||||||
|
"filter.show_all": "Show all",
|
||||||
|
"filter.hide_all": "Hide all",
|
||||||
|
"filter.button": "Show/hide calendars",
|
||||||
|
"filter.banish": "Hide permanently",
|
||||||
|
"filter.banished_footer": "Permanently hidden calendars appear under “Accounts & Calendars”, where you can show them again.",
|
||||||
|
"accounts.banished_header": "Hidden calendars",
|
||||||
|
"accounts.banished_unhide": "Show again",
|
||||||
|
"accounts.banished_unknown": "Unknown calendar",
|
||||||
|
|
||||||
// CalDAV add sheet
|
// CalDAV add sheet
|
||||||
"caldav.section": "Account details",
|
"caldav.section": "Account details",
|
||||||
|
|||||||
37
Calendarr iOS/Models/ReminderOptions.swift
Normal file
37
Calendarr iOS/Models/ReminderOptions.swift
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Reminder offset options (minutes before an event's start; 0 = at start) and
|
||||||
|
/// their localized labels. Shared by the event editor, the settings default
|
||||||
|
/// picker, and the notification scheduler so the choices stay consistent.
|
||||||
|
enum ReminderOptions {
|
||||||
|
/// Selectable offsets in minutes-before-start.
|
||||||
|
static let all: [Int] = [0, 5, 10, 15, 30, 60, 120, 1440, 2880]
|
||||||
|
|
||||||
|
private static func isEnglish(_ appLang: String) -> Bool {
|
||||||
|
if appLang == "en" { return true }
|
||||||
|
if appLang == "de" { return false }
|
||||||
|
return (Locale.current.language.languageCode?.identifier ?? "de").hasPrefix("en")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func label(_ minutes: Int, _ appLang: String) -> String {
|
||||||
|
let en = isEnglish(appLang)
|
||||||
|
if minutes <= 0 { return en ? "At start time" : "Zur Startzeit" }
|
||||||
|
if minutes < 60 { return en ? "\(minutes) min before" : "\(minutes) Min. vorher" }
|
||||||
|
if minutes < 1440 {
|
||||||
|
let h = minutes / 60
|
||||||
|
return en ? "\(h) h before" : "\(h) Std. vorher"
|
||||||
|
}
|
||||||
|
let d = minutes / 1440
|
||||||
|
return en ? "\(d) day\(d == 1 ? "" : "s") before" : "\(d) Tag\(d == 1 ? "" : "e") vorher"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func sectionTitle(_ l: String) -> String { isEnglish(l) ? "Reminders" : "Benachrichtigungen" }
|
||||||
|
static func addLabel(_ l: String) -> String { isEnglish(l) ? "Add reminder" : "Benachrichtigung hinzufügen" }
|
||||||
|
static func off(_ l: String) -> String { isEnglish(l) ? "Off" : "Aus" }
|
||||||
|
static func defaultTitle(_ l: String) -> String { isEnglish(l) ? "Default reminder" : "Standardbenachrichtigung" }
|
||||||
|
static func defaultFooter(_ l: String) -> String {
|
||||||
|
isEnglish(l)
|
||||||
|
? "Applies to all events unless an event has its own reminders."
|
||||||
|
: "Gilt für alle Termine, sofern ein Termin keine eigenen Benachrichtigungen hat."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,9 @@ class CalendarrAPI {
|
|||||||
throw APIError.decodingError
|
throw APIError.decodingError
|
||||||
}
|
}
|
||||||
let admin = user["is_admin"] as? Bool ?? false
|
let admin = user["is_admin"] as? Bool ?? false
|
||||||
|
// Persist id + display name for creator/owner comparisons and display.
|
||||||
|
UserDefaults.standard.set(user["id"] as? Int ?? 0, forKey: "userId")
|
||||||
|
UserDefaults.standard.set(user["display_name"] as? String ?? uname, forKey: "displayName")
|
||||||
return (token, uname, admin)
|
return (token, uname, admin)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +228,8 @@ class CalendarrAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date,
|
func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date,
|
||||||
isAllDay: Bool, location: String, description: String, color: String?) async throws -> CalEvent {
|
isAllDay: Bool, location: String, description: String, color: String?,
|
||||||
|
isPrivate: Bool = false, reminders: [Int]? = nil) async throws -> CalEvent {
|
||||||
var body: [String: Any] = [
|
var body: [String: Any] = [
|
||||||
"calendar_id": calendarId,
|
"calendar_id": calendarId,
|
||||||
"title": title,
|
"title": title,
|
||||||
@@ -233,9 +237,11 @@ class CalendarrAPI {
|
|||||||
"end": formatISO(end, allDay: isAllDay),
|
"end": formatISO(end, allDay: isAllDay),
|
||||||
"allDay": isAllDay,
|
"allDay": isAllDay,
|
||||||
"location": location,
|
"location": location,
|
||||||
"description": description
|
"description": description,
|
||||||
|
"private": isPrivate
|
||||||
]
|
]
|
||||||
if let c = color, !c.isEmpty { body["color"] = c }
|
if let c = color, !c.isEmpty { body["color"] = c }
|
||||||
|
if let reminders { body["reminders"] = reminders }
|
||||||
let data = try await request("/api/local/events", method: "POST", body: body)
|
let data = try await request("/api/local/events", method: "POST", body: body)
|
||||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
let ev = CalEvent.from(json: json) else { throw APIError.decodingError }
|
let ev = CalEvent.from(json: json) else { throw APIError.decodingError }
|
||||||
@@ -243,16 +249,19 @@ class CalendarrAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateLocalEvent(uid: String, title: String, start: Date, end: Date,
|
func updateLocalEvent(uid: String, title: String, start: Date, end: Date,
|
||||||
isAllDay: Bool, location: String, description: String, color: String?) async throws {
|
isAllDay: Bool, location: String, description: String, color: String?,
|
||||||
|
isPrivate: Bool = false, reminders: [Int]? = nil) async throws {
|
||||||
var body: [String: Any] = [
|
var body: [String: Any] = [
|
||||||
"title": title,
|
"title": title,
|
||||||
"start": formatISO(start, allDay: isAllDay),
|
"start": formatISO(start, allDay: isAllDay),
|
||||||
"end": formatISO(end, allDay: isAllDay),
|
"end": formatISO(end, allDay: isAllDay),
|
||||||
"allDay": isAllDay,
|
"allDay": isAllDay,
|
||||||
"location": location,
|
"location": location,
|
||||||
"description": description
|
"description": description,
|
||||||
|
"private": isPrivate
|
||||||
]
|
]
|
||||||
if let c = color { body["color"] = c }
|
if let c = color { body["color"] = c }
|
||||||
|
if let reminders { body["reminders"] = reminders }
|
||||||
_ = try await request("/api/local/events/\(uid)", method: "PUT", body: body)
|
_ = try await request("/api/local/events/\(uid)", method: "PUT", body: body)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,4 +374,194 @@ class CalendarrAPI {
|
|||||||
]
|
]
|
||||||
_ = try await request("/api/homeassistant/events", method: "POST", body: body)
|
_ = try await request("/api/homeassistant/events", method: "POST", body: body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete a Home Assistant calendar event.
|
||||||
|
/// `calendarId` is the numeric HA-calendar DB id; `uid` is the HA event uid.
|
||||||
|
func deleteHAEvent(calendarId: Int, uid: String) async throws {
|
||||||
|
// uid is a path segment and may contain "/" or other reserved chars.
|
||||||
|
let allowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/"))
|
||||||
|
let encUid = uid.addingPercentEncoding(withAllowedCharacters: allowed) ?? uid
|
||||||
|
_ = try await request("/api/homeassistant/events/\(calendarId)/\(encUid)", method: "DELETE")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Calendar visibility (sidebar_hidden)
|
||||||
|
|
||||||
|
/// Toggle a calendar's server-side visibility. Mirrors the web: hiding sets
|
||||||
|
/// `enabled=false, sidebar_hidden=true` (server then omits its events);
|
||||||
|
/// showing sets `enabled=true, sidebar_hidden=false`. Only CalDAV / Google /
|
||||||
|
/// Home Assistant have this flag; `local` / `ical` are a no-op.
|
||||||
|
func setCalendarSidebarHidden(source: String, calendarId: Int, hidden: Bool) async throws {
|
||||||
|
let path: String
|
||||||
|
switch source {
|
||||||
|
case "caldav": path = "/api/caldav/calendars/\(calendarId)"
|
||||||
|
case "google": path = "/api/google/calendars/\(calendarId)"
|
||||||
|
case "homeassistant": path = "/api/homeassistant/calendars/\(calendarId)"
|
||||||
|
default: return
|
||||||
|
}
|
||||||
|
_ = try await request(path, method: "PUT",
|
||||||
|
body: ["enabled": !hidden, "sidebar_hidden": hidden])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Calendar colour
|
||||||
|
|
||||||
|
func updateLocalCalendarColor(id: Int, color: String) async throws {
|
||||||
|
_ = try await request("/api/local/calendars/\(id)", method: "PUT", body: ["color": color])
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateICalColor(id: Int, color: String) async throws {
|
||||||
|
_ = try await request("/api/ical/subscriptions/\(id)", method: "PUT", body: ["color": color])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a per-calendar colour for server-managed sources (caldav/google/homeassistant).
|
||||||
|
func setCalendarColor(source: String, calendarId: Int, color: String) async throws {
|
||||||
|
let path: String
|
||||||
|
switch source {
|
||||||
|
case "caldav": path = "/api/caldav/calendars/\(calendarId)"
|
||||||
|
case "google": path = "/api/google/calendars/\(calendarId)"
|
||||||
|
case "homeassistant": path = "/api/homeassistant/calendars/\(calendarId)"
|
||||||
|
default: return
|
||||||
|
}
|
||||||
|
_ = try await request(path, method: "PUT", body: ["color": color])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Profile (display name / login name / email)
|
||||||
|
|
||||||
|
/// Update profile fields. A login-name change returns a fresh token (the old
|
||||||
|
/// one becomes invalid) — the caller must store the returned token.
|
||||||
|
func updateProfile(displayName: String?, username: String?, email: String?) async throws -> String? {
|
||||||
|
var body: [String: Any] = [:]
|
||||||
|
if let d = displayName { body["display_name"] = d }
|
||||||
|
if let u = username { body["username"] = u }
|
||||||
|
if let e = email { body["email"] = e } else { body["email"] = NSNull() }
|
||||||
|
let data = try await request("/api/profile/", method: "PUT", body: body)
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
return json?["access_token"] as? String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Targeted settings (avoid overwriting the whole AppSettings)
|
||||||
|
|
||||||
|
func updatePrivateVisibility(_ value: String) async throws {
|
||||||
|
_ = try await request("/api/settings/", method: "PUT", body: ["private_event_visibility": value])
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateGroupVisibleCalendar(_ calendarId: Int?) async throws {
|
||||||
|
_ = try await request("/api/settings/", method: "PUT",
|
||||||
|
body: ["group_visible_calendar_id": calendarId as Any? ?? NSNull()])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Sharing
|
||||||
|
|
||||||
|
func getUserDirectory() async throws -> [DirectoryUser] {
|
||||||
|
let data = try await request("/api/users/directory")
|
||||||
|
return (try? JSONDecoder().decode([DirectoryUser].self, from: data)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func getShares(calendarId: Int) async throws -> [CalendarShare] {
|
||||||
|
let data = try await request("/api/local/calendars/\(calendarId)/shares")
|
||||||
|
return (try? JSONDecoder().decode([CalendarShare].self, from: data)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func addShare(calendarId: Int, userId: Int, permission: String) async throws {
|
||||||
|
_ = try await request("/api/local/calendars/\(calendarId)/shares", method: "POST",
|
||||||
|
body: ["user_id": userId, "permission": permission])
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeShare(calendarId: Int, userId: Int) async throws {
|
||||||
|
_ = try await request("/api/local/calendars/\(calendarId)/shares/\(userId)", method: "DELETE")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Groups
|
||||||
|
|
||||||
|
func getGroups() async throws -> [CalGroup] {
|
||||||
|
let data = try await request("/api/groups/")
|
||||||
|
return (try? JSONDecoder().decode([CalGroup].self, from: data)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func getGroup(id: Int) async throws -> CalGroup {
|
||||||
|
let data = try await request("/api/groups/\(id)")
|
||||||
|
guard let g = try? JSONDecoder().decode(CalGroup.self, from: data) else { throw APIError.decodingError }
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
func createGroup(name: String, memberIds: [Int], icon: String?) async throws -> CalGroup {
|
||||||
|
var body: [String: Any] = ["name": name, "member_ids": memberIds]
|
||||||
|
if let icon { body["icon"] = icon }
|
||||||
|
let data = try await request("/api/groups/", method: "POST", body: body)
|
||||||
|
guard let g = try? JSONDecoder().decode(CalGroup.self, from: data) else { throw APIError.decodingError }
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateGroup(id: Int, name: String?, icon: String?) async throws {
|
||||||
|
var body: [String: Any] = [:]
|
||||||
|
if let name { body["name"] = name }
|
||||||
|
if let icon { body["icon"] = icon }
|
||||||
|
_ = try await request("/api/groups/\(id)", method: "PUT", body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteGroup(id: Int) async throws {
|
||||||
|
_ = try await request("/api/groups/\(id)", method: "DELETE")
|
||||||
|
}
|
||||||
|
|
||||||
|
func addGroupMember(groupId: Int, userId: Int) async throws {
|
||||||
|
_ = try await request("/api/groups/\(groupId)/members", method: "POST", body: ["user_id": userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeGroupMember(groupId: Int, userId: Int) async throws {
|
||||||
|
_ = try await request("/api/groups/\(groupId)/members/\(userId)", method: "DELETE")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setGroupMemberColor(groupId: Int, userId: Int, color: String) async throws {
|
||||||
|
_ = try await request("/api/groups/\(groupId)/members/\(userId)/color", method: "PUT",
|
||||||
|
body: ["color": color])
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchGroupCombined(groupId: Int, start: Date, end: Date) async throws -> [CalEvent] {
|
||||||
|
let iso = ISO8601DateFormatter()
|
||||||
|
iso.formatOptions = [.withInternetDateTime]
|
||||||
|
iso.timeZone = TimeZone(abbreviation: "UTC")
|
||||||
|
let s = iso.string(from: start)
|
||||||
|
let e = iso.string(from: end)
|
||||||
|
let sEnc = s.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? s
|
||||||
|
let eEnc = e.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? e
|
||||||
|
let data = try await request("/api/groups/\(groupId)/combined?start=\(sEnc)&end=\(eEnc)")
|
||||||
|
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let arr = root["events"] as? [[String: Any]] else { return [] }
|
||||||
|
return arr.compactMap { CalEvent.from(json: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – iCal import / export
|
||||||
|
|
||||||
|
/// Import a .ics file into a local calendar. Returns (imported, skipped, errors).
|
||||||
|
func importICS(calendarId: Int, fileURL: URL) async throws -> (imported: Int, skipped: Int, errors: [String]) {
|
||||||
|
guard let url = URL(string: baseURL + "/api/local/calendars/\(calendarId)/import") else { throw APIError.invalidURL }
|
||||||
|
let fileData = try Data(contentsOf: fileURL)
|
||||||
|
let boundary = "Boundary-\(UUID().uuidString)"
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.httpMethod = "POST"
|
||||||
|
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||||
|
var bodyData = Data()
|
||||||
|
let filename = fileURL.lastPathComponent.isEmpty ? "import.ics" : fileURL.lastPathComponent
|
||||||
|
bodyData.append("--\(boundary)\r\n".data(using: .utf8)!)
|
||||||
|
bodyData.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
|
||||||
|
bodyData.append("Content-Type: text/calendar\r\n\r\n".data(using: .utf8)!)
|
||||||
|
bodyData.append(fileData)
|
||||||
|
bodyData.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
|
||||||
|
req.httpBody = bodyData
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: req)
|
||||||
|
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
if status == 401 { throw APIError.unauthorized }
|
||||||
|
if status >= 400 {
|
||||||
|
let msg = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["detail"] as? String ?? "Fehler \(status)"
|
||||||
|
throw APIError.serverError(msg)
|
||||||
|
}
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
let errs = (json?["errors"] as? [String]) ?? []
|
||||||
|
return (json?["imported"] as? Int ?? 0, json?["skipped"] as? Int ?? 0, errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export a local calendar as raw .ics bytes.
|
||||||
|
func exportICS(calendarId: Int) async throws -> Data {
|
||||||
|
return try await request("/api/local/calendars/\(calendarId)/export")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
65
Calendarr iOS/Services/NotificationScheduler.swift
Normal file
65
Calendarr iOS/Services/NotificationScheduler.swift
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
/// Posted when the default-reminder setting changes, so the calendar host
|
||||||
|
/// can recompute the scheduled local notifications from its cached events.
|
||||||
|
static let rescheduleReminders = Notification.Name("rescheduleReminders")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schedules local OS notifications for upcoming events. Per-event reminders
|
||||||
|
/// (local events) take precedence; otherwise the user's default reminder applies
|
||||||
|
/// to every event (incl. external). Re-run whenever events or the default change.
|
||||||
|
enum NotificationScheduler {
|
||||||
|
|
||||||
|
static func requestAuthorizationIfNeeded() {
|
||||||
|
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recompute and (re)schedule notifications from the given events. The iOS
|
||||||
|
/// pending-notification cap is 64, so only the soonest are scheduled.
|
||||||
|
static func reschedule(events: [CalEvent]) {
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
center.getNotificationSettings { settings in
|
||||||
|
guard settings.authorizationStatus == .authorized
|
||||||
|
|| settings.authorizationStatus == .provisional else { return }
|
||||||
|
|
||||||
|
let defaultMin = (UserDefaults.standard.object(forKey: "defaultReminderMinutes") as? Int) ?? -1
|
||||||
|
let now = Date()
|
||||||
|
var pending: [(fire: Date, event: CalEvent)] = []
|
||||||
|
for ev in events {
|
||||||
|
let offsets = ev.reminders.isEmpty
|
||||||
|
? (defaultMin >= 0 ? [defaultMin] : [])
|
||||||
|
: ev.reminders
|
||||||
|
for m in offsets {
|
||||||
|
let fire = ev.startDate.addingTimeInterval(-Double(m) * 60)
|
||||||
|
if fire > now { pending.append((fire, ev)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pending.sort { $0.fire < $1.fire }
|
||||||
|
let limited = pending.prefix(60) // stay safely under the 64 system cap
|
||||||
|
|
||||||
|
center.removeAllPendingNotificationRequests()
|
||||||
|
for item in limited {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = item.event.title
|
||||||
|
content.body = bodyText(item.event)
|
||||||
|
content.sound = .default
|
||||||
|
let comps = Calendar.current.dateComponents(
|
||||||
|
[.year, .month, .day, .hour, .minute, .second], from: item.fire)
|
||||||
|
let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: false)
|
||||||
|
center.add(UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: trigger))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func bodyText(_ ev: CalEvent) -> String {
|
||||||
|
var parts: [String] = []
|
||||||
|
if !ev.isAllDay {
|
||||||
|
let f = DateFormatter(); f.timeStyle = .short; f.dateStyle = .none
|
||||||
|
parts.append(f.string(from: ev.startDate))
|
||||||
|
}
|
||||||
|
if !ev.location.isEmpty { parts.append(ev.location) }
|
||||||
|
return parts.joined(separator: " · ")
|
||||||
|
}
|
||||||
|
}
|
||||||
157
Calendarr iOS/Services/SettingsSync.swift
Normal file
157
Calendarr iOS/Services/SettingsSync.swift
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
/// Posted after a successful pull applied new settings to UserDefaults, so
|
||||||
|
/// views holding live state (CalendarHostView → store, widgets) can react.
|
||||||
|
static let settingsDidChange = Notification.Name("settingsDidChange")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two-way synchronisation of appearance/behaviour settings between the app and
|
||||||
|
/// the Calendarr server. The server is treated as the source of truth on pull;
|
||||||
|
/// local edits are pushed immediately so the server then holds the newest value.
|
||||||
|
///
|
||||||
|
/// Two groups:
|
||||||
|
/// - **optional** (colors, contrasts, hour height) only sync when the user has
|
||||||
|
/// enabled the `settingsSync` toggle.
|
||||||
|
/// - **always** (default view, week start, dim past events) sync regardless of
|
||||||
|
/// the toggle, because they describe how the user expects the calendar to be
|
||||||
|
/// computed/presented everywhere.
|
||||||
|
enum SettingsSync {
|
||||||
|
|
||||||
|
// MARK: – UserDefaults keys
|
||||||
|
|
||||||
|
enum Key {
|
||||||
|
// optional group
|
||||||
|
static let primaryColor = "primaryColor"
|
||||||
|
static let accentColor = "accentColor"
|
||||||
|
static let todayColor = "todayColor"
|
||||||
|
static let textColor = "textColor"
|
||||||
|
static let backgroundColor = "backgroundColor"
|
||||||
|
static let lineColor = "lineColor"
|
||||||
|
static let monthDividerColor = "monthDividerColor"
|
||||||
|
static let monthLabelColor = "monthLabelColor"
|
||||||
|
static let textContrast = "textContrast"
|
||||||
|
static let lineContrast = "lineContrast"
|
||||||
|
static let hourHeight = "hourHeight"
|
||||||
|
// always group
|
||||||
|
static let defaultView = "defaultView"
|
||||||
|
static let weekStartDay = "weekStartDay"
|
||||||
|
static let dimPastEvents = "dimPastEvents"
|
||||||
|
static let defaultReminder = "defaultReminderMinutes" // Int, -1 = off
|
||||||
|
// master switch
|
||||||
|
static let enabled = "settingsSync"
|
||||||
|
}
|
||||||
|
|
||||||
|
static var isEnabled: Bool { UserDefaults.standard.bool(forKey: Key.enabled) }
|
||||||
|
|
||||||
|
// MARK: – Defaults (mirror the historical hard-coded values)
|
||||||
|
|
||||||
|
private static func int(_ key: String, _ fallback: Int) -> Int {
|
||||||
|
let v = UserDefaults.standard.object(forKey: key) as? Int
|
||||||
|
return v ?? fallback
|
||||||
|
}
|
||||||
|
private static func str(_ key: String, _ fallback: String) -> String {
|
||||||
|
UserDefaults.standard.string(forKey: key) ?? fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Build AppSettings from local UserDefaults
|
||||||
|
|
||||||
|
static func currentSettings() -> AppSettings {
|
||||||
|
var s = AppSettings()
|
||||||
|
s.primaryColor = str(Key.primaryColor, "#4285f4")
|
||||||
|
s.accentColor = str(Key.accentColor, "#ea4335")
|
||||||
|
s.todayColor = str(Key.todayColor, "#4285f4")
|
||||||
|
s.textColor = str(Key.textColor, "#FFFFFF")
|
||||||
|
s.backgroundColor = str(Key.backgroundColor, "#000000")
|
||||||
|
s.lineColor = str(Key.lineColor, "#3A3A3C")
|
||||||
|
s.monthDividerColor = str(Key.monthDividerColor, "#7090c0")
|
||||||
|
s.monthLabelColor = str(Key.monthLabelColor, "#7090c0")
|
||||||
|
s.textContrast = int(Key.textContrast, 3)
|
||||||
|
s.lineContrast = int(Key.lineContrast, 3)
|
||||||
|
s.hourHeight = int(Key.hourHeight, 60)
|
||||||
|
s.defaultView = str(Key.defaultView, "month")
|
||||||
|
s.weekStartDay = str(Key.weekStartDay, "monday")
|
||||||
|
s.dimPastEvents = UserDefaults.standard.bool(forKey: Key.dimPastEvents)
|
||||||
|
let rem = int(Key.defaultReminder, -1)
|
||||||
|
s.defaultReminderMinutes = rem < 0 ? nil : rem
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Apply a server snapshot to local UserDefaults
|
||||||
|
|
||||||
|
/// Always writes the "always" trio. Writes the optional group only when
|
||||||
|
/// `includeOptional` is true.
|
||||||
|
static func apply(_ s: AppSettings, includeOptional: Bool) {
|
||||||
|
let d = UserDefaults.standard
|
||||||
|
// always group
|
||||||
|
d.set(s.defaultView, forKey: Key.defaultView)
|
||||||
|
d.set(s.weekStartDay, forKey: Key.weekStartDay)
|
||||||
|
d.set(s.dimPastEvents, forKey: Key.dimPastEvents)
|
||||||
|
d.set(s.defaultReminderMinutes ?? -1, forKey: Key.defaultReminder)
|
||||||
|
guard includeOptional else { return }
|
||||||
|
// NOTE: textColor / backgroundColor / lineColor are intentionally NOT
|
||||||
|
// synced – the server has no columns for them (iOS-only). Writing the
|
||||||
|
// resilient-decoded defaults here would wipe the user's local choices.
|
||||||
|
d.set(s.primaryColor, forKey: Key.primaryColor)
|
||||||
|
d.set(s.accentColor, forKey: Key.accentColor)
|
||||||
|
d.set(s.todayColor, forKey: Key.todayColor)
|
||||||
|
d.set(s.monthDividerColor, forKey: Key.monthDividerColor)
|
||||||
|
d.set(s.monthLabelColor, forKey: Key.monthLabelColor)
|
||||||
|
d.set(s.textContrast, forKey: Key.textContrast)
|
||||||
|
d.set(s.lineContrast, forKey: Key.lineContrast)
|
||||||
|
d.set(s.hourHeight, forKey: Key.hourHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Pull
|
||||||
|
|
||||||
|
/// Fetch the server's settings and apply them locally (server wins).
|
||||||
|
static func pull(api: CalendarrAPI) async {
|
||||||
|
guard let server = try? await api.getSettings() else { return }
|
||||||
|
apply(server, includeOptional: isEnabled)
|
||||||
|
await MainActor.run {
|
||||||
|
NotificationCenter.default.post(name: .settingsDidChange, object: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Push (debounced)
|
||||||
|
|
||||||
|
private static var pushTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
/// Schedule a debounced push. Repeated calls (e.g. while dragging a colour
|
||||||
|
/// slider) collapse into a single network write ~1.2 s after the last edit.
|
||||||
|
static func push(api: CalendarrAPI) {
|
||||||
|
pushTask?.cancel()
|
||||||
|
pushTask = Task {
|
||||||
|
try? await Task.sleep(for: .milliseconds(1200))
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
await performPush(api: api)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-modify-write: start from the server's current settings so that,
|
||||||
|
/// when the optional group is NOT being synced, the server's colours stay
|
||||||
|
/// intact. Overwrite the trio always, the optional group only if enabled.
|
||||||
|
private static func performPush(api: CalendarrAPI) async {
|
||||||
|
guard var merged = try? await api.getSettings() else { return }
|
||||||
|
let local = currentSettings()
|
||||||
|
// always group
|
||||||
|
merged.defaultView = local.defaultView
|
||||||
|
merged.weekStartDay = local.weekStartDay
|
||||||
|
merged.dimPastEvents = local.dimPastEvents
|
||||||
|
merged.defaultReminderMinutes = local.defaultReminderMinutes
|
||||||
|
if isEnabled {
|
||||||
|
merged.primaryColor = local.primaryColor
|
||||||
|
merged.accentColor = local.accentColor
|
||||||
|
merged.todayColor = local.todayColor
|
||||||
|
merged.textColor = local.textColor
|
||||||
|
merged.backgroundColor = local.backgroundColor
|
||||||
|
merged.lineColor = local.lineColor
|
||||||
|
merged.monthDividerColor = local.monthDividerColor
|
||||||
|
merged.monthLabelColor = local.monthLabelColor
|
||||||
|
merged.textContrast = local.textContrast
|
||||||
|
merged.lineContrast = local.lineContrast
|
||||||
|
merged.hourHeight = local.hourHeight
|
||||||
|
}
|
||||||
|
try? await api.updateSettings(merged)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
struct AccountsView: View {
|
struct AccountsView: View {
|
||||||
let api: CalendarrAPI
|
let api: CalendarrAPI
|
||||||
@@ -14,6 +15,14 @@ struct AccountsView: View {
|
|||||||
@State private var showAddICal = false
|
@State private var showAddICal = false
|
||||||
@State private var showAddHA = false
|
@State private var showAddHA = false
|
||||||
@State private var errorAlert: String?
|
@State private var errorAlert: String?
|
||||||
|
@State private var banishedKeys: Set<String> = CalendarStore.loadBanishedKeys()
|
||||||
|
|
||||||
|
// Sharing / import / export
|
||||||
|
@State private var shareCalId: Int?
|
||||||
|
@State private var showImporter = false
|
||||||
|
@State private var importTargetCalId: Int?
|
||||||
|
@State private var exportDoc: ExportedICS?
|
||||||
|
@State private var infoMessage: String?
|
||||||
|
|
||||||
@AppStorage("appLanguage") private var appLang = "system"
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
|
|
||||||
@@ -24,6 +33,7 @@ struct AccountsView: View {
|
|||||||
ProgressView(L10n.t("accounts.loading", appLang))
|
ProgressView(L10n.t("accounts.loading", appLang))
|
||||||
} else {
|
} else {
|
||||||
List {
|
List {
|
||||||
|
if !banishedKeys.isEmpty { banishedSection }
|
||||||
caldavSection
|
caldavSection
|
||||||
localSection
|
localSection
|
||||||
icalSection
|
icalSection
|
||||||
@@ -63,10 +73,55 @@ struct AccountsView: View {
|
|||||||
}, message: {
|
}, message: {
|
||||||
Text(errorAlert ?? "")
|
Text(errorAlert ?? "")
|
||||||
})
|
})
|
||||||
|
.sheet(item: Binding(get: { shareCalId.map { IdentifiableInt(id: $0) } },
|
||||||
|
set: { shareCalId = $0?.id })) { wrap in
|
||||||
|
SharingView(api: api, calendarId: wrap.id)
|
||||||
|
}
|
||||||
|
.sheet(item: $exportDoc) { doc in
|
||||||
|
ActivityView(items: [doc.url])
|
||||||
|
}
|
||||||
|
.fileImporter(isPresented: $showImporter,
|
||||||
|
allowedContentTypes: [UTType(filenameExtension: "ics") ?? .data, .data],
|
||||||
|
allowsMultipleSelection: false) { result in
|
||||||
|
Task { await handleImport(result) }
|
||||||
|
}
|
||||||
|
.alert(L10n.t("common.info", appLang), isPresented: .constant(infoMessage != nil), actions: {
|
||||||
|
Button(L10n.t("common.ok", appLang)) { infoMessage = nil }
|
||||||
|
}, message: { Text(infoMessage ?? "") })
|
||||||
}
|
}
|
||||||
.task { await load() }
|
.task { await load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func exportCalendar(_ cal: LocalCalendar) async {
|
||||||
|
do {
|
||||||
|
let data = try await api.exportICS(calendarId: cal.id)
|
||||||
|
let safe = cal.name.components(separatedBy: CharacterSet.alphanumerics.inverted).joined(separator: "_")
|
||||||
|
let url = FileManager.default.temporaryDirectory.appendingPathComponent("\(safe.isEmpty ? "calendar" : safe).ics")
|
||||||
|
try data.write(to: url)
|
||||||
|
exportDoc = ExportedICS(url: url)
|
||||||
|
} catch {
|
||||||
|
errorAlert = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleImport(_ result: Result<[URL], Error>) async {
|
||||||
|
guard let calId = importTargetCalId else { return }
|
||||||
|
switch result {
|
||||||
|
case .success(let urls):
|
||||||
|
guard let url = urls.first else { return }
|
||||||
|
let scoped = url.startAccessingSecurityScopedResource()
|
||||||
|
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
|
||||||
|
do {
|
||||||
|
let r = try await api.importICS(calendarId: calId, fileURL: url)
|
||||||
|
infoMessage = String(format: L10n.t("ics.import_result", appLang), r.imported, r.skipped)
|
||||||
|
} catch {
|
||||||
|
errorAlert = error.localizedDescription
|
||||||
|
}
|
||||||
|
case .failure(let err):
|
||||||
|
errorAlert = err.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: – Sections
|
// MARK: – Sections
|
||||||
|
|
||||||
var caldavSection: some View {
|
var caldavSection: some View {
|
||||||
@@ -76,16 +131,22 @@ struct AccountsView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
ForEach(caldavAccounts) { acc in
|
ForEach(caldavAccounts) { acc in
|
||||||
HStack {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Circle()
|
HStack {
|
||||||
.fill(Color(hex: acc.color))
|
Circle().fill(Color(hex: acc.color)).frame(width: 12, height: 12)
|
||||||
.frame(width: 12, height: 12)
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
Text(acc.name).font(.body)
|
||||||
Text(acc.name).font(.body)
|
Text(acc.url).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||||
Text(acc.url)
|
}
|
||||||
.font(.caption)
|
}
|
||||||
.foregroundStyle(.secondary)
|
ForEach(acc.calendars ?? []) { cal in
|
||||||
.lineLimit(1)
|
HStack {
|
||||||
|
CalendarColorDot(hex: cal.color ?? acc.color) { hex in
|
||||||
|
try? await api.setCalendarColor(source: "caldav", calendarId: cal.id, color: hex)
|
||||||
|
}
|
||||||
|
Text(cal.name).font(.callout)
|
||||||
|
}
|
||||||
|
.padding(.leading, 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,10 +169,35 @@ struct AccountsView: View {
|
|||||||
} else {
|
} else {
|
||||||
ForEach(localCalendars) { cal in
|
ForEach(localCalendars) { cal in
|
||||||
HStack {
|
HStack {
|
||||||
Circle()
|
CalendarColorDot(hex: cal.color, editable: cal.owned) { hex in
|
||||||
.fill(Color(hex: cal.color))
|
try? await api.updateLocalCalendarColor(id: cal.id, color: hex)
|
||||||
.frame(width: 12, height: 12)
|
}
|
||||||
Text(cal.name)
|
Text(cal.name)
|
||||||
|
if cal.group {
|
||||||
|
Image(systemName: "person.2.fill").font(.caption2).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if !cal.owned, let by = cal.sharedBy {
|
||||||
|
Text(String(format: L10n.t("accounts.shared_by", appLang), by))
|
||||||
|
.font(.caption2).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Menu {
|
||||||
|
if cal.owned && !cal.group {
|
||||||
|
Button { shareCalId = cal.id } label: {
|
||||||
|
Label(L10n.t("share.title", appLang), systemImage: "person.crop.circle.badge.plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cal.owned || cal.permission == "read_write" {
|
||||||
|
Button { importTargetCalId = cal.id; showImporter = true } label: {
|
||||||
|
Label(L10n.t("ics.import", appLang), systemImage: "square.and.arrow.down")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button { Task { await exportCalendar(cal) } } label: {
|
||||||
|
Label(L10n.t("ics.export", appLang), systemImage: "square.and.arrow.up")
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDelete { offsets in
|
.onDelete { offsets in
|
||||||
@@ -133,9 +219,9 @@ struct AccountsView: View {
|
|||||||
} else {
|
} else {
|
||||||
ForEach(icalSubs) { sub in
|
ForEach(icalSubs) { sub in
|
||||||
HStack {
|
HStack {
|
||||||
Circle()
|
CalendarColorDot(hex: sub.color) { hex in
|
||||||
.fill(Color(hex: sub.color))
|
try? await api.updateICalColor(id: sub.id, color: hex)
|
||||||
.frame(width: 12, height: 12)
|
}
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(sub.name).font(.body)
|
Text(sub.name).font(.body)
|
||||||
Text(String(format: L10n.t("accounts.ical.every", appLang), sub.refreshMinutes))
|
Text(String(format: L10n.t("accounts.ical.every", appLang), sub.refreshMinutes))
|
||||||
@@ -162,10 +248,20 @@ struct AccountsView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
ForEach(googleAccounts) { acc in
|
ForEach(googleAccounts) { acc in
|
||||||
HStack {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Image(systemName: "g.circle.fill")
|
HStack {
|
||||||
.foregroundStyle(.red)
|
Image(systemName: "g.circle.fill").foregroundStyle(.red)
|
||||||
Text(acc.email)
|
Text(acc.email)
|
||||||
|
}
|
||||||
|
ForEach(acc.calendars ?? []) { cal in
|
||||||
|
HStack {
|
||||||
|
CalendarColorDot(hex: cal.color ?? "#4285f4") { hex in
|
||||||
|
try? await api.setCalendarColor(source: "google", calendarId: cal.id, color: hex)
|
||||||
|
}
|
||||||
|
Text(cal.name).font(.callout)
|
||||||
|
}
|
||||||
|
.padding(.leading, 8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDelete { offsets in
|
.onDelete { offsets in
|
||||||
@@ -180,6 +276,86 @@ struct AccountsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var banishedSection: some View {
|
||||||
|
Section {
|
||||||
|
ForEach(Array(banishedKeys).sorted(), id: \.self) { key in
|
||||||
|
let info = resolveBanished(key)
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Circle()
|
||||||
|
.fill(Color(hex: info.colorHex))
|
||||||
|
.frame(width: 12, height: 12)
|
||||||
|
.opacity(0.5)
|
||||||
|
Text(info.name)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Button(L10n.t("accounts.banished_unhide", appLang)) {
|
||||||
|
unbanish(key)
|
||||||
|
}
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text(L10n.t("accounts.banished_header", appLang))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-show a banished calendar. For server-backed sources this clears the
|
||||||
|
/// server's sidebar_hidden (re-enabling the calendar); for local/ical it's
|
||||||
|
/// just the local set.
|
||||||
|
private func unbanish(_ key: String) {
|
||||||
|
banishedKeys.remove(key)
|
||||||
|
CalendarStore.saveBanishedKeys(banishedKeys)
|
||||||
|
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
|
||||||
|
if let parsed = CalendarStore.parseCalendarKey(key),
|
||||||
|
CalendarStore.serverManagedSources.contains(parsed.source) {
|
||||||
|
// The server excluded this calendar's events while hidden, so they
|
||||||
|
// aren't in the cache. Re-enable on the server, then force a refetch
|
||||||
|
// so the events actually reappear without a manual sync.
|
||||||
|
Task {
|
||||||
|
try? await api.setCalendarSidebarHidden(source: parsed.source, calendarId: parsed.id, hidden: false)
|
||||||
|
NotificationCenter.default.post(name: .manualSyncRequested, object: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveBanished(_ key: String) -> (name: String, colorHex: String) {
|
||||||
|
let parts = key.split(separator: ":", maxSplits: 1).map(String.init)
|
||||||
|
guard parts.count == 2, let id = Int(parts[1]) else {
|
||||||
|
return (L10n.t("accounts.banished_unknown", appLang), "#888888")
|
||||||
|
}
|
||||||
|
switch parts[0] {
|
||||||
|
case "local":
|
||||||
|
if let c = localCalendars.first(where: { $0.id == id }) {
|
||||||
|
return (c.name, c.color)
|
||||||
|
}
|
||||||
|
case "caldav":
|
||||||
|
for acc in caldavAccounts {
|
||||||
|
if let c = acc.calendars?.first(where: { $0.id == id }) {
|
||||||
|
return ("\(acc.name) – \(c.name)", c.color ?? acc.color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "ical":
|
||||||
|
if let s = icalSubs.first(where: { $0.id == id }) {
|
||||||
|
return (s.name, s.color)
|
||||||
|
}
|
||||||
|
case "google":
|
||||||
|
for acc in googleAccounts {
|
||||||
|
if let c = acc.calendars?.first(where: { $0.id == id }) {
|
||||||
|
return ("\(acc.email) – \(c.name)", c.color ?? "#4285f4")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "homeassistant":
|
||||||
|
for acc in haAccounts {
|
||||||
|
if let c = acc.calendars?.first(where: { $0.id == id }) {
|
||||||
|
return ("\(acc.name) – \(c.name)", c.color ?? "#46bdc6")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
return (L10n.t("accounts.banished_unknown", appLang), "#888888")
|
||||||
|
}
|
||||||
|
|
||||||
var haSection: some View {
|
var haSection: some View {
|
||||||
Section {
|
Section {
|
||||||
if haAccounts.isEmpty {
|
if haAccounts.isEmpty {
|
||||||
@@ -187,12 +363,20 @@ struct AccountsView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
ForEach(haAccounts) { acc in
|
ForEach(haAccounts) { acc in
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text(acc.name).font(.body)
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(acc.url)
|
Text(acc.name).font(.body)
|
||||||
.font(.caption)
|
Text(acc.url).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||||
.foregroundStyle(.secondary)
|
}
|
||||||
.lineLimit(1)
|
ForEach(acc.calendars ?? []) { cal in
|
||||||
|
HStack {
|
||||||
|
CalendarColorDot(hex: cal.color ?? "#03a9f4") { hex in
|
||||||
|
try? await api.setCalendarColor(source: "homeassistant", calendarId: cal.id, color: hex)
|
||||||
|
}
|
||||||
|
Text(cal.name).font(.callout)
|
||||||
|
}
|
||||||
|
.padding(.leading, 8)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDelete { offsets in
|
.onDelete { offsets in
|
||||||
@@ -210,12 +394,29 @@ struct AccountsView: View {
|
|||||||
|
|
||||||
private func load() async {
|
private func load() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
|
banishedKeys = CalendarStore.loadBanishedKeys()
|
||||||
async let c = (try? await api.getCalDAVAccounts()) ?? []
|
async let c = (try? await api.getCalDAVAccounts()) ?? []
|
||||||
async let l = (try? await api.getLocalCalendars()) ?? []
|
async let l = (try? await api.getLocalCalendars()) ?? []
|
||||||
async let i = (try? await api.getICalSubscriptions()) ?? []
|
async let i = (try? await api.getICalSubscriptions()) ?? []
|
||||||
async let g = (try? await api.getGoogleAccounts()) ?? []
|
async let g = (try? await api.getGoogleAccounts()) ?? []
|
||||||
async let h = (try? await api.getHomeAssistantAccounts()) ?? []
|
async let h = (try? await api.getHomeAssistantAccounts()) ?? []
|
||||||
(caldavAccounts, localCalendars, icalSubs, googleAccounts, haAccounts) = await (c, l, i, g, h)
|
(caldavAccounts, localCalendars, icalSubs, googleAccounts, haAccounts) = await (c, l, i, g, h)
|
||||||
|
|
||||||
|
// Reconcile banished list with the server's sidebar_hidden (server wins
|
||||||
|
// for CalDAV/Google/HA; local/ical keep their local state).
|
||||||
|
var b = banishedKeys
|
||||||
|
func applyServerHidden(_ source: String, _ id: Int, _ hidden: Bool) {
|
||||||
|
let key = CalendarStore.calendarKey(source: source, calendarId: "\(id)")
|
||||||
|
if hidden { b.insert(key) } else { b.remove(key) }
|
||||||
|
}
|
||||||
|
for acc in caldavAccounts { for cal in acc.calendars ?? [] { applyServerHidden("caldav", cal.id, cal.sidebarHidden) } }
|
||||||
|
for acc in googleAccounts { for cal in acc.calendars ?? [] { applyServerHidden("google", cal.id, cal.sidebarHidden) } }
|
||||||
|
for acc in haAccounts { for cal in acc.calendars ?? [] { applyServerHidden("homeassistant", cal.id, cal.sidebarHidden) } }
|
||||||
|
if b != banishedKeys {
|
||||||
|
banishedKeys = b
|
||||||
|
CalendarStore.saveBanishedKeys(b)
|
||||||
|
NotificationCenter.default.post(name: .banishedCalendarsChanged, object: nil)
|
||||||
|
}
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,3 +700,127 @@ struct AddHASheet: View {
|
|||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: – Sharing / Import-Export helpers
|
||||||
|
|
||||||
|
struct IdentifiableInt: Identifiable { let id: Int }
|
||||||
|
struct ExportedICS: Identifiable { let id = UUID(); let url: URL }
|
||||||
|
|
||||||
|
/// A tappable colour swatch (ColorPicker) for a calendar. Persists via `onPick`
|
||||||
|
/// when the chosen colour changes. Read-only fallback when `editable` is false.
|
||||||
|
struct CalendarColorDot: View {
|
||||||
|
let hex: String
|
||||||
|
var editable: Bool = true
|
||||||
|
let onPick: (String) async -> Void
|
||||||
|
@State private var color: Color
|
||||||
|
|
||||||
|
init(hex: String, editable: Bool = true, onPick: @escaping (String) async -> Void) {
|
||||||
|
self.hex = hex; self.editable = editable; self.onPick = onPick
|
||||||
|
_color = State(initialValue: Color(hex: hex))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if editable {
|
||||||
|
ColorPicker("", selection: $color, supportsOpacity: false)
|
||||||
|
.labelsHidden()
|
||||||
|
.frame(width: 26, height: 26)
|
||||||
|
.onChange(of: color) { _, c in Task { await onPick(c.toHex()) } }
|
||||||
|
} else {
|
||||||
|
Circle().fill(Color(hex: hex)).frame(width: 14, height: 14)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps UIActivityViewController so an exported .ics can be shared/saved.
|
||||||
|
struct ActivityView: UIViewControllerRepresentable {
|
||||||
|
let items: [Any]
|
||||||
|
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||||
|
UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||||
|
}
|
||||||
|
func updateUIViewController(_ vc: UIActivityViewController, context: Context) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manage who a local calendar is shared with (owner only).
|
||||||
|
struct SharingView: View {
|
||||||
|
let api: CalendarrAPI
|
||||||
|
let calendarId: Int
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
|
|
||||||
|
@State private var shares: [CalendarShare] = []
|
||||||
|
@State private var directory: [DirectoryUser] = []
|
||||||
|
@State private var search = ""
|
||||||
|
@State private var permission = "read"
|
||||||
|
@State private var error = ""
|
||||||
|
|
||||||
|
private var candidates: [DirectoryUser] {
|
||||||
|
let sharedIds = Set(shares.map { $0.userId })
|
||||||
|
return directory.filter { !sharedIds.contains($0.id) &&
|
||||||
|
(search.isEmpty || $0.displayName.localizedCaseInsensitiveContains(search)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section(L10n.t("share.current", appLang)) {
|
||||||
|
if shares.isEmpty {
|
||||||
|
Text(L10n.t("share.none", appLang)).foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(shares) { s in
|
||||||
|
HStack {
|
||||||
|
Text(s.displayName ?? "—")
|
||||||
|
Spacer()
|
||||||
|
Text(s.permission == "read_write"
|
||||||
|
? L10n.t("perm.read_write", appLang)
|
||||||
|
: L10n.t("perm.read", appLang))
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDelete { offsets in
|
||||||
|
Task { await removeShares(offsets) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section(L10n.t("share.add", appLang)) {
|
||||||
|
Picker(L10n.t("share.permission", appLang), selection: $permission) {
|
||||||
|
Text(L10n.t("perm.read", appLang)).tag("read")
|
||||||
|
Text(L10n.t("perm.read_write", appLang)).tag("read_write")
|
||||||
|
}
|
||||||
|
TextField(L10n.t("share.search", appLang), text: $search)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
ForEach(candidates) { u in
|
||||||
|
Button { Task { await addShare(u.id) } } label: {
|
||||||
|
HStack {
|
||||||
|
Text(u.displayName)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "plus.circle").foregroundStyle(Color.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !error.isEmpty { Section { Text(error).foregroundStyle(.red) } }
|
||||||
|
}
|
||||||
|
.navigationTitle(L10n.t("share.title", appLang))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button(L10n.t("common.done", appLang)) { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
shares = (try? await api.getShares(calendarId: calendarId)) ?? []
|
||||||
|
directory = (try? await api.getUserDirectory()) ?? []
|
||||||
|
}
|
||||||
|
private func addShare(_ userId: Int) async {
|
||||||
|
do { try await api.addShare(calendarId: calendarId, userId: userId, permission: permission); await load() }
|
||||||
|
catch { self.error = error.localizedDescription }
|
||||||
|
}
|
||||||
|
private func removeShares(_ offsets: IndexSet) async {
|
||||||
|
for i in offsets { try? await api.removeShare(calendarId: calendarId, userId: shares[i].userId) }
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
private enum CalEditorContext: Identifiable {
|
||||||
|
case create(Date)
|
||||||
|
case edit(CalEvent)
|
||||||
|
var id: String {
|
||||||
|
switch self {
|
||||||
|
case .create(let d): return "new-\(d.timeIntervalSince1970)"
|
||||||
|
case .edit(let ev): return "edit-\(ev.id)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct CalendarHostView: View {
|
struct CalendarHostView: View {
|
||||||
let api: CalendarrAPI
|
let api: CalendarrAPI
|
||||||
@Binding var showMenu: Bool
|
@Binding var showMenu: Bool
|
||||||
@@ -8,21 +19,24 @@ struct CalendarHostView: View {
|
|||||||
@AppStorage("cacheMonths") private var cacheMonths = 3
|
@AppStorage("cacheMonths") private var cacheMonths = 3
|
||||||
@AppStorage("appLanguage") private var appLang = "system"
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
@AppStorage("backgroundColor") private var bgHex = "#000000"
|
@AppStorage("backgroundColor") private var bgHex = "#000000"
|
||||||
|
@AppStorage("weekStartDay") private var weekStartDay = "monday"
|
||||||
|
@AppStorage("defaultView") private var defaultView = "month"
|
||||||
|
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
@State private var store = CalendarStore()
|
@State private var store = CalendarStore()
|
||||||
@State private var showEditor = false
|
@State private var editorContext: CalEditorContext? = nil
|
||||||
@State private var editorDate: Date = .now
|
|
||||||
@State private var editingEvent: CalEvent? = nil
|
|
||||||
@State private var selectedEvent: CalEvent? = nil
|
@State private var selectedEvent: CalEvent? = nil
|
||||||
@State private var visibleMonth: Date = .now
|
|
||||||
@State private var showFilter = false
|
@State private var showFilter = false
|
||||||
|
@State private var didApplyDefaultView = false
|
||||||
|
@State private var groups: [CalGroup] = []
|
||||||
|
|
||||||
private var titleString: String {
|
private var titleString: String {
|
||||||
if store.viewType == .month {
|
if store.viewType == .month {
|
||||||
let f = DateFormatter()
|
let f = DateFormatter()
|
||||||
f.locale = L10n.locale(appLang)
|
f.locale = L10n.locale(appLang)
|
||||||
f.dateFormat = "LLLL yyyy"
|
f.dateFormat = "LLLL yyyy"
|
||||||
return f.string(from: visibleMonth).capitalized(with: L10n.locale(appLang))
|
return f.string(from: store.visibleMonth).capitalized(with: L10n.locale(appLang))
|
||||||
}
|
}
|
||||||
return store.titleForCurrentView(language: appLang)
|
return store.titleForCurrentView(language: appLang)
|
||||||
}
|
}
|
||||||
@@ -35,84 +49,99 @@ struct CalendarHostView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: – Loading indicator
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var loadingIndicator: some View {
|
||||||
|
if store.isLoading || store.isCachingBackground {
|
||||||
|
ProgressView()
|
||||||
|
.padding(14)
|
||||||
|
.background(.regularMaterial, in: Circle())
|
||||||
|
.shadow(color: .black.opacity(0.12), radius: 6, y: 2)
|
||||||
|
.transition(.opacity.combined(with: .scale(scale: 0.85)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: – Flat variant
|
// MARK: – Flat variant
|
||||||
|
|
||||||
private var flatVariant: some View {
|
private var flatVariant: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
topBar
|
topBar
|
||||||
|
groupBanner
|
||||||
Divider()
|
Divider()
|
||||||
errorBanner
|
errorBanner
|
||||||
calendarContent
|
calendarContent
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(Color(hex: bgHex))
|
.background(Color(hex: bgHex))
|
||||||
.overlay(alignment: .top) {
|
.overlay(alignment: .top) {
|
||||||
if store.isLoading {
|
loadingIndicator.padding(.top, 12)
|
||||||
ProgressView().padding(.top, 10).transition(.opacity)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: store.isLoading || store.isCachingBackground)
|
||||||
}
|
}
|
||||||
.overlay(alignment: .bottomTrailing) { solidFAB }
|
.overlay(alignment: .bottomTrailing) { solidFAB }
|
||||||
// Subtle background cache indicator (top-leading)
|
|
||||||
.overlay(alignment: .topLeading) {
|
|
||||||
if store.isCachingBackground {
|
|
||||||
Image(systemName: "arrow.triangle.2.circlepath")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.padding(6)
|
|
||||||
.transition(.opacity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.modifier(calendarSheets)
|
.modifier(calendarSheets)
|
||||||
.task { await startup() }
|
.task { await startup() }
|
||||||
.onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } }
|
.onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } }
|
||||||
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
|
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
|
||||||
.onChange(of: cacheMonths) { _, _ in Task { await recache() } }
|
.onChange(of: cacheMonths) { _, _ in Task { await recache() } }
|
||||||
.onChange(of: visibleMonth) { _, new in Task { await ensureLoaded(around: new) } }
|
.onChange(of: store.visibleMonth) { _, new in Task { await ensureLoaded(around: new) } }
|
||||||
|
.onChange(of: scenePhase) { _, phase in if phase == .active { Task { await SettingsSync.pull(api: api) } } }
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in
|
||||||
store.syncBanishedFromDefaults()
|
store.syncBanishedFromDefaults()
|
||||||
}
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .settingsDidChange)) { _ in
|
||||||
|
applyServerDrivenSettings(initial: false)
|
||||||
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in
|
||||||
|
Task { await forceReload() }
|
||||||
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .rescheduleReminders)) { _ in
|
||||||
|
store.rescheduleNotifications()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – Liquid Glass variant
|
// MARK: – Liquid Glass variant
|
||||||
|
|
||||||
private var glassVariant: some View {
|
private var glassVariant: some View {
|
||||||
|
// Real iOS-26 Liquid Glass: the system NavigationStack toolbar renders the
|
||||||
|
// glass bar (buttons). The month TITLE is NOT placed in the toolbar — the
|
||||||
|
// system title silently fails to refresh on month change on iOS 26 — but
|
||||||
|
// as a normal inline Text in a top safe-area inset just below the glass
|
||||||
|
// bar, where it updates reliably (same mechanism as the flat variant).
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
calendarContent
|
calendarContent
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(Color(hex: bgHex))
|
.background(Color(hex: bgHex))
|
||||||
.overlay(alignment: .top) {
|
.overlay(alignment: .top) {
|
||||||
if store.isLoading {
|
loadingIndicator.padding(.top, 12)
|
||||||
ProgressView().padding(.top, 10).transition(.opacity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.overlay(alignment: .top) {
|
|
||||||
if let err = store.lastError { errorBannerView(err).padding(.top, 8) }
|
|
||||||
}
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: store.isLoading || store.isCachingBackground)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
HStack(spacing: 2) {
|
HStack(spacing: 2) {
|
||||||
Button { store.navigatePrev() } label: { Image(systemName: "chevron.left") }
|
Button { store.navigatePrev() } label: { Image(systemName: "chevron.left") }
|
||||||
Button { store.navigateNext() } label: { Image(systemName: "chevron.right") }
|
Button { store.navigateNext() } label: { Image(systemName: "chevron.right") }
|
||||||
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .principal) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout)
|
||||||
|
menuButton
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
|
VStack(spacing: 0) {
|
||||||
Text(titleString)
|
Text(titleString)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.7)
|
.minimumScaleFactor(0.7)
|
||||||
}
|
.frame(maxWidth: .infinity)
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
.padding(.vertical, 6)
|
||||||
HStack(spacing: 8) {
|
.background(.bar)
|
||||||
viewPickerMenu
|
groupBanner
|
||||||
Button { showFilter = true } label: {
|
errorBanner
|
||||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
|
||||||
.foregroundStyle(store.hiddenCalendarKeys.isEmpty ? .primary : Color.accentColor)
|
|
||||||
}
|
|
||||||
.accessibilityLabel(L10n.t("filter.button", appLang))
|
|
||||||
Button { showMenu = true } label: { Image(systemName: "line.3.horizontal") }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,15 +151,28 @@ struct CalendarHostView: View {
|
|||||||
.onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } }
|
.onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } }
|
||||||
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
|
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
|
||||||
.onChange(of: cacheMonths) { _, _ in Task { await recache() } }
|
.onChange(of: cacheMonths) { _, _ in Task { await recache() } }
|
||||||
.onChange(of: visibleMonth) { _, new in Task { await ensureLoaded(around: new) } }
|
.onChange(of: store.visibleMonth) { _, new in Task { await ensureLoaded(around: new) } }
|
||||||
|
.onChange(of: scenePhase) { _, phase in if phase == .active { Task { await SettingsSync.pull(api: api) } } }
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in
|
||||||
store.syncBanishedFromDefaults()
|
store.syncBanishedFromDefaults()
|
||||||
}
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .settingsDidChange)) { _ in
|
||||||
|
applyServerDrivenSettings(initial: false)
|
||||||
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in
|
||||||
|
Task { await forceReload() }
|
||||||
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .rescheduleReminders)) { _ in
|
||||||
|
store.rescheduleNotifications()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – Top bar (flat mode)
|
// MARK: – Top bar (flat mode)
|
||||||
|
|
||||||
private var topBar: some View {
|
/// Shared bar contents (chevrons / today / title / group / view / filter / menu).
|
||||||
|
/// Used by both the flat and the glass top bar so the inline title — which
|
||||||
|
/// updates reliably on month change — is identical in both modes.
|
||||||
|
@ViewBuilder private var barContents: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
HStack(spacing: 2) {
|
HStack(spacing: 2) {
|
||||||
Button { store.navigatePrev() } label: {
|
Button { store.navigatePrev() } label: {
|
||||||
@@ -143,53 +185,103 @@ struct CalendarHostView: View {
|
|||||||
.font(.system(size: 17, weight: .medium))
|
.font(.system(size: 17, weight: .medium))
|
||||||
.frame(width: 36, height: 36)
|
.frame(width: 36, height: 36)
|
||||||
}
|
}
|
||||||
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }
|
|
||||||
.font(.callout).padding(.horizontal, 6)
|
|
||||||
}
|
}
|
||||||
.padding(.leading, 8)
|
.padding(.leading, 6)
|
||||||
Spacer(minLength: 8)
|
Spacer(minLength: 6)
|
||||||
Text(titleString)
|
Text(titleString)
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.7)
|
.minimumScaleFactor(0.7)
|
||||||
Spacer(minLength: 8)
|
.layoutPriority(1)
|
||||||
viewPickerMenu
|
Spacer(minLength: 6)
|
||||||
filterButton
|
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }
|
||||||
Button { showMenu = true } label: {
|
.font(.callout).padding(.horizontal, 6)
|
||||||
Image(systemName: "line.3.horizontal")
|
.lineLimit(1).fixedSize()
|
||||||
.font(.system(size: 18, weight: .medium))
|
menuButton
|
||||||
.frame(width: 40, height: 40)
|
.padding(.trailing, 2)
|
||||||
}
|
|
||||||
.padding(.trailing, 4)
|
|
||||||
}
|
}
|
||||||
.frame(height: 48)
|
.frame(height: 48)
|
||||||
.background(.bar)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var filterButton: some View {
|
private var topBar: some View {
|
||||||
Button { showFilter = true } label: {
|
barContents.background(.bar)
|
||||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
}
|
||||||
.font(.system(size: 17, weight: .medium))
|
|
||||||
.foregroundStyle(store.hiddenCalendarKeys.isEmpty ? .primary : Color.accentColor)
|
@ViewBuilder private var groupBanner: some View {
|
||||||
.frame(width: 40, height: 40)
|
if let g = store.activeGroup {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
GroupIconView(icon: g.icon).font(.subheadline)
|
||||||
|
Text("\(L10n.t("groups.view_label", appLang)): \(g.name)")
|
||||||
|
.font(.subheadline).lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
Button(L10n.t("groups.exit", appLang)) { switchGroup(nil) }
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12).padding(.vertical, 7)
|
||||||
|
.background(Color.accentColor.opacity(0.18))
|
||||||
}
|
}
|
||||||
.accessibilityLabel(L10n.t("filter.button", appLang))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var viewPickerMenu: some View {
|
private func switchGroup(_ g: CalGroup?) {
|
||||||
|
store.activeGroup = g
|
||||||
|
store.hiddenGroupKeys = [] // member visibility is per-group; start fresh
|
||||||
|
// The cache holds the previous mode's events — drop it and reload the
|
||||||
|
// visible range + prefetch a wide window so the whole grid is covered.
|
||||||
|
Task { await forceReload() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single top-bar action: a compact popup holding view / filter /
|
||||||
|
/// groups / sync, plus an "Einstellungen" entry that opens the full menu.
|
||||||
|
/// (Replaces the separate view / filter / group icons in the bar.)
|
||||||
|
private var menuButton: some View {
|
||||||
Menu {
|
Menu {
|
||||||
ForEach(CalViewType.allCases, id: \.self) { vt in
|
// View (fixed icon, not per-view)
|
||||||
Button { store.viewType = vt } label: {
|
Menu {
|
||||||
Label(vt.label(appLang), systemImage: vt.systemImage)
|
ForEach(CalViewType.allCases, id: \.self) { vt in
|
||||||
|
Button { store.viewType = vt } label: {
|
||||||
|
Label(vt.label(appLang), systemImage: store.viewType == vt ? "checkmark" : vt.systemImage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label(L10n.t("view.change", appLang), systemImage: "rectangle.3.group")
|
||||||
|
}
|
||||||
|
// Filter
|
||||||
|
Button { showFilter = true } label: {
|
||||||
|
Label(L10n.t("filter.button", appLang), systemImage: "line.3.horizontal.decrease.circle")
|
||||||
|
}
|
||||||
|
// Groups
|
||||||
|
if !groups.isEmpty {
|
||||||
|
Menu {
|
||||||
|
Button { switchGroup(nil) } label: {
|
||||||
|
Label(L10n.t("groups.personal", appLang),
|
||||||
|
systemImage: store.activeGroup == nil ? "checkmark" : "person")
|
||||||
|
}
|
||||||
|
ForEach(groups) { g in
|
||||||
|
Button { switchGroup(g) } label: {
|
||||||
|
Label(g.name,
|
||||||
|
systemImage: store.activeGroup?.id == g.id ? "checkmark" : GroupIcons.symbol(g.icon))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label(L10n.t("groups.title", appLang), systemImage: "person.2")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Sync
|
||||||
|
Button { Task { await SettingsSync.pull(api: api); await forceReload() } } label: {
|
||||||
|
Label(L10n.t("menu.sync", appLang), systemImage: "arrow.triangle.2.circlepath")
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
// Full settings menu
|
||||||
|
Button { showMenu = true } label: {
|
||||||
|
Label(L10n.t("menu.section.settings", appLang), systemImage: "gearshape")
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: store.viewType.systemImage)
|
Image(systemName: "line.3.horizontal")
|
||||||
.font(.system(size: 17, weight: .medium))
|
.font(.system(size: 18, weight: .medium))
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(store.activeGroup == nil && store.hiddenCalendarKeys.isEmpty ? .primary : Color.accentColor)
|
||||||
.frame(width: 40, height: 40)
|
.frame(width: 36, height: 36)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(L10n.t("view.change", appLang))
|
.accessibilityLabel(L10n.t("nav.menu", appLang))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – Error banner
|
// MARK: – Error banner
|
||||||
@@ -228,13 +320,9 @@ struct CalendarHostView: View {
|
|||||||
case .month:
|
case .month:
|
||||||
// Month view uses vertical scroll – no horizontal swipe.
|
// Month view uses vertical scroll – no horizontal swipe.
|
||||||
MonthView(store: store,
|
MonthView(store: store,
|
||||||
onDayTap: { editorDate = $0 },
|
onDayTap: { store.currentDate = $0 },
|
||||||
onEventTap: { selectedEvent = $0 },
|
onEventTap: { selectedEvent = $0 },
|
||||||
onCreateEvent: { day in
|
onCreateEvent: { day in editorContext = .create(day) },
|
||||||
editingEvent = nil
|
|
||||||
editorDate = day
|
|
||||||
showEditor = true
|
|
||||||
},
|
|
||||||
onShowWeek: { day in
|
onShowWeek: { day in
|
||||||
store.currentDate = day
|
store.currentDate = day
|
||||||
store.viewType = .week
|
store.viewType = .week
|
||||||
@@ -242,16 +330,11 @@ struct CalendarHostView: View {
|
|||||||
onShowDay: { day in
|
onShowDay: { day in
|
||||||
store.currentDate = day
|
store.currentDate = day
|
||||||
store.viewType = .day
|
store.viewType = .day
|
||||||
},
|
})
|
||||||
visibleMonth: $visibleMonth)
|
|
||||||
case .week:
|
case .week:
|
||||||
WeekView(store: store,
|
WeekView(store: store,
|
||||||
onEventTap: { selectedEvent = $0 },
|
onEventTap: { selectedEvent = $0 },
|
||||||
onCreateEvent: { date in
|
onCreateEvent: { date in editorContext = .create(date) },
|
||||||
editingEvent = nil
|
|
||||||
editorDate = date
|
|
||||||
showEditor = true
|
|
||||||
},
|
|
||||||
onShowMonth: { date in
|
onShowMonth: { date in
|
||||||
store.currentDate = date
|
store.currentDate = date
|
||||||
store.viewType = .month
|
store.viewType = .month
|
||||||
@@ -264,11 +347,7 @@ struct CalendarHostView: View {
|
|||||||
case .day:
|
case .day:
|
||||||
DayView(store: store,
|
DayView(store: store,
|
||||||
onEventTap: { selectedEvent = $0 },
|
onEventTap: { selectedEvent = $0 },
|
||||||
onCreateEvent: { date in
|
onCreateEvent: { date in editorContext = .create(date) })
|
||||||
editingEvent = nil
|
|
||||||
editorDate = date
|
|
||||||
showEditor = true
|
|
||||||
})
|
|
||||||
.simultaneousGesture(swipe)
|
.simultaneousGesture(swipe)
|
||||||
case .quarter:
|
case .quarter:
|
||||||
QuarterView(store: store, onEventTap: { selectedEvent = $0 })
|
QuarterView(store: store, onEventTap: { selectedEvent = $0 })
|
||||||
@@ -283,7 +362,7 @@ struct CalendarHostView: View {
|
|||||||
/// Standard solid FAB (flat mode)
|
/// Standard solid FAB (flat mode)
|
||||||
private var solidFAB: some View {
|
private var solidFAB: some View {
|
||||||
Button {
|
Button {
|
||||||
editingEvent = nil; editorDate = .now; showEditor = true
|
editorContext = .create(.now)
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
.font(.system(size: 22, weight: .semibold))
|
.font(.system(size: 22, weight: .semibold))
|
||||||
@@ -301,7 +380,7 @@ struct CalendarHostView: View {
|
|||||||
private var glassFAB: some View {
|
private var glassFAB: some View {
|
||||||
if #available(iOS 26, *) {
|
if #available(iOS 26, *) {
|
||||||
Button {
|
Button {
|
||||||
editingEvent = nil; editorDate = .now; showEditor = true
|
editorContext = .create(.now)
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
.font(.system(size: 22, weight: .semibold))
|
.font(.system(size: 22, weight: .semibold))
|
||||||
@@ -319,16 +398,25 @@ struct CalendarHostView: View {
|
|||||||
// MARK: – Sheets modifier
|
// MARK: – Sheets modifier
|
||||||
|
|
||||||
private var calendarSheets: CalendarSheets {
|
private var calendarSheets: CalendarSheets {
|
||||||
CalendarSheets(store: store, showEditor: $showEditor,
|
CalendarSheets(store: store, editorContext: $editorContext,
|
||||||
editorDate: $editorDate, editingEvent: $editingEvent,
|
|
||||||
selectedEvent: $selectedEvent, showFilter: $showFilter,
|
selectedEvent: $selectedEvent, showFilter: $showFilter,
|
||||||
api: api, reload: { await onNavigate() })
|
api: api,
|
||||||
|
reload: { await onNavigate() },
|
||||||
|
reloadForce: { await reloadVisible(force: true) })
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – Loading logic
|
// MARK: – Loading logic
|
||||||
|
|
||||||
private func startup() async {
|
private func startup() async {
|
||||||
|
// Ask for notification permission early so reminders can be scheduled.
|
||||||
|
NotificationScheduler.requestAuthorizationIfNeeded()
|
||||||
|
// 0. Pull settings first so week-start / default-view are correct
|
||||||
|
// before we compute the initial range and load events.
|
||||||
|
await SettingsSync.pull(api: api)
|
||||||
|
applyServerDrivenSettings(initial: true)
|
||||||
|
|
||||||
await store.loadWritableCalendars(api: api)
|
await store.loadWritableCalendars(api: api)
|
||||||
|
groups = (try? await api.getGroups()) ?? []
|
||||||
// 1. Load current view immediately (visible)
|
// 1. Load current view immediately (visible)
|
||||||
let (s, e) = store.rangeForCurrentView()
|
let (s, e) = store.rangeForCurrentView()
|
||||||
await store.loadEvents(api: api, start: s, end: e)
|
await store.loadEvents(api: api, start: s, end: e)
|
||||||
@@ -336,6 +424,25 @@ struct CalendarHostView: View {
|
|||||||
Task(priority: .background) {
|
Task(priority: .background) {
|
||||||
await store.prefetchBackground(api: api, months: cacheMonths)
|
await store.prefetchBackground(api: api, months: cacheMonths)
|
||||||
}
|
}
|
||||||
|
// 3. Periodic settings pull (tied to this .task's lifetime).
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(for: .seconds(600))
|
||||||
|
if Task.isCancelled { break }
|
||||||
|
await SettingsSync.pull(api: api)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the server-driven "always sync" settings to the live store.
|
||||||
|
/// `weekStartsOnMonday` is applied every time; the default view is applied
|
||||||
|
/// only once at startup so it never overrides the user's manual switches.
|
||||||
|
private func applyServerDrivenSettings(initial: Bool) {
|
||||||
|
store.weekStartsOnMonday = (weekStartDay != "sunday")
|
||||||
|
if initial, !didApplyDefaultView {
|
||||||
|
didApplyDefaultView = true
|
||||||
|
if let vt = CalViewType(rawValue: defaultView) {
|
||||||
|
store.viewType = vt
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called on every navigation – instant if within cache, fetches otherwise.
|
/// Called on every navigation – instant if within cache, fetches otherwise.
|
||||||
@@ -344,12 +451,31 @@ struct CalendarHostView: View {
|
|||||||
await store.loadEvents(api: api, start: s, end: e)
|
await store.loadEvents(api: api, start: s, end: e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Re-fetch the visible range. With `force` it bypasses the cache so a just
|
||||||
|
/// created/edited event shows up immediately (the server is authoritative).
|
||||||
|
private func reloadVisible(force: Bool) async {
|
||||||
|
let (s, e) = store.rangeForCurrentView()
|
||||||
|
await store.loadEvents(api: api, start: s, end: e, force: force)
|
||||||
|
}
|
||||||
|
|
||||||
/// Called when cacheMonths setting changes – clear cache and re-prefetch.
|
/// Called when cacheMonths setting changes – clear cache and re-prefetch.
|
||||||
private func recache() async {
|
private func recache() async {
|
||||||
store.invalidateCache()
|
store.invalidateCache()
|
||||||
await startup()
|
await startup()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Manual sync from the menu: drop the event cache and re-fetch from the
|
||||||
|
/// server (the periodic loop in `startup()` is untouched, so we don't spawn
|
||||||
|
/// a second one). Settings were already pulled by the menu action.
|
||||||
|
private func forceReload() async {
|
||||||
|
store.invalidateCache()
|
||||||
|
let (s, e) = store.rangeForCurrentView()
|
||||||
|
await store.loadEvents(api: api, start: s, end: e)
|
||||||
|
Task(priority: .background) {
|
||||||
|
await store.prefetchBackground(api: api, months: cacheMonths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Called when the user scrolls into a new month – refreshes the visible range
|
/// Called when the user scrolls into a new month – refreshes the visible range
|
||||||
/// immediately from cache, then fetches on demand if needed.
|
/// immediately from cache, then fetches on demand if needed.
|
||||||
private func ensureLoaded(around month: Date) async {
|
private func ensureLoaded(around month: Date) async {
|
||||||
@@ -373,27 +499,32 @@ struct CalendarHostView: View {
|
|||||||
|
|
||||||
private struct CalendarSheets: ViewModifier {
|
private struct CalendarSheets: ViewModifier {
|
||||||
let store: CalendarStore
|
let store: CalendarStore
|
||||||
@Binding var showEditor: Bool
|
@Binding var editorContext: CalEditorContext?
|
||||||
@Binding var editorDate: Date
|
|
||||||
@Binding var editingEvent: CalEvent?
|
|
||||||
@Binding var selectedEvent: CalEvent?
|
@Binding var selectedEvent: CalEvent?
|
||||||
@Binding var showFilter: Bool
|
@Binding var showFilter: Bool
|
||||||
let api: CalendarrAPI
|
let api: CalendarrAPI
|
||||||
let reload: () async -> Void
|
let reload: () async -> Void
|
||||||
|
let reloadForce: () async -> Void
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
content
|
content
|
||||||
.sheet(isPresented: $showEditor) {
|
// Use sheet(item:) so the editing event is captured atomically –
|
||||||
|
// avoiding the race where sheet(isPresented:) evaluates its content
|
||||||
|
// before the editingEvent state update propagates.
|
||||||
|
.sheet(item: $editorContext) { ctx in
|
||||||
|
let editingEv: CalEvent? = { if case .edit(let ev) = ctx { return ev }; return nil }()
|
||||||
|
let date: Date = { if case .create(let d) = ctx { return d }; return .now }()
|
||||||
EventEditorSheet(api: api, store: store,
|
EventEditorSheet(api: api, store: store,
|
||||||
initialDate: editorDate, editingEvent: editingEvent) {
|
initialDate: date, editingEvent: editingEv) {
|
||||||
editingEvent = nil; await reload()
|
editorContext = nil
|
||||||
|
await reloadForce()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(item: $selectedEvent) { ev in
|
.sheet(item: $selectedEvent) { ev in
|
||||||
EventDetailSheet(event: ev, api: api, store: store) { updated in
|
EventDetailSheet(event: ev, api: api, store: store) { updated, needsForce in
|
||||||
selectedEvent = nil
|
selectedEvent = nil
|
||||||
if let u = updated { editingEvent = u; showEditor = true }
|
if let u = updated { editorContext = .edit(u) }
|
||||||
await reload()
|
if needsForce { await reloadForce() } else { await reload() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showFilter) {
|
.sheet(isPresented: $showFilter) {
|
||||||
|
|||||||
@@ -5,14 +5,20 @@ struct DayView: View {
|
|||||||
let onEventTap: (CalEvent) -> Void
|
let onEventTap: (CalEvent) -> Void
|
||||||
let onCreateEvent: (Date) -> Void
|
let onCreateEvent: (Date) -> Void
|
||||||
|
|
||||||
@AppStorage("appLanguage") private var appLang = "system"
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
@AppStorage("todayColor") private var todayHex = "#4285f4"
|
@AppStorage("todayColor") private var todayHex = "#4285f4"
|
||||||
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
||||||
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
|
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
|
||||||
|
@AppStorage("textContrast") private var textContrast = 3
|
||||||
|
@AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout
|
||||||
|
|
||||||
private var cal: Calendar { store.userCalendar }
|
private var cal: Calendar { store.userCalendar }
|
||||||
private var allDayEvents: [CalEvent] { store.events(on: store.currentDate).filter(\.isAllDay) }
|
private var allDayEvents: [CalEvent] {
|
||||||
private var timedEvents: [CalEvent] { store.events(on: store.currentDate).filter { !$0.isAllDay } }
|
store.events(on: store.currentDate).filter { $0.isAllDay || eventSpansMultipleDays($0) }
|
||||||
|
}
|
||||||
|
private var timedEvents: [CalEvent] {
|
||||||
|
store.events(on: store.currentDate).filter { !$0.isAllDay && !eventSpansMultipleDays($0) }
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -97,7 +103,7 @@ struct DayView: View {
|
|||||||
Color.clear.frame(height: hourHeight)
|
Color.clear.frame(height: hourHeight)
|
||||||
Text(String(format: "%02d:00", h))
|
Text(String(format: "%02d:00", h))
|
||||||
.font(.system(size: 10))
|
.font(.system(size: 10))
|
||||||
.foregroundStyle(Color(hex: textHex).opacity(0.6))
|
.foregroundStyle(Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
|
||||||
.offset(y: -6)
|
.offset(y: -6)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,7 +137,8 @@ private struct DayHourSlot: View {
|
|||||||
let language: String
|
let language: String
|
||||||
let onCreateEvent: (Date) -> Void
|
let onCreateEvent: (Date) -> Void
|
||||||
|
|
||||||
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
|
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
|
||||||
|
@AppStorage("lineContrast") private var lineContrast = 3
|
||||||
|
|
||||||
private var date: Date {
|
private var date: Date {
|
||||||
Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day
|
Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day
|
||||||
@@ -139,7 +146,7 @@ private struct DayHourSlot: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Rectangle().fill(Color(hex: lineHex).opacity(0.4)).frame(height: 0.5)
|
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(height: 0.5)
|
||||||
Color.clear.frame(height: hourHeight - 0.5)
|
Color.clear.frame(height: hourHeight - 0.5)
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
|
|||||||
@@ -4,11 +4,16 @@ struct EventDetailSheet: View {
|
|||||||
let event: CalEvent
|
let event: CalEvent
|
||||||
let api: CalendarrAPI
|
let api: CalendarrAPI
|
||||||
let store: CalendarStore
|
let store: CalendarStore
|
||||||
let onDone: (CalEvent?) async -> Void
|
/// Called when the sheet should close.
|
||||||
|
/// - `editEvent`: non-nil when the user wants to edit this event
|
||||||
|
/// - `forceReload`: true when server data changed (create/copy) and the
|
||||||
|
/// caller must bypass the cache to fetch fresh events
|
||||||
|
let onDone: (_ editEvent: CalEvent?, _ forceReload: Bool) async -> Void
|
||||||
|
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@State private var showDeleteConfirm = false
|
@State private var showDeleteConfirm = false
|
||||||
@State private var isDeleting = false
|
@State private var isDeleting = false
|
||||||
|
@State private var showCopySheet = false
|
||||||
|
|
||||||
private let timeFmt: DateFormatter = {
|
private let timeFmt: DateFormatter = {
|
||||||
let f = DateFormatter()
|
let f = DateFormatter()
|
||||||
@@ -37,7 +42,14 @@ struct EventDetailSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var canEdit: Bool {
|
private var canEdit: Bool {
|
||||||
event.source == "local" || event.source == "caldav"
|
event.source == "local" || event.source == "caldav" || event.source == "homeassistant"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canDelete: Bool { canEdit }
|
||||||
|
|
||||||
|
private var currentUserId: Int? {
|
||||||
|
let id = UserDefaults.standard.integer(forKey: "userId")
|
||||||
|
return id == 0 ? nil : id
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -84,9 +96,31 @@ struct EventDetailSheet: View {
|
|||||||
Text(event.source.capitalized)
|
Text(event.source.capitalized)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
if let creator = event.creator, creator.id != currentUserId {
|
||||||
|
HStack {
|
||||||
|
Label("Erstellt von", systemImage: "person")
|
||||||
|
Spacer()
|
||||||
|
Text(creator.displayName)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if event.isPrivate {
|
||||||
|
Label("Privat", systemImage: "lock")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if canEdit {
|
if !store.writableCalendars.isEmpty {
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
showCopySheet = true
|
||||||
|
} label: {
|
||||||
|
Label("Termin kopieren", systemImage: "doc.on.doc")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if canDelete {
|
||||||
Section {
|
Section {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
showDeleteConfirm = true
|
showDeleteConfirm = true
|
||||||
@@ -104,18 +138,18 @@ struct EventDetailSheet: View {
|
|||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button("Schliessen") {
|
Button("Schliessen") {
|
||||||
Task { await onDone(nil) }
|
Task { await onDone(nil, false) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if canEdit {
|
if canEdit {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
Button("Bearbeiten") {
|
Button("Bearbeiten") {
|
||||||
Task { await onDone(event) }
|
Task { await onDone(event, false) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.confirmationDialog("Termin löschen?", isPresented: $showDeleteConfirm, titleVisibility: .visible) {
|
.alert("Termin löschen?", isPresented: $showDeleteConfirm) {
|
||||||
Button("Löschen", role: .destructive) {
|
Button("Löschen", role: .destructive) {
|
||||||
Task { await deleteEvent() }
|
Task { await deleteEvent() }
|
||||||
}
|
}
|
||||||
@@ -123,19 +157,39 @@ struct EventDetailSheet: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text("\"\(event.title)\" wird dauerhaft gelöscht.")
|
Text("\"\(event.title)\" wird dauerhaft gelöscht.")
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showCopySheet) {
|
||||||
|
EventEditorSheet(
|
||||||
|
api: api,
|
||||||
|
store: store,
|
||||||
|
initialDate: event.startDate,
|
||||||
|
editingEvent: nil,
|
||||||
|
copyFrom: event
|
||||||
|
) {
|
||||||
|
// Copy created a new server-side event → force reload so it appears
|
||||||
|
await onDone(nil, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deleteEvent() async {
|
private func deleteEvent() async {
|
||||||
isDeleting = true
|
isDeleting = true
|
||||||
do {
|
do {
|
||||||
if event.source == "local" {
|
switch event.source {
|
||||||
|
case "local":
|
||||||
try await api.deleteLocalEvent(uid: event.id)
|
try await api.deleteLocalEvent(uid: event.id)
|
||||||
} else {
|
case "homeassistant":
|
||||||
|
// calendarId looks like "homeassistant-42" → numeric DB id 42
|
||||||
|
let calId = Int(event.calendarId.replacingOccurrences(of: "homeassistant-", with: "")) ?? 0
|
||||||
|
try await api.deleteHAEvent(calendarId: calId, uid: event.id)
|
||||||
|
default:
|
||||||
let calId = Int(event.calendarId)
|
let calId = Int(event.calendarId)
|
||||||
try await api.deleteCalDAVEvent(uid: event.id, url: event.url, calendarId: calId)
|
try await api.deleteCalDAVEvent(uid: event.id, url: event.url, calendarId: calId)
|
||||||
}
|
}
|
||||||
await onDone(nil)
|
// Optimistically drop it from the cache so it vanishes immediately,
|
||||||
|
// regardless of how long the source takes to propagate the delete.
|
||||||
|
store.removeCachedEvent(id: event.id)
|
||||||
|
await onDone(nil, false)
|
||||||
} catch {
|
} catch {
|
||||||
isDeleting = false
|
isDeleting = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ struct EventEditorSheet: View {
|
|||||||
let store: CalendarStore
|
let store: CalendarStore
|
||||||
let initialDate: Date
|
let initialDate: Date
|
||||||
let editingEvent: CalEvent?
|
let editingEvent: CalEvent?
|
||||||
|
var copyFrom: CalEvent? = nil
|
||||||
let onSaved: () async -> Void
|
let onSaved: () async -> Void
|
||||||
|
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@AppStorage("appLanguage") private var appLang = "system"
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
|
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
|
||||||
@State private var title = ""
|
@State private var title = ""
|
||||||
@State private var isAllDay = false
|
@State private var isAllDay = false
|
||||||
@State private var startDate = Date()
|
@State private var startDate = Date()
|
||||||
@@ -17,10 +19,13 @@ struct EventEditorSheet: View {
|
|||||||
@State private var notes = ""
|
@State private var notes = ""
|
||||||
@State private var selectedCalendarId: String = ""
|
@State private var selectedCalendarId: String = ""
|
||||||
@State private var color = ""
|
@State private var color = ""
|
||||||
|
@State private var isPrivate = false
|
||||||
|
@State private var reminders: [Int] = []
|
||||||
@State private var isSaving = false
|
@State private var isSaving = false
|
||||||
@State private var error = ""
|
@State private var error = ""
|
||||||
|
|
||||||
private var isEditing: Bool { editingEvent != nil }
|
private var isEditing: Bool { editingEvent != nil }
|
||||||
|
private var isCopying: Bool { copyFrom != nil && editingEvent == nil }
|
||||||
|
|
||||||
private var selectedCal: WritableCalendar? {
|
private var selectedCal: WritableCalendar? {
|
||||||
store.writableCalendars.first { $0.id == selectedCalendarId }
|
store.writableCalendars.first { $0.id == selectedCalendarId }
|
||||||
@@ -73,6 +78,34 @@ struct EventEditorSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if selectedCal?.source == "local" {
|
||||||
|
Section {
|
||||||
|
Toggle(L10n.t("event.private", appLang), isOn: $isPrivate)
|
||||||
|
.tint(Color.accentColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(ReminderOptions.sectionTitle(appLang)) {
|
||||||
|
ForEach(Array(reminders.enumerated()), id: \.offset) { idx, _ in
|
||||||
|
Picker(ReminderOptions.sectionTitle(appLang), selection: Binding(
|
||||||
|
get: { reminders.indices.contains(idx) ? reminders[idx] : 0 },
|
||||||
|
set: { if reminders.indices.contains(idx) { reminders[idx] = $0 } }
|
||||||
|
)) {
|
||||||
|
ForEach(ReminderOptions.all, id: \.self) { opt in
|
||||||
|
Text(ReminderOptions.label(opt, appLang)).tag(opt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
.onDelete { reminders.remove(atOffsets: $0) }
|
||||||
|
Button {
|
||||||
|
let next = ReminderOptions.all.first { !reminders.contains($0) } ?? 15
|
||||||
|
reminders.append(next)
|
||||||
|
} label: {
|
||||||
|
Label(ReminderOptions.addLabel(appLang), systemImage: "bell.badge.plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Section(L10n.t("event.color_section", appLang)) {
|
Section(L10n.t("event.color_section", appLang)) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(L10n.t("event.color", appLang))
|
Text(L10n.t("event.color", appLang))
|
||||||
@@ -96,9 +129,11 @@ struct EventEditorSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(isEditing
|
.navigationTitle(
|
||||||
? L10n.t("event.edit_title", appLang)
|
isEditing ? L10n.t("event.edit_title", appLang) :
|
||||||
: L10n.t("event.new_title", appLang))
|
isCopying ? L10n.t("event.copy_title", appLang) :
|
||||||
|
L10n.t("event.new_title", appLang)
|
||||||
|
)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
@@ -116,6 +151,12 @@ struct EventEditorSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear { setup() }
|
.onAppear { setup() }
|
||||||
|
.onChange(of: startDate) { oldStart, newStart in
|
||||||
|
guard newStart >= endDate else { return }
|
||||||
|
let duration = endDate.timeIntervalSince(oldStart)
|
||||||
|
let minDuration: TimeInterval = isAllDay ? 86400 : 3600
|
||||||
|
endDate = newStart.addingTimeInterval(max(duration, minDuration))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setup() {
|
private func setup() {
|
||||||
@@ -123,17 +164,43 @@ struct EventEditorSheet: View {
|
|||||||
title = ev.title
|
title = ev.title
|
||||||
isAllDay = ev.isAllDay
|
isAllDay = ev.isAllDay
|
||||||
startDate = ev.startDate
|
startDate = ev.startDate
|
||||||
endDate = ev.endDate
|
// All-day end dates are stored as exclusive (day after last); subtract 1 for the picker.
|
||||||
|
endDate = ev.isAllDay
|
||||||
|
? Calendar.current.date(byAdding: .day, value: -1, to: ev.endDate) ?? ev.endDate
|
||||||
|
: ev.endDate
|
||||||
location = ev.location
|
location = ev.location
|
||||||
notes = ev.notes
|
notes = ev.notes
|
||||||
color = ev.color ?? ""
|
color = ev.color ?? ""
|
||||||
selectedCalendarId = ev.calendarId
|
isPrivate = ev.isPrivate
|
||||||
|
reminders = ev.reminders
|
||||||
|
// HA events use "homeassistant-42" in CalEvent but "ha-42" in WritableCalendar
|
||||||
|
if ev.source == "homeassistant" {
|
||||||
|
let num = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
|
||||||
|
selectedCalendarId = "ha-\(num)"
|
||||||
|
} else {
|
||||||
|
selectedCalendarId = ev.calendarId
|
||||||
|
}
|
||||||
|
} else if let ev = copyFrom {
|
||||||
|
title = ev.title
|
||||||
|
isAllDay = ev.isAllDay
|
||||||
|
startDate = ev.startDate
|
||||||
|
endDate = ev.isAllDay
|
||||||
|
? Calendar.current.date(byAdding: .day, value: -1, to: ev.endDate) ?? ev.endDate
|
||||||
|
: ev.endDate
|
||||||
|
location = ev.location
|
||||||
|
notes = ev.notes
|
||||||
|
color = ev.color ?? ""
|
||||||
|
isPrivate = ev.isPrivate
|
||||||
|
reminders = ev.reminders
|
||||||
|
selectedCalendarId = store.writableCalendars.first?.id ?? ""
|
||||||
} else {
|
} else {
|
||||||
let cal = Calendar.current
|
let cal = Calendar.current
|
||||||
startDate = cal.date(bySettingHour: cal.component(.hour, from: initialDate),
|
startDate = cal.date(bySettingHour: cal.component(.hour, from: initialDate),
|
||||||
minute: 0, second: 0, of: initialDate) ?? initialDate
|
minute: 0, second: 0, of: initialDate) ?? initialDate
|
||||||
endDate = startDate.addingTimeInterval(3600)
|
endDate = startDate.addingTimeInterval(3600)
|
||||||
selectedCalendarId = store.writableCalendars.first?.id ?? ""
|
selectedCalendarId = store.writableCalendars.first?.id ?? ""
|
||||||
|
// New events inherit the user's default reminder (editable).
|
||||||
|
if defaultReminderMinutes >= 0 { reminders = [defaultReminderMinutes] }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,10 +216,20 @@ struct EventEditorSheet: View {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
if let ev = editingEvent {
|
if let ev = editingEvent {
|
||||||
if ev.source == "local" {
|
switch ev.source {
|
||||||
|
case "local":
|
||||||
try await api.updateLocalEvent(uid: ev.id, title: title, start: start, end: end,
|
try await api.updateLocalEvent(uid: ev.id, title: title, start: start, end: end,
|
||||||
isAllDay: isAllDay, location: location, description: notes, color: colorVal)
|
isAllDay: isAllDay, location: location, description: notes, color: colorVal,
|
||||||
} else {
|
isPrivate: isPrivate, reminders: reminders)
|
||||||
|
case "homeassistant":
|
||||||
|
// No update API exists – delete the old event and recreate with new data.
|
||||||
|
let rawId = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
|
||||||
|
let haCalId = Int(rawId) ?? 0
|
||||||
|
try await api.deleteHAEvent(calendarId: haCalId, uid: ev.id)
|
||||||
|
try await api.createHAEvent(calendarId: haCalId, title: title,
|
||||||
|
start: start, end: end, isAllDay: isAllDay,
|
||||||
|
location: location, description: notes)
|
||||||
|
default: // caldav
|
||||||
let calId = Int(ev.calendarId)
|
let calId = Int(ev.calendarId)
|
||||||
try await api.updateCalDAVEvent(uid: ev.id, url: ev.url, calendarId: calId,
|
try await api.updateCalDAVEvent(uid: ev.id, url: ev.url, calendarId: calId,
|
||||||
title: title, start: start, end: end, isAllDay: isAllDay,
|
title: title, start: start, end: end, isAllDay: isAllDay,
|
||||||
@@ -163,7 +240,8 @@ struct EventEditorSheet: View {
|
|||||||
case "local":
|
case "local":
|
||||||
_ = try await api.createLocalEvent(calendarId: cal.numericId, title: title,
|
_ = try await api.createLocalEvent(calendarId: cal.numericId, title: title,
|
||||||
start: start, end: end, isAllDay: isAllDay,
|
start: start, end: end, isAllDay: isAllDay,
|
||||||
location: location, description: notes, color: colorVal)
|
location: location, description: notes, color: colorVal,
|
||||||
|
isPrivate: isPrivate, reminders: reminders)
|
||||||
case "google":
|
case "google":
|
||||||
try await api.createGoogleEvent(calendarDbId: cal.numericId, title: title,
|
try await api.createGoogleEvent(calendarDbId: cal.numericId, title: title,
|
||||||
start: start, end: end, isAllDay: isAllDay,
|
start: start, end: end, isAllDay: isAllDay,
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ struct MonthView: View {
|
|||||||
let onCreateEvent: (Date) -> Void
|
let onCreateEvent: (Date) -> Void
|
||||||
let onShowWeek: (Date) -> Void
|
let onShowWeek: (Date) -> Void
|
||||||
let onShowDay: (Date) -> Void
|
let onShowDay: (Date) -> Void
|
||||||
@Binding var visibleMonth: Date
|
|
||||||
|
|
||||||
@AppStorage("appLanguage") private var appLang = "system"
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
@AppStorage("monthDividerColor") private var dividerHex = "#7090c0"
|
@AppStorage("monthDividerColor") private var dividerHex = "#7090c0"
|
||||||
@AppStorage("monthLabelColor") private var labelHex = "#7090c0"
|
@AppStorage("monthLabelColor") private var labelHex = "#7090c0"
|
||||||
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
||||||
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
|
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
|
||||||
|
@AppStorage("textContrast") private var textContrast = 3
|
||||||
|
|
||||||
@State private var scrolledWeek: Date? = nil
|
@State private var scrolledWeek: Date? = nil
|
||||||
@State private var didInitialScroll = false
|
@State private var didInitialScroll = false
|
||||||
@@ -52,8 +52,7 @@ struct MonthView: View {
|
|||||||
headerRow
|
headerRow
|
||||||
Divider()
|
Divider()
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) { ForEach(weekStarts, id: \.self) { ws in
|
||||||
ForEach(weekStarts, id: \.self) { ws in
|
|
||||||
WeekRow(weekStart: ws,
|
WeekRow(weekStart: ws,
|
||||||
store: store,
|
store: store,
|
||||||
dividerColor: Color(hex: dividerHex),
|
dividerColor: Color(hex: dividerHex),
|
||||||
@@ -71,6 +70,7 @@ struct MonthView: View {
|
|||||||
}
|
}
|
||||||
.scrollTargetLayout()
|
.scrollTargetLayout()
|
||||||
}
|
}
|
||||||
|
.scrollIndicators(.hidden)
|
||||||
.scrollPosition(id: $scrolledWeek, anchor: .top)
|
.scrollPosition(id: $scrolledWeek, anchor: .top)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if !didInitialScroll {
|
if !didInitialScroll {
|
||||||
@@ -98,7 +98,7 @@ struct MonthView: View {
|
|||||||
ForEach(weekdayHeaders, id: \.self) { d in
|
ForEach(weekdayHeaders, id: \.self) { d in
|
||||||
Text(d)
|
Text(d)
|
||||||
.font(.caption2.weight(.semibold))
|
.font(.caption2.weight(.semibold))
|
||||||
.foregroundStyle(Color(hex: textHex).opacity(0.7))
|
.foregroundStyle(Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
|
||||||
.frame(maxWidth: .infinity, minHeight: weekdayHeaderHeight)
|
.frame(maxWidth: .infinity, minHeight: weekdayHeaderHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,23 +108,16 @@ struct MonthView: View {
|
|||||||
cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date))!
|
cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date))!
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Determine the visible month from the currently-scrolled week.
|
/// Determine the header month from the currently-scrolled week.
|
||||||
/// Instead of switching as soon as a few days of the next month appear,
|
/// Rule: take the month of the topmost visible week's start day. This
|
||||||
/// we count the month affiliation of the visible week rows and keep the
|
/// means as long as the "1." of the next month is still visible in the
|
||||||
/// month that occupies the majority of the viewport.
|
/// top row, the header keeps showing the previous month – and only flips
|
||||||
|
/// to the new month once its "1." has scrolled out of view above.
|
||||||
private func publishVisibleMonth(from week: Date?) {
|
private func publishVisibleMonth(from week: Date?) {
|
||||||
guard let w = week else { return }
|
guard let w = week else { return }
|
||||||
|
let month = cal.date(from: cal.dateComponents([.year, .month], from: w)) ?? w
|
||||||
let visibleWeeks = (0..<6).compactMap { cal.date(byAdding: .weekOfYear, value: $0, to: w) }
|
if store.visibleMonth != month {
|
||||||
let monthCounts = visibleWeeks.reduce(into: [Date: Int]()) { acc, weekStart in
|
store.visibleMonth = month
|
||||||
guard let midWeek = cal.date(byAdding: .day, value: 3, to: weekStart) else { return }
|
|
||||||
let month = cal.date(from: cal.dateComponents([.year, .month], from: midWeek)) ?? midWeek
|
|
||||||
acc[month, default: 0] += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
let selectedMonth = monthCounts.max { a, b in a.value < b.value }?.key
|
|
||||||
if let m = selectedMonth, visibleMonth != m {
|
|
||||||
visibleMonth = m
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -298,6 +291,9 @@ private struct DayCell: View {
|
|||||||
let onShowWeek: () -> Void
|
let onShowWeek: () -> Void
|
||||||
let onShowDay: () -> Void
|
let onShowDay: () -> Void
|
||||||
|
|
||||||
|
@AppStorage("textContrast") private var textContrast = 3
|
||||||
|
@AppStorage("lineContrast") private var lineContrast = 3
|
||||||
|
|
||||||
private var cal: Calendar { Calendar.current }
|
private var cal: Calendar { Calendar.current }
|
||||||
private var dayNum: Int { cal.component(.day, from: date) }
|
private var dayNum: Int { cal.component(.day, from: date) }
|
||||||
private var isFirstOfMonth: Bool { dayNum == 1 }
|
private var isFirstOfMonth: Bool { dayNum == 1 }
|
||||||
@@ -337,14 +333,14 @@ private struct DayCell: View {
|
|||||||
if extraCount > 0 {
|
if extraCount > 0 {
|
||||||
Text("+\(extraCount)")
|
Text("+\(extraCount)")
|
||||||
.font(.system(size: 9, weight: .medium))
|
.font(.system(size: 9, weight: .medium))
|
||||||
.foregroundStyle(textColor.opacity(0.6))
|
.foregroundStyle(textColor.opacity(secondaryTextOpacity(textContrast)))
|
||||||
.padding(.leading, 4)
|
.padding(.leading, 4)
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
if let wn = weekNumber {
|
if let wn = weekNumber {
|
||||||
Text("\(cwLabel) \(wn)")
|
Text("\(cwLabel) \(wn)")
|
||||||
.font(.system(size: 9, weight: .medium))
|
.font(.system(size: 9, weight: .medium))
|
||||||
.foregroundStyle(textColor.opacity(0.6))
|
.foregroundStyle(textColor.opacity(secondaryTextOpacity(textContrast)))
|
||||||
.padding(.trailing, 4)
|
.padding(.trailing, 4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -352,11 +348,11 @@ private struct DayCell: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
.overlay(alignment: .trailing) {
|
.overlay(alignment: .trailing) {
|
||||||
Rectangle().fill(lineColor.opacity(0.4)).frame(width: 0.5)
|
Rectangle().fill(lineColor.opacity(gridLineOpacity(lineContrast))).frame(width: 0.5)
|
||||||
}
|
}
|
||||||
.overlay(alignment: .top) {
|
.overlay(alignment: .top) {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(edge == .topHighlight ? dividerColor : lineColor.opacity(0.3))
|
.fill(edge == .topHighlight ? dividerColor : lineColor.opacity(gridLineOpacity(lineContrast)))
|
||||||
.frame(height: edge == .topHighlight ? 1.5 : 0.5)
|
.frame(height: edge == .topHighlight ? 1.5 : 0.5)
|
||||||
}
|
}
|
||||||
.overlay(alignment: .bottom) {
|
.overlay(alignment: .bottom) {
|
||||||
@@ -384,6 +380,9 @@ private struct DayCell: View {
|
|||||||
|
|
||||||
private struct EventBar: View {
|
private struct EventBar: View {
|
||||||
let event: CalEvent
|
let event: CalEvent
|
||||||
|
@AppStorage("dimPastEvents") private var dimPast = false
|
||||||
|
|
||||||
|
private var isPast: Bool { event.endDate < .now }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 3) {
|
HStack(spacing: 3) {
|
||||||
@@ -397,5 +396,6 @@ private struct EventBar: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(Color(hex: event.effectiveColor))
|
.background(Color(hex: event.effectiveColor))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 3))
|
.clipShape(RoundedRectangle(cornerRadius: 3))
|
||||||
|
.opacity(dimPast && isPast ? 0.5 : 1.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,54 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// Shared constants used by WeekView, DayView, EventEditorSheet
|
// Shared constants used by WeekView, DayView, EventEditorSheet
|
||||||
let hourHeight: CGFloat = 60
|
|
||||||
let timeColumnWidth: CGFloat = 44
|
let timeColumnWidth: CGFloat = 44
|
||||||
let hours = Array(0..<24)
|
let hours = Array(0..<24)
|
||||||
|
|
||||||
|
/// Live hour-row height, driven by the synced `hourHeight` setting.
|
||||||
|
/// Falls back to 60 when unset (fresh install / value 0). Views that lay out
|
||||||
|
/// against this also observe `@AppStorage("hourHeight")` so their body
|
||||||
|
/// re-renders when it changes.
|
||||||
|
var hourHeight: CGFloat {
|
||||||
|
let v = UserDefaults.standard.integer(forKey: "hourHeight")
|
||||||
|
return v > 0 ? CGFloat(v) : 60
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opacity for secondary text (weekday headers, time labels, "+N"/"KW"),
|
||||||
|
/// mapped from the 1–4 `textContrast` level. Level 3 ≈ the previous hard-coded
|
||||||
|
/// look so existing installs are visually unchanged.
|
||||||
|
func secondaryTextOpacity(_ level: Int) -> Double {
|
||||||
|
switch level {
|
||||||
|
case 1: return 0.4
|
||||||
|
case 2: return 0.55
|
||||||
|
case 4: return 1.0
|
||||||
|
default: return 0.75
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opacity for grid lines / separators, mapped from the 1–4 `lineContrast`
|
||||||
|
/// level. Level 3 ≈ the previous hard-coded ~0.4 look.
|
||||||
|
func gridLineOpacity(_ level: Int) -> Double {
|
||||||
|
switch level {
|
||||||
|
case 1: return 0.15
|
||||||
|
case 2: return 0.3
|
||||||
|
case 4: return 0.8
|
||||||
|
default: return 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A timed (non-all-day) event that crosses a day boundary. Such events must
|
||||||
|
/// NOT be placed in the hourly grid — their height would be `duration ×
|
||||||
|
/// hourHeight`, i.e. taller than the whole day, rendering as a giant block
|
||||||
|
/// (and, sharing one id across days, only drawing on the first day). They are
|
||||||
|
/// shown in the all-day strip instead, like all-day events.
|
||||||
|
func eventSpansMultipleDays(_ ev: CalEvent) -> Bool {
|
||||||
|
guard !ev.isAllDay, ev.endDate > ev.startDate else { return false }
|
||||||
|
let cal = Calendar.current
|
||||||
|
// End is exclusive: an event ending exactly at midnight is still single-day.
|
||||||
|
let lastInstant = ev.endDate.addingTimeInterval(-1)
|
||||||
|
return !cal.isDate(ev.startDate, inSameDayAs: lastInstant)
|
||||||
|
}
|
||||||
|
|
||||||
// Position helpers
|
// Position helpers
|
||||||
func eventTop(_ ev: CalEvent) -> CGFloat {
|
func eventTop(_ ev: CalEvent) -> CGFloat {
|
||||||
let cal = Calendar.current
|
let cal = Calendar.current
|
||||||
@@ -21,6 +65,9 @@ func eventHeight(_ ev: CalEvent) -> CGFloat {
|
|||||||
// Shared event block used in WeekView and DayView
|
// Shared event block used in WeekView and DayView
|
||||||
struct EventBlock: View {
|
struct EventBlock: View {
|
||||||
let event: CalEvent
|
let event: CalEvent
|
||||||
|
@AppStorage("dimPastEvents") private var dimPast = false
|
||||||
|
|
||||||
|
private var isPast: Bool { event.endDate < .now }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
RoundedRectangle(cornerRadius: 4)
|
RoundedRectangle(cornerRadius: 4)
|
||||||
@@ -41,5 +88,6 @@ struct EventBlock: View {
|
|||||||
.padding(4)
|
.padding(4)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 1)
|
.padding(.horizontal, 1)
|
||||||
|
.opacity(dimPast && isPast ? 0.5 : 1.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ struct WeekView: View {
|
|||||||
let onShowMonth: (Date) -> Void
|
let onShowMonth: (Date) -> Void
|
||||||
let onShowDay: (Date) -> Void
|
let onShowDay: (Date) -> Void
|
||||||
|
|
||||||
@AppStorage("appLanguage") private var appLang = "system"
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
@AppStorage("todayColor") private var todayHex = "#4285f4"
|
@AppStorage("todayColor") private var todayHex = "#4285f4"
|
||||||
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
||||||
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
|
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
|
||||||
|
@AppStorage("textContrast") private var textContrast = 3
|
||||||
|
@AppStorage("lineContrast") private var lineContrast = 3
|
||||||
|
@AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout
|
||||||
|
|
||||||
private var cal: Calendar { store.userCalendar }
|
private var cal: Calendar { store.userCalendar }
|
||||||
|
|
||||||
@@ -21,14 +24,16 @@ struct WeekView: View {
|
|||||||
|
|
||||||
private var timedEvents: [(Int, CalEvent)] {
|
private var timedEvents: [(Int, CalEvent)] {
|
||||||
weekDays.enumerated().flatMap { idx, day in
|
weekDays.enumerated().flatMap { idx, day in
|
||||||
store.events(on: day).filter { !$0.isAllDay }.map { (idx, $0) }
|
store.events(on: day)
|
||||||
|
.filter { !$0.isAllDay && !eventSpansMultipleDays($0) }
|
||||||
|
.map { (idx, $0) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var allDayEvents: [CalEvent] {
|
private var allDayEvents: [CalEvent] {
|
||||||
let s = weekDays.first ?? .now
|
let s = weekDays.first ?? .now
|
||||||
let e = cal.date(byAdding: .day, value: 1, to: weekDays.last ?? .now)!
|
let e = cal.date(byAdding: .day, value: 1, to: weekDays.last ?? .now)!
|
||||||
return store.events(in: s, end: e).filter(\.isAllDay)
|
return store.events(in: s, end: e).filter { $0.isAllDay || eventSpansMultipleDays($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var todayIndex: Int? {
|
private var todayIndex: Int? {
|
||||||
@@ -56,10 +61,10 @@ struct WeekView: View {
|
|||||||
ForEach(weekDays, id: \.self) { day in
|
ForEach(weekDays, id: \.self) { day in
|
||||||
Text(headerFmt.string(from: day).uppercased())
|
Text(headerFmt.string(from: day).uppercased())
|
||||||
.font(.system(size: 10, weight: .semibold))
|
.font(.system(size: 10, weight: .semibold))
|
||||||
.foregroundStyle(cal.isDateInToday(day) ? Color.accentColor : Color(hex: textHex).opacity(0.7))
|
.foregroundStyle(cal.isDateInToday(day) ? Color(hex: todayHex) : Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
|
||||||
.frame(maxWidth: .infinity, minHeight: 36)
|
.frame(maxWidth: .infinity, minHeight: 36)
|
||||||
.overlay(alignment: .trailing) {
|
.overlay(alignment: .trailing) {
|
||||||
Rectangle().fill(Color(hex: lineHex)).frame(width: 0.5)
|
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(width: 0.5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,7 +99,7 @@ struct WeekView: View {
|
|||||||
.padding(.horizontal, 1)
|
.padding(.horizontal, 1)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.overlay(alignment: .trailing) {
|
.overlay(alignment: .trailing) {
|
||||||
Rectangle().fill(Color(.separator)).frame(width: 0.5)
|
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(width: 0.5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,7 +132,7 @@ struct WeekView: View {
|
|||||||
}
|
}
|
||||||
.frame(width: colW)
|
.frame(width: colW)
|
||||||
.overlay(alignment: .trailing) {
|
.overlay(alignment: .trailing) {
|
||||||
Rectangle().fill(Color(hex: lineHex)).frame(width: 0.5)
|
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(width: 0.5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,7 +176,7 @@ struct WeekView: View {
|
|||||||
Color.clear.frame(height: hourHeight)
|
Color.clear.frame(height: hourHeight)
|
||||||
Text(String(format: "%02d:00", h))
|
Text(String(format: "%02d:00", h))
|
||||||
.font(.system(size: 10))
|
.font(.system(size: 10))
|
||||||
.foregroundStyle(Color(hex: textHex).opacity(0.6))
|
.foregroundStyle(Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
|
||||||
.offset(y: -6)
|
.offset(y: -6)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,7 +213,8 @@ struct HourSlot: View {
|
|||||||
let onShowMonth: (Date) -> Void
|
let onShowMonth: (Date) -> Void
|
||||||
let onShowDay: (Date) -> Void
|
let onShowDay: (Date) -> Void
|
||||||
|
|
||||||
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
|
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
|
||||||
|
@AppStorage("lineContrast") private var lineContrast = 3
|
||||||
|
|
||||||
private var date: Date {
|
private var date: Date {
|
||||||
Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day
|
Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day
|
||||||
@@ -216,7 +222,7 @@ struct HourSlot: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Rectangle().fill(Color(hex: lineHex).opacity(0.4)).frame(height: 0.5)
|
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(height: 0.5)
|
||||||
Color.clear.frame(height: hourHeight - 0.5)
|
Color.clear.frame(height: hourHeight - 0.5)
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
|
|||||||
285
Calendarr iOS/Views/CalendarFilterSheet.swift
Normal file
285
Calendarr iOS/Views/CalendarFilterSheet.swift
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Lets the user toggle which calendars contribute events to the displayed
|
||||||
|
/// calendar views (and the home-screen widgets). Filtering is purely
|
||||||
|
/// client-side: hidden keys live in UserDefaults via `CalendarStore`. No
|
||||||
|
/// server roundtrip is required to toggle visibility.
|
||||||
|
struct CalendarFilterSheet: View {
|
||||||
|
let api: CalendarrAPI
|
||||||
|
let store: CalendarStore
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
|
|
||||||
|
@State private var caldavAccounts: [CalDAVAccount] = []
|
||||||
|
@State private var localCalendars: [LocalCalendar] = []
|
||||||
|
@State private var icalSubs: [ICalSubscription] = []
|
||||||
|
@State private var googleAccounts: [GoogleAccount] = []
|
||||||
|
@State private var haAccounts: [HomeAssistantAccount] = []
|
||||||
|
@State private var isLoading = true
|
||||||
|
@State private var hidden: Set<String> = []
|
||||||
|
@State private var banished: Set<String> = []
|
||||||
|
/// All non-banished keys discovered during load — used by bulk show/hide.
|
||||||
|
@State private var allKeys: Set<String> = []
|
||||||
|
/// Group-mode: the active group's full detail (members + colours) and the
|
||||||
|
/// per-member / group-calendar hidden keys.
|
||||||
|
@State private var groupDetail: CalGroup? = nil
|
||||||
|
@State private var hiddenGroup: Set<String> = []
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if isLoading {
|
||||||
|
ProgressView(L10n.t("filter.loading", appLang))
|
||||||
|
} else if store.activeGroup != nil {
|
||||||
|
groupFilterList
|
||||||
|
} else if allKeys.isEmpty {
|
||||||
|
Text(L10n.t("filter.empty", appLang))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
let visibleLocals = localCalendars.filter {
|
||||||
|
!banished.contains(CalendarStore.calendarKey(source: "local", calendarId: "\($0.id)"))
|
||||||
|
}
|
||||||
|
if !visibleLocals.isEmpty {
|
||||||
|
Section(L10n.t("accounts.local.header", appLang)) {
|
||||||
|
ForEach(visibleLocals) { cal in
|
||||||
|
row(name: cal.name, colorHex: cal.color,
|
||||||
|
key: CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForEach(caldavAccounts) { acc in
|
||||||
|
let cals = (acc.calendars ?? []).filter {
|
||||||
|
!banished.contains(CalendarStore.calendarKey(source: "caldav", calendarId: "\($0.id)"))
|
||||||
|
}
|
||||||
|
if !cals.isEmpty {
|
||||||
|
Section(acc.name) {
|
||||||
|
ForEach(cals) { cal in
|
||||||
|
row(name: cal.name,
|
||||||
|
colorHex: cal.color ?? acc.color,
|
||||||
|
key: CalendarStore.calendarKey(source: "caldav", calendarId: "\(cal.id)"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let visibleSubs = icalSubs.filter {
|
||||||
|
!banished.contains(CalendarStore.calendarKey(source: "ical", calendarId: "\($0.id)"))
|
||||||
|
}
|
||||||
|
if !visibleSubs.isEmpty {
|
||||||
|
Section(L10n.t("accounts.ical.header", appLang)) {
|
||||||
|
ForEach(visibleSubs) { sub in
|
||||||
|
row(name: sub.name, colorHex: sub.color,
|
||||||
|
key: CalendarStore.calendarKey(source: "ical", calendarId: "\(sub.id)"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForEach(googleAccounts) { acc in
|
||||||
|
let cals = (acc.calendars ?? []).filter {
|
||||||
|
!banished.contains(CalendarStore.calendarKey(source: "google", calendarId: "\($0.id)"))
|
||||||
|
}
|
||||||
|
if !cals.isEmpty {
|
||||||
|
Section(acc.email) {
|
||||||
|
ForEach(cals) { cal in
|
||||||
|
row(name: cal.name,
|
||||||
|
colorHex: cal.color ?? "#4285f4",
|
||||||
|
key: CalendarStore.calendarKey(source: "google", calendarId: "\(cal.id)"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForEach(haAccounts) { acc in
|
||||||
|
let cals = (acc.calendars ?? []).filter {
|
||||||
|
!banished.contains(CalendarStore.calendarKey(source: "homeassistant", calendarId: "\($0.id)"))
|
||||||
|
}
|
||||||
|
if !cals.isEmpty {
|
||||||
|
Section(acc.name) {
|
||||||
|
ForEach(cals) { cal in
|
||||||
|
row(name: cal.name,
|
||||||
|
colorHex: cal.color ?? "#46bdc6",
|
||||||
|
key: CalendarStore.calendarKey(source: "homeassistant", calendarId: "\(cal.id)"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !banished.isEmpty {
|
||||||
|
Section {
|
||||||
|
Text(L10n.t("filter.banished_footer", appLang))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(L10n.t("filter.title", appLang))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Menu {
|
||||||
|
Button(L10n.t("filter.show_all", appLang)) {
|
||||||
|
hidden = []
|
||||||
|
store.setHiddenCalendars(hidden)
|
||||||
|
}
|
||||||
|
Button(L10n.t("filter.hide_all", appLang)) {
|
||||||
|
hidden = allKeys
|
||||||
|
store.setHiddenCalendars(hidden)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
|
}
|
||||||
|
.disabled(allKeys.isEmpty)
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button(L10n.t("nav.done", appLang)) { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func row(name: String, colorHex: String, key: String) -> some View {
|
||||||
|
let isVisible = !hidden.contains(key)
|
||||||
|
Button {
|
||||||
|
if isVisible { hidden.insert(key) } else { hidden.remove(key) }
|
||||||
|
// New hidden state == was-visible (flip). Previous code passed the
|
||||||
|
// inverse, which persisted the opposite of what the UI showed.
|
||||||
|
store.setCalendarHidden(key, hidden: isVisible)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Circle()
|
||||||
|
.fill(Color(hex: colorHex))
|
||||||
|
.frame(width: 14, height: 14)
|
||||||
|
.opacity(isVisible ? 1.0 : 0.35)
|
||||||
|
Text(name)
|
||||||
|
.foregroundStyle(isVisible ? .primary : .secondary)
|
||||||
|
.strikethrough(!isVisible, color: .secondary)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: isVisible ? "eye" : "eye.slash")
|
||||||
|
.foregroundStyle(isVisible ? Color.accentColor : .secondary)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
hidden.remove(key)
|
||||||
|
banished.insert(key)
|
||||||
|
store.setCalendarBanished(key, banished: true)
|
||||||
|
pushBanishToServer(key: key, hidden: true)
|
||||||
|
} label: {
|
||||||
|
Label(L10n.t("filter.banish", appLang), systemImage: "archivebox")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Group overlay filter (hide individual members / the group calendar)
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var groupFilterList: some View {
|
||||||
|
if let g = groupDetail {
|
||||||
|
List {
|
||||||
|
Section(header: Label(g.name, systemImage: GroupIcons.symbol(g.icon))) {
|
||||||
|
ForEach(g.members ?? []) { m in
|
||||||
|
groupRow(name: m.displayName ?? "—",
|
||||||
|
colorHex: m.color ?? "#4285f4",
|
||||||
|
key: CalendarStore.groupMemberKey(m.id))
|
||||||
|
}
|
||||||
|
groupRow(name: L10n.t("group.calendar", appLang),
|
||||||
|
colorHex: g.groupCalendarColor ?? "#4285f4",
|
||||||
|
key: CalendarStore.groupCalendarKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(L10n.t("filter.empty", appLang)).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func groupRow(name: String, colorHex: String, key: String) -> some View {
|
||||||
|
let isVisible = !hiddenGroup.contains(key)
|
||||||
|
Button {
|
||||||
|
if isVisible { hiddenGroup.insert(key) } else { hiddenGroup.remove(key) }
|
||||||
|
store.setGroupKeyHidden(key, hidden: isVisible)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Circle()
|
||||||
|
.fill(Color(hex: colorHex))
|
||||||
|
.frame(width: 14, height: 14)
|
||||||
|
.opacity(isVisible ? 1.0 : 0.35)
|
||||||
|
Text(name)
|
||||||
|
.foregroundStyle(isVisible ? .primary : .secondary)
|
||||||
|
.strikethrough(!isVisible, color: .secondary)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: isVisible ? "eye" : "eye.slash")
|
||||||
|
.foregroundStyle(isVisible ? Color.accentColor : .secondary)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
isLoading = true
|
||||||
|
// Group overlay: list members (+ the group calendar) to hide individually.
|
||||||
|
if let g = store.activeGroup {
|
||||||
|
hiddenGroup = store.hiddenGroupKeys
|
||||||
|
groupDetail = try? await api.getGroup(id: g.id)
|
||||||
|
isLoading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hidden = store.hiddenCalendarKeys
|
||||||
|
banished = store.banishedCalendarKeys
|
||||||
|
async let c = (try? await api.getCalDAVAccounts()) ?? []
|
||||||
|
async let l = (try? await api.getLocalCalendars()) ?? []
|
||||||
|
async let i = (try? await api.getICalSubscriptions()) ?? []
|
||||||
|
async let g = (try? await api.getGoogleAccounts()) ?? []
|
||||||
|
async let h = (try? await api.getHomeAssistantAccounts()) ?? []
|
||||||
|
(caldavAccounts, localCalendars, icalSubs, googleAccounts, haAccounts) = await (c, l, i, g, h)
|
||||||
|
|
||||||
|
// Reconcile banished state with the server's sidebar_hidden flags
|
||||||
|
// (server wins for CalDAV/Google/HA; local/ical keep their local state).
|
||||||
|
var b = store.banishedCalendarKeys
|
||||||
|
func applyServerHidden(_ source: String, _ id: Int, _ hidden: Bool) {
|
||||||
|
let key = CalendarStore.calendarKey(source: source, calendarId: "\(id)")
|
||||||
|
if hidden { b.insert(key) } else { b.remove(key) }
|
||||||
|
}
|
||||||
|
for acc in caldavAccounts { for cal in acc.calendars ?? [] { applyServerHidden("caldav", cal.id, cal.sidebarHidden) } }
|
||||||
|
for acc in googleAccounts { for cal in acc.calendars ?? [] { applyServerHidden("google", cal.id, cal.sidebarHidden) } }
|
||||||
|
for acc in haAccounts { for cal in acc.calendars ?? [] { applyServerHidden("homeassistant", cal.id, cal.sidebarHidden) } }
|
||||||
|
store.setBanishedCalendars(b)
|
||||||
|
banished = b
|
||||||
|
|
||||||
|
var keys = Set<String>()
|
||||||
|
for cal in localCalendars {
|
||||||
|
keys.insert(CalendarStore.calendarKey(source: "local", calendarId: "\(cal.id)"))
|
||||||
|
}
|
||||||
|
for acc in caldavAccounts {
|
||||||
|
for cal in acc.calendars ?? [] {
|
||||||
|
keys.insert(CalendarStore.calendarKey(source: "caldav", calendarId: "\(cal.id)"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for sub in icalSubs {
|
||||||
|
keys.insert(CalendarStore.calendarKey(source: "ical", calendarId: "\(sub.id)"))
|
||||||
|
}
|
||||||
|
for acc in googleAccounts {
|
||||||
|
for cal in acc.calendars ?? [] {
|
||||||
|
keys.insert(CalendarStore.calendarKey(source: "google", calendarId: "\(cal.id)"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for acc in haAccounts {
|
||||||
|
for cal in acc.calendars ?? [] {
|
||||||
|
keys.insert(CalendarStore.calendarKey(source: "homeassistant", calendarId: "\(cal.id)"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allKeys = keys
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For server-backed sources, persist the banish on the server too.
|
||||||
|
private func pushBanishToServer(key: String, hidden: Bool) {
|
||||||
|
guard let parsed = CalendarStore.parseCalendarKey(key),
|
||||||
|
CalendarStore.serverManagedSources.contains(parsed.source) else { return }
|
||||||
|
Task { try? await api.setCalendarSidebarHidden(source: parsed.source, calendarId: parsed.id, hidden: hidden) }
|
||||||
|
}
|
||||||
|
}
|
||||||
412
Calendarr iOS/Views/GroupsView.swift
Normal file
412
Calendarr iOS/Views/GroupsView.swift
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Group icons (cross-platform, non-emoji)
|
||||||
|
|
||||||
|
/// Canonical group-icon keys stored server-side and rendered as native SF
|
||||||
|
/// Symbols here (Material on Android, SVG on web), so groups look consistent
|
||||||
|
/// instead of relying on OS-specific emoji rendering.
|
||||||
|
enum GroupIcons {
|
||||||
|
static let keys = ["people", "home", "heart", "work", "school", "sports",
|
||||||
|
"party", "pet", "travel", "music", "food", "star"]
|
||||||
|
|
||||||
|
static func symbol(_ key: String?) -> String {
|
||||||
|
switch key {
|
||||||
|
case "people": return "person.2.fill"
|
||||||
|
case "home": return "house.fill"
|
||||||
|
case "heart": return "heart.fill"
|
||||||
|
case "work": return "briefcase.fill"
|
||||||
|
case "school": return "graduationcap.fill"
|
||||||
|
case "sports": return "figure.run"
|
||||||
|
case "party": return "party.popper.fill"
|
||||||
|
case "pet": return "pawprint.fill"
|
||||||
|
case "travel": return "airplane"
|
||||||
|
case "music": return "music.note"
|
||||||
|
case "food": return "fork.knife"
|
||||||
|
case "star": return "star.fill"
|
||||||
|
default: return "person.2.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isKey(_ s: String?) -> Bool { if let s { return keys.contains(s) }; return false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a group's icon: native SF Symbol for known keys, the legacy emoji for
|
||||||
|
/// pre-migration groups, else a default people glyph.
|
||||||
|
struct GroupIconView: View {
|
||||||
|
let icon: String?
|
||||||
|
var body: some View {
|
||||||
|
if GroupIcons.isKey(icon) {
|
||||||
|
Image(systemName: GroupIcons.symbol(icon))
|
||||||
|
} else if let e = icon, !e.isEmpty {
|
||||||
|
Text(e)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "person.2.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Groups list
|
||||||
|
|
||||||
|
struct GroupsView: View {
|
||||||
|
let api: CalendarrAPI
|
||||||
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
|
@State private var groups: [CalGroup] = []
|
||||||
|
@State private var isLoading = true
|
||||||
|
@State private var showCreate = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if isLoading {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
if groups.isEmpty {
|
||||||
|
Text(L10n.t("groups.none", appLang)).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
ForEach(groups) { g in
|
||||||
|
NavigationLink {
|
||||||
|
GroupCombinedView(api: api, group: g)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
GroupIconView(icon: g.icon)
|
||||||
|
Text(g.name)
|
||||||
|
Spacer()
|
||||||
|
if let n = g.memberCount {
|
||||||
|
Text("\(n)").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.swipeActions {
|
||||||
|
NavigationLink {
|
||||||
|
GroupManageSheet(api: api, groupId: g.id) { await load() }
|
||||||
|
} label: {
|
||||||
|
Label(L10n.t("group.manage", appLang), systemImage: "slider.horizontal.3")
|
||||||
|
}.tint(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(L10n.t("groups.title", appLang))
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button { showCreate = true } label: { Image(systemName: "plus") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showCreate) {
|
||||||
|
GroupEditSheet(api: api, existing: nil) { await load() }
|
||||||
|
}
|
||||||
|
.task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
isLoading = true
|
||||||
|
groups = (try? await api.getGroups()) ?? []
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Create / edit a group (name + icon + members)
|
||||||
|
|
||||||
|
struct GroupEditSheet: View {
|
||||||
|
let api: CalendarrAPI
|
||||||
|
let existing: CalGroup?
|
||||||
|
let onDone: () async -> Void
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
|
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var icon = "people"
|
||||||
|
@State private var directory: [DirectoryUser] = []
|
||||||
|
@State private var selected: Set<Int> = []
|
||||||
|
@State private var error = ""
|
||||||
|
|
||||||
|
private let icons = GroupIcons.keys
|
||||||
|
private let cols = [GridItem(.adaptive(minimum: 46))]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section(L10n.t("group.name", appLang)) {
|
||||||
|
TextField(L10n.t("group.name", appLang), text: $name)
|
||||||
|
}
|
||||||
|
Section(L10n.t("group.icon", appLang)) {
|
||||||
|
LazyVGrid(columns: cols, spacing: 8) {
|
||||||
|
ForEach(icons, id: \.self) { ic in
|
||||||
|
Image(systemName: GroupIcons.symbol(ic))
|
||||||
|
.font(.title3)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.background(ic == icon ? Color.accentColor.opacity(0.25) : Color(.systemGray6))
|
||||||
|
.foregroundStyle(ic == icon ? Color.accentColor : .primary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.onTapGesture { icon = ic }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section(L10n.t("group.members", appLang)) {
|
||||||
|
ForEach(directory) { u in
|
||||||
|
Button {
|
||||||
|
if selected.contains(u.id) { selected.remove(u.id) } else { selected.insert(u.id) }
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: selected.contains(u.id) ? "checkmark.square.fill" : "square")
|
||||||
|
.foregroundStyle(selected.contains(u.id) ? Color.accentColor : .secondary)
|
||||||
|
Text(u.displayName).foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !error.isEmpty { Section { Text(error).foregroundStyle(.red) } }
|
||||||
|
}
|
||||||
|
.navigationTitle(existing == nil ? L10n.t("group.create", appLang) : L10n.t("group.manage", appLang))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button(L10n.t("event.save", appLang)) { Task { await save() } }
|
||||||
|
.bold().disabled(name.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { directory = (try? await api.getUserDirectory()) ?? [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() async {
|
||||||
|
do {
|
||||||
|
_ = try await api.createGroup(name: name, memberIds: Array(selected), icon: icon)
|
||||||
|
await onDone()
|
||||||
|
dismiss()
|
||||||
|
} catch { self.error = error.localizedDescription }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Manage existing group (rename, icon, members, colors, delete)
|
||||||
|
|
||||||
|
struct GroupManageSheet: View {
|
||||||
|
let api: CalendarrAPI
|
||||||
|
let groupId: Int
|
||||||
|
let onDone: () async -> Void
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
|
|
||||||
|
@State private var group: CalGroup?
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var icon = "people"
|
||||||
|
@State private var directory: [DirectoryUser] = []
|
||||||
|
@State private var memberIds: Set<Int> = []
|
||||||
|
@State private var showDeleteConfirm = false
|
||||||
|
@State private var error = ""
|
||||||
|
|
||||||
|
private let icons = GroupIcons.keys
|
||||||
|
private let cols = [GridItem(.adaptive(minimum: 46))]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section(L10n.t("group.name", appLang)) {
|
||||||
|
TextField(L10n.t("group.name", appLang), text: $name)
|
||||||
|
}
|
||||||
|
Section(L10n.t("group.icon", appLang)) {
|
||||||
|
LazyVGrid(columns: cols, spacing: 8) {
|
||||||
|
ForEach(icons, id: \.self) { ic in
|
||||||
|
Image(systemName: GroupIcons.symbol(ic))
|
||||||
|
.font(.title3)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.background(ic == icon ? Color.accentColor.opacity(0.25) : Color(.systemGray6))
|
||||||
|
.foregroundStyle(ic == icon ? Color.accentColor : .primary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
.onTapGesture { icon = ic }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section(L10n.t("group.members", appLang)) {
|
||||||
|
ForEach(directory) { u in
|
||||||
|
Button {
|
||||||
|
if memberIds.contains(u.id) { memberIds.remove(u.id) } else { memberIds.insert(u.id) }
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: memberIds.contains(u.id) ? "checkmark.square.fill" : "square")
|
||||||
|
.foregroundStyle(memberIds.contains(u.id) ? Color.accentColor : .secondary)
|
||||||
|
Text(u.displayName).foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let members = group?.members {
|
||||||
|
Section(L10n.t("group.member_colors", appLang)) {
|
||||||
|
ForEach(members) { m in
|
||||||
|
MemberColorRow(api: api, groupId: groupId, member: m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
Button(role: .destructive) { showDeleteConfirm = true } label: {
|
||||||
|
Label(L10n.t("group.delete", appLang), systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !error.isEmpty { Section { Text(error).foregroundStyle(.red) } }
|
||||||
|
}
|
||||||
|
.navigationTitle(L10n.t("group.manage", appLang))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) { Button(L10n.t("common.cancel", appLang)) { dismiss() } }
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button(L10n.t("event.save", appLang)) { Task { await save() } }.bold()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert(L10n.t("group.delete", appLang), isPresented: $showDeleteConfirm) {
|
||||||
|
Button(L10n.t("group.delete", appLang), role: .destructive) { Task { await deleteGroup() } }
|
||||||
|
Button(L10n.t("common.cancel", appLang), role: .cancel) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
directory = (try? await api.getUserDirectory()) ?? []
|
||||||
|
if let g = try? await api.getGroup(id: groupId) {
|
||||||
|
group = g
|
||||||
|
name = g.name
|
||||||
|
icon = GroupIcons.isKey(g.icon) ? g.icon! : "people"
|
||||||
|
let me = UserDefaults.standard.integer(forKey: "userId")
|
||||||
|
memberIds = Set((g.members ?? []).map { $0.id }.filter { $0 != me })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() async {
|
||||||
|
do {
|
||||||
|
try await api.updateGroup(id: groupId, name: name, icon: icon)
|
||||||
|
let me = UserDefaults.standard.integer(forKey: "userId")
|
||||||
|
let current = Set((group?.members ?? []).map { $0.id }.filter { $0 != me })
|
||||||
|
for id in memberIds where !current.contains(id) { try await api.addGroupMember(groupId: groupId, userId: id) }
|
||||||
|
for id in current where !memberIds.contains(id) { try await api.removeGroupMember(groupId: groupId, userId: id) }
|
||||||
|
await onDone()
|
||||||
|
dismiss()
|
||||||
|
} catch { self.error = error.localizedDescription }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteGroup() async {
|
||||||
|
do { try await api.deleteGroup(id: groupId); await onDone(); dismiss() }
|
||||||
|
catch { self.error = error.localizedDescription }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MemberColorRow: View {
|
||||||
|
let api: CalendarrAPI
|
||||||
|
let groupId: Int
|
||||||
|
let member: GroupMember
|
||||||
|
@State private var color: Color
|
||||||
|
|
||||||
|
init(api: CalendarrAPI, groupId: Int, member: GroupMember) {
|
||||||
|
self.api = api; self.groupId = groupId; self.member = member
|
||||||
|
_color = State(initialValue: Color(hex: member.color ?? "#4285f4"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text(member.displayName ?? "—")
|
||||||
|
Spacer()
|
||||||
|
ColorPicker("", selection: $color, supportsOpacity: false)
|
||||||
|
.labelsHidden()
|
||||||
|
.onChange(of: color) { _, c in
|
||||||
|
Task { try? await api.setGroupMemberColor(groupId: groupId, userId: member.id, color: c.toHex()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Combined (overlay) agenda view
|
||||||
|
|
||||||
|
struct GroupCombinedView: View {
|
||||||
|
let api: CalendarrAPI
|
||||||
|
let group: CalGroup
|
||||||
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
|
|
||||||
|
@State private var anchor = Date()
|
||||||
|
@State private var events: [CalEvent] = []
|
||||||
|
@State private var isLoading = false
|
||||||
|
|
||||||
|
private var monthRange: (Date, Date) {
|
||||||
|
let cal = Calendar.current
|
||||||
|
let start = cal.date(from: cal.dateComponents([.year, .month], from: anchor)) ?? anchor
|
||||||
|
let end = cal.date(byAdding: .month, value: 1, to: start) ?? anchor
|
||||||
|
return (start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var grouped: [(day: Date, items: [CalEvent])] {
|
||||||
|
let cal = Calendar.current
|
||||||
|
let dict = Dictionary(grouping: events.sorted { $0.startDate < $1.startDate }) {
|
||||||
|
cal.startOfDay(for: $0.startDate)
|
||||||
|
}
|
||||||
|
return dict.keys.sorted().map { ($0, dict[$0] ?? []) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private let monthFmt: DateFormatter = {
|
||||||
|
let f = DateFormatter(); f.dateFormat = "MMMM yyyy"; return f
|
||||||
|
}()
|
||||||
|
private let dayFmt: DateFormatter = {
|
||||||
|
let f = DateFormatter(); f.dateFormat = "EEEE, d. MMM"; return f
|
||||||
|
}()
|
||||||
|
private let timeFmt: DateFormatter = {
|
||||||
|
let f = DateFormatter(); f.timeStyle = .short; f.dateStyle = .none; return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
ForEach(grouped, id: \.day) { section in
|
||||||
|
Section(dayFmt.string(from: section.day)) {
|
||||||
|
ForEach(section.items) { ev in
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
RoundedRectangle(cornerRadius: 3)
|
||||||
|
.fill(Color(hex: ev.effectiveColor))
|
||||||
|
.frame(width: 5, height: 34)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(displayTitle(ev)).font(.body)
|
||||||
|
Text(ev.isAllDay ? L10n.t("event.allday", appLang) : timeFmt.string(from: ev.startDate))
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !isLoading && events.isEmpty {
|
||||||
|
Text(L10n.t("groups.combined_empty", appLang)).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(group.name)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
HStack {
|
||||||
|
Button { shift(-1) } label: { Image(systemName: "chevron.left") }
|
||||||
|
Button { shift(1) } label: { Image(systemName: "chevron.right") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .principal) {
|
||||||
|
Text(monthFmt.string(from: anchor)).font(.headline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task(id: anchor) { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer the server-decorated title; fall back to a name prefix.
|
||||||
|
private func displayTitle(_ ev: CalEvent) -> String {
|
||||||
|
if let dt = ev.displayTitle, !dt.isEmpty { return dt }
|
||||||
|
let me = UserDefaults.standard.integer(forKey: "userId")
|
||||||
|
if let c = ev.creator, ev.isGroupEvent, c.id != me { return "\(firstName(c.displayName)): \(ev.title)" }
|
||||||
|
if let o = ev.owner, o.id != me { return "\(firstName(o.displayName)): \(ev.title)" }
|
||||||
|
return ev.title
|
||||||
|
}
|
||||||
|
private func firstName(_ s: String) -> String { s.split(separator: " ").first.map(String.init) ?? s }
|
||||||
|
|
||||||
|
private func shift(_ months: Int) {
|
||||||
|
anchor = Calendar.current.date(byAdding: .month, value: months, to: anchor) ?? anchor
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
isLoading = true
|
||||||
|
let (s, e) = monthRange
|
||||||
|
events = (try? await api.fetchGroupCombined(groupId: group.id, start: s, end: e)) ?? []
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ struct MenuSheet: View {
|
|||||||
@Environment(AppState.self) var appState
|
@Environment(AppState.self) var appState
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@AppStorage("appLanguage") private var appLang = "system"
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
|
@State private var isSyncing = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -61,6 +62,12 @@ struct MenuSheet: View {
|
|||||||
Label(L10n.t("menu.accounts", appLang), systemImage: "tray.2")
|
Label(L10n.t("menu.accounts", appLang), systemImage: "tray.2")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NavigationLink {
|
||||||
|
GroupsView(api: api)
|
||||||
|
} label: {
|
||||||
|
Label(L10n.t("groups.title", appLang), systemImage: "person.2")
|
||||||
|
}
|
||||||
|
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
ServerView()
|
ServerView()
|
||||||
} label: {
|
} label: {
|
||||||
@@ -68,6 +75,19 @@ struct MenuSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section(L10n.t("menu.sync.section", appLang)) {
|
||||||
|
Button {
|
||||||
|
Task { await syncNow() }
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Label(L10n.t("menu.sync", appLang), systemImage: "arrow.triangle.2.circlepath")
|
||||||
|
Spacer()
|
||||||
|
if isSyncing { ProgressView() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isSyncing)
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Button(role: .destructive) {
|
Button(role: .destructive) {
|
||||||
dismiss()
|
dismiss()
|
||||||
@@ -88,4 +108,14 @@ struct MenuSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Manual sync: pull appearance/behaviour settings from the server, then
|
||||||
|
/// ask the calendar host to re-fetch events (cache-busting).
|
||||||
|
private func syncNow() async {
|
||||||
|
isSyncing = true
|
||||||
|
await SettingsSync.pull(api: api)
|
||||||
|
NotificationCenter.default.post(name: .manualSyncRequested, object: nil)
|
||||||
|
isSyncing = false
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ struct ProfileView: View {
|
|||||||
kontoSection(profile: profile)
|
kontoSection(profile: profile)
|
||||||
passwordSection
|
passwordSection
|
||||||
twoFASection(profile: profile)
|
twoFASection(profile: profile)
|
||||||
|
adminNoteSection
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,6 +142,20 @@ struct ProfileView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var adminNoteSection: some View {
|
||||||
|
Section {
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.top, 1)
|
||||||
|
Text(L10n.t("profile.admin_note", appLang))
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func load() async {
|
private func load() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
|
|||||||
@@ -2,12 +2,8 @@ import SwiftUI
|
|||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
let api: CalendarrAPI
|
let api: CalendarrAPI
|
||||||
@State private var settings = AppSettings()
|
|
||||||
@State private var isLoading = true
|
|
||||||
@State private var isSaving = false
|
|
||||||
@State private var toast = ""
|
|
||||||
@State private var showToast = false
|
|
||||||
@AppStorage("liquidGlass") private var liquidGlass = false
|
@AppStorage("liquidGlass") private var liquidGlass = false
|
||||||
|
@AppStorage("settingsSync") private var settingsSync = false
|
||||||
@AppStorage("cacheMonths") private var cacheMonths = 3
|
@AppStorage("cacheMonths") private var cacheMonths = 3
|
||||||
@AppStorage("appLanguage") private var appLang = "system"
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
@AppStorage("monthDividerColor") private var dividerHex = "#7090c0"
|
@AppStorage("monthDividerColor") private var dividerHex = "#7090c0"
|
||||||
@@ -16,55 +12,185 @@ struct SettingsView: View {
|
|||||||
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
||||||
@AppStorage("backgroundColor") private var bgHex = "#000000"
|
@AppStorage("backgroundColor") private var bgHex = "#000000"
|
||||||
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
|
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
|
||||||
|
@AppStorage("primaryColor") private var primaryHex = "#4285f4"
|
||||||
|
@AppStorage("accentColor") private var accentHex = "#ea4335"
|
||||||
|
// Previously server-only; now AppStorage-backed so they persist and the
|
||||||
|
// calendar views actually apply them.
|
||||||
|
@AppStorage("textContrast") private var textContrast = 3
|
||||||
|
@AppStorage("lineContrast") private var lineContrast = 3
|
||||||
|
@AppStorage("hourHeight") private var hourHeight = 60
|
||||||
|
@AppStorage("defaultView") private var defaultView = "month"
|
||||||
|
@AppStorage("weekStartDay") private var weekStartDay = "monday"
|
||||||
|
@AppStorage("dimPastEvents") private var dimPastEvents = false
|
||||||
|
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
|
||||||
|
|
||||||
|
// Profile chapter (server-backed; loaded on appear).
|
||||||
|
@State private var displayName = ""
|
||||||
|
@State private var loginName = ""
|
||||||
|
@State private var email = ""
|
||||||
|
@State private var privateVisibility = "busy"
|
||||||
|
@State private var groupVisibleId = 0 // 0 = none
|
||||||
|
@State private var ownLocalCals: [LocalCalendar] = []
|
||||||
|
@State private var profileMsg = ""
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Group {
|
Form {
|
||||||
if isLoading {
|
profilSection
|
||||||
ProgressView(L10n.t("settings.loading", appLang))
|
privatsphaereSection
|
||||||
} else {
|
benachrichtigungenSection
|
||||||
Form {
|
geteilterKalenderSection
|
||||||
liquidGlassSection
|
liquidGlassSection
|
||||||
cacheSection
|
cacheSection
|
||||||
spracheSection
|
spracheSection
|
||||||
farbenSection
|
farbenSection
|
||||||
schriftSection
|
schriftSection
|
||||||
linienSection
|
linienSection
|
||||||
ansichtSection
|
ansichtSection
|
||||||
stundenSection
|
stundenSection
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.navigationTitle(L10n.t("settings.title", appLang))
|
.navigationTitle(L10n.t("settings.title", appLang))
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
.toolbar {
|
|
||||||
ToolbarItem(placement: .primaryAction) {
|
|
||||||
Button {
|
|
||||||
Task { await save() }
|
|
||||||
} label: {
|
|
||||||
if isSaving {
|
|
||||||
ProgressView()
|
|
||||||
} else {
|
|
||||||
Text(L10n.t("settings.save", appLang)).bold()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(isSaving)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.overlay(alignment: .bottom) {
|
|
||||||
if showToast {
|
|
||||||
Text(toast)
|
|
||||||
.padding(.horizontal, 20)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.background(.regularMaterial)
|
|
||||||
.clipShape(Capsule())
|
|
||||||
.padding(.bottom, 20)
|
|
||||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.animation(.easeInOut, value: showToast)
|
|
||||||
}
|
}
|
||||||
.task { await load() }
|
// Reflect the latest server values when opening the screen.
|
||||||
|
.task { await SettingsSync.pull(api: api) }
|
||||||
|
.task { await loadProfile() }
|
||||||
|
// Appearance changes update widgets live; synced values are also pushed
|
||||||
|
// to the server (debounced). `push` itself decides what actually gets
|
||||||
|
// sent based on the sync toggle, so every change can simply call it.
|
||||||
|
.onChange(of: primaryHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
|
||||||
|
.onChange(of: accentHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
|
||||||
|
.onChange(of: todayHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
|
||||||
|
.onChange(of: textHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
|
||||||
|
.onChange(of: bgHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
|
||||||
|
.onChange(of: lineHex) { _, _ in WidgetStore.republishAppearanceOnly(); SettingsSync.push(api: api) }
|
||||||
|
.onChange(of: dividerHex) { _, _ in SettingsSync.push(api: api) }
|
||||||
|
.onChange(of: labelHex) { _, _ in SettingsSync.push(api: api) }
|
||||||
|
.onChange(of: textContrast) { _, _ in SettingsSync.push(api: api) }
|
||||||
|
.onChange(of: lineContrast) { _, _ in SettingsSync.push(api: api) }
|
||||||
|
.onChange(of: hourHeight) { _, _ in SettingsSync.push(api: api) }
|
||||||
|
.onChange(of: defaultView) { _, _ in SettingsSync.push(api: api) }
|
||||||
|
.onChange(of: weekStartDay) { _, _ in SettingsSync.push(api: api) }
|
||||||
|
.onChange(of: dimPastEvents) { _, _ in SettingsSync.push(api: api) }
|
||||||
|
.onChange(of: appLang) { _, _ in WidgetStore.republishAppearanceOnly() }
|
||||||
|
// Enabling sync adopts the server's appearance (server wins).
|
||||||
|
.onChange(of: settingsSync) { _, on in if on { Task { await SettingsSync.pull(api: api) } } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Profil
|
||||||
|
|
||||||
|
var profilSection: some View {
|
||||||
|
Section(L10n.t("settings.nav.profile", appLang)) {
|
||||||
|
HStack {
|
||||||
|
Text(L10n.t("profile.display_name", appLang))
|
||||||
|
Spacer()
|
||||||
|
TextField(L10n.t("profile.display_name", appLang), text: $displayName)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text(L10n.t("profile.login_name", appLang))
|
||||||
|
Spacer()
|
||||||
|
Text(loginName).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("E-Mail")
|
||||||
|
Spacer()
|
||||||
|
TextField("E-Mail", text: $email)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.keyboardType(.emailAddress)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
}
|
||||||
|
Button(L10n.t("event.save", appLang)) { Task { await saveProfile() } }
|
||||||
|
if !profileMsg.isEmpty {
|
||||||
|
Text(profileMsg).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Benachrichtigungen
|
||||||
|
|
||||||
|
var benachrichtigungenSection: some View {
|
||||||
|
Section {
|
||||||
|
Picker(ReminderOptions.defaultTitle(appLang), selection: $defaultReminderMinutes) {
|
||||||
|
Text(ReminderOptions.off(appLang)).tag(-1)
|
||||||
|
ForEach(ReminderOptions.all, id: \.self) { m in
|
||||||
|
Text(ReminderOptions.label(m, appLang)).tag(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: defaultReminderMinutes) { _, _ in
|
||||||
|
SettingsSync.push(api: api)
|
||||||
|
NotificationCenter.default.post(name: .rescheduleReminders, object: nil)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text(ReminderOptions.sectionTitle(appLang))
|
||||||
|
} footer: {
|
||||||
|
Text(ReminderOptions.defaultFooter(appLang)).font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Privatsphäre
|
||||||
|
|
||||||
|
var privatsphaereSection: some View {
|
||||||
|
Section {
|
||||||
|
Picker(L10n.t("settings.private_visibility", appLang), selection: $privateVisibility) {
|
||||||
|
Text(L10n.t("settings.private.busy", appLang)).tag("busy")
|
||||||
|
Text(L10n.t("settings.private.hidden", appLang)).tag("hidden")
|
||||||
|
}
|
||||||
|
.onChange(of: privateVisibility) { _, v in
|
||||||
|
Task { try? await api.updatePrivateVisibility(v) }
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text(L10n.t("settings.privacy", appLang))
|
||||||
|
} footer: {
|
||||||
|
Text(L10n.t("settings.private_visibility.desc", appLang)).font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Geteilter Kalender
|
||||||
|
|
||||||
|
var geteilterKalenderSection: some View {
|
||||||
|
Section {
|
||||||
|
Picker(L10n.t("settings.group_visible", appLang), selection: $groupVisibleId) {
|
||||||
|
Text(L10n.t("group.visible.none", appLang)).tag(0)
|
||||||
|
ForEach(ownLocalCals) { cal in
|
||||||
|
Text(cal.name).tag(cal.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: groupVisibleId) { _, id in
|
||||||
|
Task { try? await api.updateGroupVisibleCalendar(id == 0 ? nil : id) }
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text(L10n.t("settings.calendars", appLang))
|
||||||
|
} footer: {
|
||||||
|
Text(L10n.t("settings.group_visible.desc", appLang)).font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadProfile() async {
|
||||||
|
if let p = try? await api.getProfile() {
|
||||||
|
displayName = p.displayName ?? p.username
|
||||||
|
loginName = p.username
|
||||||
|
email = p.email ?? ""
|
||||||
|
}
|
||||||
|
if let s = try? await api.getSettings() {
|
||||||
|
privateVisibility = s.privateEventVisibility
|
||||||
|
groupVisibleId = s.groupVisibleCalendarId ?? 0
|
||||||
|
}
|
||||||
|
if let cals = try? await api.getLocalCalendars() {
|
||||||
|
ownLocalCals = cals.filter { $0.owned && !$0.group }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveProfile() async {
|
||||||
|
do {
|
||||||
|
_ = try await api.updateProfile(displayName: displayName.isEmpty ? nil : displayName,
|
||||||
|
username: nil,
|
||||||
|
email: email.isEmpty ? "" : email)
|
||||||
|
UserDefaults.standard.set(displayName, forKey: "displayName")
|
||||||
|
profileMsg = L10n.t("settings.saved", appLang)
|
||||||
|
} catch {
|
||||||
|
profileMsg = error.localizedDescription
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – Liquid Glass
|
// MARK: – Liquid Glass
|
||||||
@@ -85,10 +211,25 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(Color.accentColor)
|
.tint(Color.accentColor)
|
||||||
|
|
||||||
|
Toggle(isOn: $settingsSync) {
|
||||||
|
Label {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(L10n.t("settings.sync", appLang))
|
||||||
|
Text(L10n.t("settings.sync.desc", appLang))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "arrow.triangle.2.circlepath")
|
||||||
|
.foregroundStyle(.teal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(Color.accentColor)
|
||||||
} header: {
|
} header: {
|
||||||
Text(L10n.t("settings.appdesign", appLang))
|
Text(L10n.t("settings.appdesign", appLang))
|
||||||
} footer: {
|
} footer: {
|
||||||
Text(L10n.t("settings.liquidglass.footer", appLang))
|
Text(L10n.t("settings.sync.footer", appLang))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,8 +284,8 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
var farbenSection: some View {
|
var farbenSection: some View {
|
||||||
Section(L10n.t("settings.colors", appLang)) {
|
Section(L10n.t("settings.colors", appLang)) {
|
||||||
ColorPickerRow(label: L10n.t("settings.color.primary", appLang), hex: $settings.primaryColor)
|
ColorPickerRow(label: L10n.t("settings.color.primary", appLang), hex: $primaryHex)
|
||||||
ColorPickerRow(label: L10n.t("settings.color.accent", appLang), hex: $settings.accentColor)
|
ColorPickerRow(label: L10n.t("settings.color.accent", appLang), hex: $accentHex)
|
||||||
ColorPickerRow(label: L10n.t("settings.color.today", appLang), hex: $todayHex)
|
ColorPickerRow(label: L10n.t("settings.color.today", appLang), hex: $todayHex)
|
||||||
ColorPickerRow(label: L10n.t("settings.color.text", appLang), hex: $textHex)
|
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.background", appLang), hex: $bgHex)
|
||||||
@@ -165,7 +306,7 @@ struct SettingsView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
ContrastSelector(
|
ContrastSelector(
|
||||||
value: $settings.textContrast,
|
value: $textContrast,
|
||||||
options: [
|
options: [
|
||||||
(1, L10n.t("settings.contrast.dark", appLang)),
|
(1, L10n.t("settings.contrast.dark", appLang)),
|
||||||
(2, L10n.t("settings.contrast.medium", appLang)),
|
(2, L10n.t("settings.contrast.medium", appLang)),
|
||||||
@@ -189,7 +330,7 @@ struct SettingsView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
ContrastSelector(
|
ContrastSelector(
|
||||||
value: $settings.lineContrast,
|
value: $lineContrast,
|
||||||
options: [
|
options: [
|
||||||
(1, L10n.t("settings.linecontrast.barely", appLang)),
|
(1, L10n.t("settings.linecontrast.barely", appLang)),
|
||||||
(2, L10n.t("settings.linecontrast.subtle", appLang)),
|
(2, L10n.t("settings.linecontrast.subtle", appLang)),
|
||||||
@@ -206,18 +347,18 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
var ansichtSection: some View {
|
var ansichtSection: some View {
|
||||||
Section(L10n.t("settings.calview", appLang)) {
|
Section(L10n.t("settings.calview", appLang)) {
|
||||||
Picker(L10n.t("settings.defaultview", appLang), selection: $settings.defaultView) {
|
Picker(L10n.t("settings.defaultview", appLang), selection: $defaultView) {
|
||||||
Text(L10n.t("view.month", appLang)).tag("month")
|
Text(L10n.t("view.month", appLang)).tag("month")
|
||||||
Text(L10n.t("view.week", appLang)).tag("week")
|
Text(L10n.t("view.week", appLang)).tag("week")
|
||||||
Text(L10n.t("view.day", appLang)).tag("day")
|
Text(L10n.t("view.day", appLang)).tag("day")
|
||||||
Text(L10n.t("view.quarter", appLang)).tag("quarter")
|
Text(L10n.t("view.quarter", appLang)).tag("quarter")
|
||||||
Text(L10n.t("view.agenda", appLang)).tag("agenda")
|
Text(L10n.t("view.agenda", appLang)).tag("agenda")
|
||||||
}
|
}
|
||||||
Picker(L10n.t("settings.firstweekday", appLang), selection: $settings.weekStartDay) {
|
Picker(L10n.t("settings.firstweekday", appLang), selection: $weekStartDay) {
|
||||||
Text(L10n.t("settings.monday", appLang)).tag("monday")
|
Text(L10n.t("settings.monday", appLang)).tag("monday")
|
||||||
Text(L10n.t("settings.sunday", appLang)).tag("sunday")
|
Text(L10n.t("settings.sunday", appLang)).tag("sunday")
|
||||||
}
|
}
|
||||||
Toggle(L10n.t("settings.dimpast", appLang), isOn: $settings.dimPastEvents)
|
Toggle(L10n.t("settings.dimpast", appLang), isOn: $dimPastEvents)
|
||||||
.tint(Color.accentColor)
|
.tint(Color.accentColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,7 +374,7 @@ struct SettingsView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
ContrastSelector(
|
ContrastSelector(
|
||||||
value: $settings.hourHeight,
|
value: $hourHeight,
|
||||||
options: [
|
options: [
|
||||||
(28, L10n.t("settings.hourheight.compact", appLang)),
|
(28, L10n.t("settings.hourheight.compact", appLang)),
|
||||||
(44, L10n.t("settings.hourheight.normal", appLang)),
|
(44, L10n.t("settings.hourheight.normal", appLang)),
|
||||||
@@ -246,49 +387,6 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – Actions
|
|
||||||
|
|
||||||
private func load() async {
|
|
||||||
isLoading = true
|
|
||||||
defer { isLoading = false }
|
|
||||||
if let s = try? await api.getSettings() {
|
|
||||||
settings = s
|
|
||||||
// Mirror server-side color settings so calendar views (which read AppStorage) see them.
|
|
||||||
dividerHex = s.monthDividerColor
|
|
||||||
labelHex = s.monthLabelColor
|
|
||||||
todayHex = s.todayColor
|
|
||||||
textHex = s.textColor
|
|
||||||
bgHex = s.backgroundColor
|
|
||||||
lineHex = s.lineColor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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(L10n.t("settings.saved", appLang))
|
|
||||||
} catch {
|
|
||||||
showNotice(error.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func showNotice(_ msg: String) {
|
|
||||||
toast = msg
|
|
||||||
withAnimation { showToast = true }
|
|
||||||
Task {
|
|
||||||
try? await Task.sleep(for: .seconds(2))
|
|
||||||
withAnimation { showToast = false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – Reusable Components
|
// MARK: – Reusable Components
|
||||||
|
|||||||
151
CalendarrWidgets/CalendarDayWidgetView.swift
Normal file
151
CalendarrWidgets/CalendarDayWidgetView.swift
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
struct CalendarDayWidgetView: View {
|
||||||
|
let entry: CalendarrEntry
|
||||||
|
|
||||||
|
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||||
|
private var lang: String { snapshot?.language ?? "system" }
|
||||||
|
|
||||||
|
private var cal: Calendar {
|
||||||
|
var c = Calendar(identifier: .gregorian)
|
||||||
|
c.locale = WidgetL10n.locale(lang)
|
||||||
|
c.firstWeekday = 2
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
private var weekDays: [Date] {
|
||||||
|
let start = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: entry.date)) ?? entry.date
|
||||||
|
return (0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: start) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var upcomingEvents: [WidgetEvent] {
|
||||||
|
guard let s = snapshot else { return [] }
|
||||||
|
return WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var monthFmt: DateFormatter {
|
||||||
|
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "LLLL"; return f
|
||||||
|
}
|
||||||
|
private var weekdayFmt: DateFormatter {
|
||||||
|
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "EEEE"; return f
|
||||||
|
}
|
||||||
|
private var timeFmt: DateFormatter {
|
||||||
|
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "HH:mm"; return f
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let s = snapshot {
|
||||||
|
let primary = Color(widgetHex: s.primaryColorHex)
|
||||||
|
let accent = Color(widgetHex: s.accentColorHex)
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
header(primary: primary)
|
||||||
|
weekStrip(snapshot: s, primary: primary, accent: accent)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color(widgetHex: s.lineColorHex).opacity(0.4))
|
||||||
|
.frame(height: 0.5)
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
eventList(accent: accent)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(WidgetL10n.t("widget.no_data", lang))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Header
|
||||||
|
|
||||||
|
private func header(primary: Color) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 6) {
|
||||||
|
Text("\(cal.component(.day, from: entry.date))")
|
||||||
|
.font(.system(size: 36, weight: .bold))
|
||||||
|
.foregroundStyle(primary)
|
||||||
|
.frame(width: 44, alignment: .leading)
|
||||||
|
.minimumScaleFactor(0.7)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(monthFmt.string(from: entry.date).uppercased())
|
||||||
|
.font(.system(size: 11, weight: .bold))
|
||||||
|
.foregroundStyle(primary)
|
||||||
|
Text("\(WidgetL10n.t("widget.today", lang)), \(weekdayFmt.string(from: entry.date))")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.75)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Week strip
|
||||||
|
|
||||||
|
private func weekStrip(snapshot: WidgetSnapshot, primary: Color, accent: Color) -> some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(weekDays, id: \.self) { day in
|
||||||
|
let isToday = cal.isDateInToday(day)
|
||||||
|
let hasEvs = !WidgetHelpers.events(for: day, in: snapshot).isEmpty
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text(shortDay(day))
|
||||||
|
.font(.system(size: 8, weight: .bold))
|
||||||
|
.foregroundStyle(isToday ? accent : .secondary)
|
||||||
|
ZStack {
|
||||||
|
if isToday {
|
||||||
|
Circle().fill(primary)
|
||||||
|
} else if hasEvs {
|
||||||
|
Circle().fill(accent.opacity(0.18))
|
||||||
|
}
|
||||||
|
Text("\(cal.component(.day, from: day))")
|
||||||
|
.font(.system(size: 11, weight: isToday ? .bold : .medium))
|
||||||
|
.foregroundStyle(isToday ? .white : .primary)
|
||||||
|
}
|
||||||
|
.frame(width: 22, height: 22)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shortDay(_ date: Date) -> String {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = WidgetL10n.locale(lang)
|
||||||
|
f.dateFormat = "EEE"
|
||||||
|
return String(f.string(from: date).prefix(2)).uppercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Event list
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func eventList(accent: Color) -> some View {
|
||||||
|
if upcomingEvents.isEmpty {
|
||||||
|
Text(WidgetL10n.t("widget.no_events", lang))
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
ForEach(upcomingEvents.prefix(3)) { ev in
|
||||||
|
HStack(alignment: .center, spacing: 6) {
|
||||||
|
RoundedRectangle(cornerRadius: 1.5)
|
||||||
|
.fill(Color(widgetHex: ev.colorHex))
|
||||||
|
.frame(width: 3, height: 26)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(ev.title)
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(ev.isAllDay
|
||||||
|
? WidgetL10n.t("widget.allday", lang)
|
||||||
|
: "\(timeFmt.string(from: ev.start)) – \(timeFmt.string(from: ev.end))")
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
CalendarrWidgets/CalendarrTimelineProvider.swift
Normal file
53
CalendarrWidgets/CalendarrTimelineProvider.swift
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
struct CalendarrEntry: TimelineEntry {
|
||||||
|
let date: Date
|
||||||
|
let snapshot: WidgetSnapshot?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CalendarrTimelineProvider: TimelineProvider {
|
||||||
|
func placeholder(in context: Context) -> CalendarrEntry {
|
||||||
|
CalendarrEntry(date: .now, snapshot: WidgetStore.read())
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSnapshot(in context: Context, completion: @escaping (CalendarrEntry) -> Void) {
|
||||||
|
completion(CalendarrEntry(date: .now, snapshot: WidgetStore.read()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTimeline(in context: Context, completion: @escaping (Timeline<CalendarrEntry>) -> Void) {
|
||||||
|
let snapshot = WidgetStore.read()
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
// Provide one entry per hour for the next 24h so the widget keeps
|
||||||
|
// re-rendering as time progresses (past events drop off, "now" advances).
|
||||||
|
var entries: [CalendarrEntry] = []
|
||||||
|
for h in 0..<24 {
|
||||||
|
let date = Calendar.current.date(byAdding: .hour, value: h, to: now) ?? now
|
||||||
|
entries.append(CalendarrEntry(date: date, snapshot: snapshot))
|
||||||
|
}
|
||||||
|
// Ask iOS to refresh in 30 min to pick up any new data the app wrote.
|
||||||
|
let refreshAt = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now
|
||||||
|
completion(Timeline(entries: entries, policy: .after(refreshAt)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Shared helpers used by all widget views
|
||||||
|
|
||||||
|
enum WidgetHelpers {
|
||||||
|
static func events(for day: Date, in snapshot: WidgetSnapshot) -> [WidgetEvent] {
|
||||||
|
let cal = Calendar.current
|
||||||
|
let dayStart = cal.startOfDay(for: day)
|
||||||
|
let dayEnd = cal.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart
|
||||||
|
return snapshot.events
|
||||||
|
.filter { $0.start < dayEnd && $0.end > dayStart }
|
||||||
|
.sorted { $0.start < $1.start }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func upcoming(from now: Date, daysAhead: Int, in snapshot: WidgetSnapshot) -> [WidgetEvent] {
|
||||||
|
let cal = Calendar.current
|
||||||
|
let end = cal.date(byAdding: .day, value: daysAhead, to: cal.startOfDay(for: now)) ?? now
|
||||||
|
return snapshot.events
|
||||||
|
.filter { $0.end > now && $0.start < end }
|
||||||
|
.sorted { $0.start < $1.start }
|
||||||
|
}
|
||||||
|
}
|
||||||
10
CalendarrWidgets/CalendarrWidgets.entitlements
Normal file
10
CalendarrWidgets/CalendarrWidgets.entitlements
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.com.scarriffleservices.calendarr</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
243
CalendarrWidgets/CalendarrWidgets.swift
Normal file
243
CalendarrWidgets/CalendarrWidgets.swift
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import WidgetKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct CalendarrWidgetBundle: WidgetBundle {
|
||||||
|
var body: some Widget {
|
||||||
|
TodayWidget()
|
||||||
|
TwoDaysWidget()
|
||||||
|
ThreeDaysWidget()
|
||||||
|
ThisWeekWidget()
|
||||||
|
TwoWeeksWidget()
|
||||||
|
UpcomingWidget()
|
||||||
|
UpNextWidget()
|
||||||
|
CalendarDayWidget()
|
||||||
|
TwoMonthWidget()
|
||||||
|
NowNextEventsWidget()
|
||||||
|
LockScreenWidget()
|
||||||
|
LockScreenCountWidget()
|
||||||
|
LockScreenCountdownWidget()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared chrome modifier — keeps every home-screen widget on the same theme.
|
||||||
|
private struct CalendarrWidgetChrome: ViewModifier {
|
||||||
|
let snapshot: WidgetSnapshot?
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
let lang = snapshot?.language ?? "system"
|
||||||
|
content
|
||||||
|
.containerBackground(for: .widget) {
|
||||||
|
Color(widgetHex: snapshot?.backgroundColorHex ?? "#000000")
|
||||||
|
}
|
||||||
|
.foregroundStyle(Color(widgetHex: snapshot?.textColorHex ?? "#FFFFFF"))
|
||||||
|
.environment(\.locale, WidgetL10n.locale(lang))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension View {
|
||||||
|
func calendarrChrome(_ snapshot: WidgetSnapshot?) -> some View {
|
||||||
|
modifier(CalendarrWidgetChrome(snapshot: snapshot))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Today (small)
|
||||||
|
|
||||||
|
struct TodayWidget: Widget {
|
||||||
|
let kind: String = "TodayWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
||||||
|
TodayWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||||
|
}
|
||||||
|
.configurationDisplayName(WidgetL10n.t("widget.display.today_title", "system"))
|
||||||
|
.description(WidgetL10n.t("widget.display.today_desc", "system"))
|
||||||
|
.supportedFamilies([.systemSmall])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Today & Tomorrow (medium)
|
||||||
|
|
||||||
|
struct TwoDaysWidget: Widget {
|
||||||
|
let kind: String = "TwoDaysWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
||||||
|
TwoDaysWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||||
|
}
|
||||||
|
.configurationDisplayName(WidgetL10n.t("widget.display.days_title", "system"))
|
||||||
|
.description(WidgetL10n.t("widget.display.days_desc", "system"))
|
||||||
|
.supportedFamilies([.systemMedium])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Three Days (medium)
|
||||||
|
|
||||||
|
struct ThreeDaysWidget: Widget {
|
||||||
|
let kind: String = "ThreeDaysWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
||||||
|
ThreeDaysWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||||
|
}
|
||||||
|
.configurationDisplayName(WidgetL10n.t("widget.display.threedays_title", "system"))
|
||||||
|
.description(WidgetL10n.t("widget.display.threedays_desc", "system"))
|
||||||
|
.supportedFamilies([.systemMedium])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – This Week (medium)
|
||||||
|
|
||||||
|
struct ThisWeekWidget: Widget {
|
||||||
|
let kind: String = "ThisWeekWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
||||||
|
ThisWeekWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||||
|
}
|
||||||
|
.configurationDisplayName(WidgetL10n.t("widget.display.thisweek_title", "system"))
|
||||||
|
.description(WidgetL10n.t("widget.display.thisweek_desc", "system"))
|
||||||
|
.supportedFamilies([.systemMedium])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Two Weeks (medium)
|
||||||
|
|
||||||
|
struct TwoWeeksWidget: Widget {
|
||||||
|
let kind: String = "TwoWeeksWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
||||||
|
TwoWeeksWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||||
|
}
|
||||||
|
.configurationDisplayName(WidgetL10n.t("widget.display.twoweeks_title", "system"))
|
||||||
|
.description(WidgetL10n.t("widget.display.twoweeks_desc", "system"))
|
||||||
|
.supportedFamilies([.systemMedium])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Upcoming (large + extra large on iPad)
|
||||||
|
|
||||||
|
struct UpcomingWidget: Widget {
|
||||||
|
let kind: String = "UpcomingWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
||||||
|
UpcomingWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||||
|
}
|
||||||
|
.configurationDisplayName(WidgetL10n.t("widget.display.upcoming_title", "system"))
|
||||||
|
.description(WidgetL10n.t("widget.display.upcoming_desc", "system"))
|
||||||
|
.supportedFamilies([.systemLarge, .systemExtraLarge])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Up Next + Calendar (medium)
|
||||||
|
|
||||||
|
struct UpNextWidget: Widget {
|
||||||
|
let kind: String = "UpNextWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
||||||
|
UpNextWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||||
|
}
|
||||||
|
.configurationDisplayName(WidgetL10n.t("widget.display.upnext_title", "system"))
|
||||||
|
.description(WidgetL10n.t("widget.display.upnext_desc", "system"))
|
||||||
|
.supportedFamilies([.systemMedium])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Calendar Day: date + week strip + events (medium)
|
||||||
|
|
||||||
|
struct CalendarDayWidget: Widget {
|
||||||
|
let kind: String = "CalendarDayWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
||||||
|
CalendarDayWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||||
|
}
|
||||||
|
.configurationDisplayName(WidgetL10n.t("widget.display.calday_title", "system"))
|
||||||
|
.description(WidgetL10n.t("widget.display.calday_desc", "system"))
|
||||||
|
.supportedFamilies([.systemMedium])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Two Month calendar grid (medium + large)
|
||||||
|
|
||||||
|
struct TwoMonthWidget: Widget {
|
||||||
|
let kind: String = "TwoMonthWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
||||||
|
TwoMonthWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||||
|
}
|
||||||
|
.configurationDisplayName(WidgetL10n.t("widget.display.twomonth_title", "system"))
|
||||||
|
.description(WidgetL10n.t("widget.display.twomonth_desc", "system"))
|
||||||
|
.supportedFamilies([.systemMedium, .systemLarge])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Now & Next events (medium)
|
||||||
|
|
||||||
|
struct NowNextEventsWidget: Widget {
|
||||||
|
let kind: String = "NowNextEventsWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
||||||
|
NowNextWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||||
|
}
|
||||||
|
.configurationDisplayName(WidgetL10n.t("widget.display.nownext_title", "system"))
|
||||||
|
.description(WidgetL10n.t("widget.display.nownext_desc", "system"))
|
||||||
|
.supportedFamilies([.systemMedium])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Lock Screen: date (circular, rectangular, inline)
|
||||||
|
|
||||||
|
struct LockScreenWidget: Widget {
|
||||||
|
let kind: String = "LockScreenWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
||||||
|
LockScreenWidgetView(entry: entry)
|
||||||
|
.containerBackground(for: .widget) { Color.clear }
|
||||||
|
.environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system"))
|
||||||
|
}
|
||||||
|
.configurationDisplayName(WidgetL10n.t("widget.display.lockscreen_title", "system"))
|
||||||
|
.description(WidgetL10n.t("widget.display.lockscreen_desc", "system"))
|
||||||
|
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Lock Screen: today event count (circular, rectangular, inline)
|
||||||
|
|
||||||
|
struct LockScreenCountWidget: Widget {
|
||||||
|
let kind: String = "LockScreenCountWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
||||||
|
LockScreenCountWidgetView(entry: entry)
|
||||||
|
.containerBackground(for: .widget) { Color.clear }
|
||||||
|
.environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system"))
|
||||||
|
}
|
||||||
|
.configurationDisplayName(WidgetL10n.t("widget.display.lockscreen_count_title", "system"))
|
||||||
|
.description(WidgetL10n.t("widget.display.lockscreen_count_desc", "system"))
|
||||||
|
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Lock Screen: countdown to next event (circular, rectangular, inline)
|
||||||
|
|
||||||
|
struct LockScreenCountdownWidget: Widget {
|
||||||
|
let kind: String = "LockScreenCountdownWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
|
||||||
|
LockScreenCountdownWidgetView(entry: entry)
|
||||||
|
.containerBackground(for: .widget) { Color.clear }
|
||||||
|
.environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system"))
|
||||||
|
}
|
||||||
|
.configurationDisplayName(WidgetL10n.t("widget.display.lockscreen_countdown_title", "system"))
|
||||||
|
.description(WidgetL10n.t("widget.display.lockscreen_countdown_desc", "system"))
|
||||||
|
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
|
||||||
|
}
|
||||||
|
}
|
||||||
29
CalendarrWidgets/Info.plist
Normal file
29
CalendarrWidgets/Info.plist
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Calendarr Widgets</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.widgetkit-extension</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
288
CalendarrWidgets/LockScreenWidgetViews.swift
Normal file
288
CalendarrWidgets/LockScreenWidgetViews.swift
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
// MARK: – Date widget (existing)
|
||||||
|
|
||||||
|
struct LockScreenWidgetView: View {
|
||||||
|
let entry: CalendarrEntry
|
||||||
|
@Environment(\.widgetFamily) private var family
|
||||||
|
|
||||||
|
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||||
|
private var lang: String { snapshot?.language ?? "system" }
|
||||||
|
|
||||||
|
private var cal: Calendar {
|
||||||
|
var c = Calendar(identifier: .gregorian)
|
||||||
|
c.locale = WidgetL10n.locale(lang)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
private var nextEvent: WidgetEvent? {
|
||||||
|
guard let s = snapshot else { return nil }
|
||||||
|
return WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s).first
|
||||||
|
}
|
||||||
|
|
||||||
|
private var timeFmt: DateFormatter {
|
||||||
|
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "HH:mm"; return f
|
||||||
|
}
|
||||||
|
|
||||||
|
private var monthAbbrev: String {
|
||||||
|
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "LLL"
|
||||||
|
return f.string(from: entry.date).uppercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
var body: some View {
|
||||||
|
switch family {
|
||||||
|
case .accessoryCircular:
|
||||||
|
circularView
|
||||||
|
case .accessoryRectangular:
|
||||||
|
rectangularView
|
||||||
|
default:
|
||||||
|
inlineView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Circular: today's date
|
||||||
|
|
||||||
|
private var circularView: some View {
|
||||||
|
ZStack {
|
||||||
|
AccessoryWidgetBackground()
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Text("\(cal.component(.day, from: entry.date))")
|
||||||
|
.font(.system(size: 22, weight: .bold))
|
||||||
|
.minimumScaleFactor(0.7)
|
||||||
|
.widgetAccentable()
|
||||||
|
Text(monthAbbrev)
|
||||||
|
.font(.system(size: 8, weight: .semibold))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Rectangular: next event
|
||||||
|
|
||||||
|
private var rectangularView: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
if let ev = nextEvent {
|
||||||
|
Text(ev.isAllDay
|
||||||
|
? WidgetL10n.t("widget.allday", lang)
|
||||||
|
: timeFmt.string(from: ev.start))
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.widgetAccentable()
|
||||||
|
Text(ev.title)
|
||||||
|
.font(.system(size: 14, weight: .bold))
|
||||||
|
.lineLimit(1)
|
||||||
|
if !ev.location.isEmpty {
|
||||||
|
Text(ev.location)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Image(systemName: "calendar")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.widgetAccentable()
|
||||||
|
Text(WidgetL10n.t("widget.no_events", lang))
|
||||||
|
.font(.system(size: 13))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Inline: brief next event
|
||||||
|
|
||||||
|
private var inlineView: some View {
|
||||||
|
let text: String = {
|
||||||
|
guard let ev = nextEvent else {
|
||||||
|
return WidgetL10n.t("widget.no_events", lang)
|
||||||
|
}
|
||||||
|
return ev.isAllDay ? ev.title : "\(timeFmt.string(from: ev.start)) \(ev.title)"
|
||||||
|
}()
|
||||||
|
return Label(text, systemImage: "calendar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Today event count widget
|
||||||
|
|
||||||
|
struct LockScreenCountWidgetView: View {
|
||||||
|
let entry: CalendarrEntry
|
||||||
|
@Environment(\.widgetFamily) private var family
|
||||||
|
|
||||||
|
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||||
|
private var lang: String { snapshot?.language ?? "system" }
|
||||||
|
|
||||||
|
private var timeFmt: DateFormatter {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = WidgetL10n.locale(lang)
|
||||||
|
f.dateFormat = "HH:mm"
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
private var todayEvents: [WidgetEvent] {
|
||||||
|
guard let s = snapshot else { return [] }
|
||||||
|
return WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
var body: some View {
|
||||||
|
switch family {
|
||||||
|
case .accessoryCircular:
|
||||||
|
circularView
|
||||||
|
case .accessoryRectangular:
|
||||||
|
rectangularView
|
||||||
|
default:
|
||||||
|
inlineView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var circularView: some View {
|
||||||
|
ZStack {
|
||||||
|
AccessoryWidgetBackground()
|
||||||
|
VStack(spacing: 1) {
|
||||||
|
Image(systemName: "calendar")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.widgetAccentable()
|
||||||
|
Text("\(todayEvents.count)")
|
||||||
|
.font(.system(size: 22, weight: .bold))
|
||||||
|
.minimumScaleFactor(0.7)
|
||||||
|
.widgetAccentable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var rectangularView: some View {
|
||||||
|
let countLabel = "\(todayEvents.count) \(WidgetL10n.t("widget.events_count", lang))"
|
||||||
|
return VStack(alignment: .leading, spacing: 3) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(WidgetL10n.t("widget.today", lang).uppercased())
|
||||||
|
.font(.system(size: 9, weight: .bold))
|
||||||
|
.widgetAccentable()
|
||||||
|
Text("· \(countLabel)")
|
||||||
|
.font(.system(size: 9))
|
||||||
|
}
|
||||||
|
if todayEvents.isEmpty {
|
||||||
|
Text(WidgetL10n.t("widget.no_events", lang))
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(todayEvents.prefix(2)) { ev in
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(ev.isAllDay ? "·" : timeFmt.string(from: ev.start))
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.widgetAccentable()
|
||||||
|
.frame(width: 32, alignment: .leading)
|
||||||
|
Text(ev.title)
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var inlineView: some View {
|
||||||
|
let label = "\(todayEvents.count) \(WidgetL10n.t("widget.events_count", lang))"
|
||||||
|
return Label(label, systemImage: "calendar.badge.clock")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Countdown to next event widget
|
||||||
|
|
||||||
|
struct LockScreenCountdownWidgetView: View {
|
||||||
|
let entry: CalendarrEntry
|
||||||
|
@Environment(\.widgetFamily) private var family
|
||||||
|
|
||||||
|
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||||
|
private var lang: String { snapshot?.language ?? "system" }
|
||||||
|
|
||||||
|
private var timeFmt: DateFormatter {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = WidgetL10n.locale(lang)
|
||||||
|
f.dateFormat = "HH:mm"
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
private var nextEvent: WidgetEvent? {
|
||||||
|
guard let s = snapshot else { return nil }
|
||||||
|
return WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s).first
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isRunning: Bool {
|
||||||
|
guard let ev = nextEvent, !ev.isAllDay else { return false }
|
||||||
|
return ev.start <= entry.date && ev.end > entry.date
|
||||||
|
}
|
||||||
|
|
||||||
|
private var countdownText: String {
|
||||||
|
guard let ev = nextEvent else { return WidgetL10n.t("widget.no_events", lang) }
|
||||||
|
if isRunning { return WidgetL10n.t("widget.running", lang) }
|
||||||
|
if ev.isAllDay { return WidgetL10n.t("widget.allday", lang) }
|
||||||
|
let total = Int(max(0, ev.start.timeIntervalSince(entry.date)) / 60)
|
||||||
|
if total < 60 { return "in \(total)m" }
|
||||||
|
let h = total / 60; let m = total % 60
|
||||||
|
return m == 0 ? "in \(h)h" : "in \(h)h \(m)m"
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
var body: some View {
|
||||||
|
switch family {
|
||||||
|
case .accessoryCircular:
|
||||||
|
circularView
|
||||||
|
case .accessoryRectangular:
|
||||||
|
rectangularView
|
||||||
|
default:
|
||||||
|
inlineView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var circularView: some View {
|
||||||
|
ZStack {
|
||||||
|
AccessoryWidgetBackground()
|
||||||
|
VStack(spacing: 1) {
|
||||||
|
Text(countdownText)
|
||||||
|
.font(.system(size: 13, weight: .bold))
|
||||||
|
.minimumScaleFactor(0.5)
|
||||||
|
.lineLimit(1)
|
||||||
|
.widgetAccentable()
|
||||||
|
if let ev = nextEvent, !ev.isAllDay {
|
||||||
|
Text(timeFmt.string(from: ev.start))
|
||||||
|
.font(.system(size: 8))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var rectangularView: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
if let ev = nextEvent {
|
||||||
|
Text(countdownText)
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.widgetAccentable()
|
||||||
|
Text(ev.title)
|
||||||
|
.font(.system(size: 14, weight: .bold))
|
||||||
|
.lineLimit(1)
|
||||||
|
let timeStr = ev.isAllDay
|
||||||
|
? WidgetL10n.t("widget.allday", lang)
|
||||||
|
: "\(timeFmt.string(from: ev.start)) – \(timeFmt.string(from: ev.end))"
|
||||||
|
Text(timeStr)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.lineLimit(1)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "timer")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.widgetAccentable()
|
||||||
|
Text(WidgetL10n.t("widget.no_events", lang))
|
||||||
|
.font(.system(size: 13))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var inlineView: some View {
|
||||||
|
let text: String = {
|
||||||
|
guard let ev = nextEvent else { return WidgetL10n.t("widget.no_events", lang) }
|
||||||
|
return "\(ev.title) \(countdownText)"
|
||||||
|
}()
|
||||||
|
return Label(text, systemImage: "timer")
|
||||||
|
}
|
||||||
|
}
|
||||||
162
CalendarrWidgets/NowNextWidgetView.swift
Normal file
162
CalendarrWidgets/NowNextWidgetView.swift
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
struct NowNextWidgetView: View {
|
||||||
|
let entry: CalendarrEntry
|
||||||
|
|
||||||
|
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||||
|
private var lang: String { snapshot?.language ?? "system" }
|
||||||
|
|
||||||
|
private var cal: Calendar {
|
||||||
|
var c = Calendar(identifier: .gregorian)
|
||||||
|
c.locale = WidgetL10n.locale(lang)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
private var timeFmt: DateFormatter {
|
||||||
|
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "HH:mm"; return f
|
||||||
|
}
|
||||||
|
private var dayOfWeekFmt: DateFormatter {
|
||||||
|
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "EEEE"; return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// Currently running event, or next upcoming timed event, or first all-day event
|
||||||
|
private var featuredEvent: WidgetEvent? {
|
||||||
|
guard let s = snapshot else { return nil }
|
||||||
|
let pool = WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s)
|
||||||
|
if let running = pool.first(where: { !$0.isAllDay && $0.start <= entry.date }) { return running }
|
||||||
|
if let next = pool.first(where: { !$0.isAllDay }) { return next }
|
||||||
|
return pool.first
|
||||||
|
}
|
||||||
|
|
||||||
|
// All upcoming events today except the featured one
|
||||||
|
private var remainingEvents: [WidgetEvent] {
|
||||||
|
guard let s = snapshot else { return [] }
|
||||||
|
let pool = WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s)
|
||||||
|
guard let featured = featuredEvent else { return pool }
|
||||||
|
return pool.filter { $0.id != featured.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func timeRange(_ ev: WidgetEvent) -> String {
|
||||||
|
ev.isAllDay
|
||||||
|
? WidgetL10n.t("widget.allday", lang)
|
||||||
|
: "\(timeFmt.string(from: ev.start)) – \(timeFmt.string(from: ev.end))"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let s = snapshot {
|
||||||
|
let line = Color(widgetHex: s.lineColorHex)
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
featuredCard(snapshot: s)
|
||||||
|
bottomRow(line: line)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(WidgetL10n.t("widget.no_data", lang))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Featured event card
|
||||||
|
|
||||||
|
private func featuredCard(snapshot: WidgetSnapshot) -> some View {
|
||||||
|
let ev = featuredEvent
|
||||||
|
let baseColor = ev.map { Color(widgetHex: $0.colorHex) } ?? Color(widgetHex: snapshot.primaryColorHex)
|
||||||
|
|
||||||
|
return ZStack(alignment: .leading) {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [baseColor.opacity(0.75), baseColor],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(ev?.title ?? WidgetL10n.t("widget.no_events", lang))
|
||||||
|
.font(.system(size: 13, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(ev.map { timeRange($0) } ?? "")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundStyle(.white.opacity(0.85))
|
||||||
|
}
|
||||||
|
.padding(.leading, 10)
|
||||||
|
.padding(.vertical, 9)
|
||||||
|
Spacer()
|
||||||
|
if ev != nil {
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
.padding(.trailing, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Bottom: date + event list
|
||||||
|
|
||||||
|
private func bottomRow(line: Color) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 0) {
|
||||||
|
// Left: day name + large number
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(dayOfWeekFmt.string(from: entry.date).uppercased())
|
||||||
|
.font(.system(size: 8, weight: .bold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.6)
|
||||||
|
Text("\(cal.component(.day, from: entry.date))")
|
||||||
|
.font(.system(size: 30, weight: .light))
|
||||||
|
}
|
||||||
|
.frame(width: 50, alignment: .leading)
|
||||||
|
|
||||||
|
// Divider
|
||||||
|
line.opacity(0.4).frame(width: 0.5)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
|
||||||
|
// Right: event list
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
let shown = remainingEvents.prefix(2)
|
||||||
|
if shown.isEmpty {
|
||||||
|
Text(WidgetL10n.t("widget.no_events", lang))
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(shown) { ev in
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 5) {
|
||||||
|
Circle()
|
||||||
|
.fill(Color(widgetHex: ev.colorHex))
|
||||||
|
.frame(width: 7, height: 7)
|
||||||
|
.padding(.top, 1)
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(ev.title)
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(timeRange(ev))
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
|
// +N badge
|
||||||
|
if remainingEvents.count > 2 {
|
||||||
|
Text("+\(remainingEvents.count - 2)")
|
||||||
|
.font(.system(size: 9, weight: .semibold))
|
||||||
|
.padding(.horizontal, 5)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(.secondary.opacity(0.18), in: Capsule())
|
||||||
|
.frame(maxHeight: .infinity, alignment: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
116
CalendarrWidgets/ThisWeekWidgetView.swift
Normal file
116
CalendarrWidgets/ThisWeekWidgetView.swift
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
struct ThisWeekWidgetView: View {
|
||||||
|
let entry: CalendarrEntry
|
||||||
|
|
||||||
|
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||||
|
private var lang: String { snapshot?.language ?? "system" }
|
||||||
|
|
||||||
|
private var cal: Calendar {
|
||||||
|
var c = Calendar(identifier: .gregorian)
|
||||||
|
c.locale = WidgetL10n.locale(lang)
|
||||||
|
c.firstWeekday = 2
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
private var weekStart: Date {
|
||||||
|
cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: entry.date)) ?? entry.date
|
||||||
|
}
|
||||||
|
|
||||||
|
private var weekDays: [Date] {
|
||||||
|
(0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: weekStart) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var monthHeader: String {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = WidgetL10n.locale(lang)
|
||||||
|
f.dateFormat = "LLLL yyyy"
|
||||||
|
return f.string(from: weekStart).uppercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var weekdayHeaders: [String] {
|
||||||
|
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang)
|
||||||
|
let symbols = f.shortWeekdaySymbols ?? cal.shortWeekdaySymbols
|
||||||
|
let start = cal.firstWeekday - 1
|
||||||
|
return (0..<7).map { String(symbols[(start + $0) % 7].prefix(2)).uppercased() }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let s = snapshot {
|
||||||
|
let primary = Color(widgetHex: s.primaryColorHex)
|
||||||
|
let accent = Color(widgetHex: s.accentColorHex)
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||||
|
Text(monthHeader.split(separator: " ").first.map(String.init) ?? "")
|
||||||
|
.font(.system(size: 10, weight: .bold))
|
||||||
|
.foregroundStyle(primary)
|
||||||
|
Text(monthHeader.split(separator: " ").dropFirst().joined(separator: " "))
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
}
|
||||||
|
// Equal-width columns via maxWidth — no GeometryReader needed
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(Array(weekDays.enumerated()), id: \.offset) { idx, day in
|
||||||
|
dayColumn(day, snapshot: s, primary: primary, accent: accent)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
|
.overlay(alignment: .trailing) {
|
||||||
|
if idx < 6 {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color(widgetHex: s.lineColorHex).opacity(0.35))
|
||||||
|
.frame(width: 0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(WidgetL10n.t("widget.no_data", lang))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dayColumn(_ day: Date,
|
||||||
|
snapshot: WidgetSnapshot,
|
||||||
|
primary: Color,
|
||||||
|
accent: Color) -> some View {
|
||||||
|
let isToday = cal.isDateInToday(day)
|
||||||
|
let evs = WidgetHelpers.events(for: day, in: snapshot)
|
||||||
|
let dayIdx = (0..<7).firstIndex { i in cal.isDate(weekDays[i], inSameDayAs: day) } ?? 0
|
||||||
|
return VStack(alignment: .center, spacing: 1) {
|
||||||
|
Text(weekdayHeaders[dayIdx])
|
||||||
|
.font(.system(size: 7.5, weight: .bold))
|
||||||
|
.foregroundStyle(isToday ? accent : .secondary)
|
||||||
|
Text("\(cal.component(.day, from: day))")
|
||||||
|
.font(.system(size: 10, weight: isToday ? .bold : .semibold))
|
||||||
|
.foregroundStyle(isToday ? Color.white : Color.primary)
|
||||||
|
.frame(width: 16, height: 16)
|
||||||
|
.background(isToday ? primary : Color.clear)
|
||||||
|
.clipShape(Circle())
|
||||||
|
ForEach(evs.prefix(3)) { ev in
|
||||||
|
eventPill(ev)
|
||||||
|
}
|
||||||
|
if evs.count > 3 {
|
||||||
|
Text("+\(evs.count - 3)")
|
||||||
|
.font(.system(size: 6.5))
|
||||||
|
.foregroundStyle(accent)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func eventPill(_ ev: WidgetEvent) -> some View {
|
||||||
|
Text(ev.title)
|
||||||
|
.font(.system(size: 7, weight: .medium))
|
||||||
|
.lineLimit(1)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 2)
|
||||||
|
.padding(.vertical, 0.5)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color(widgetHex: ev.colorHex))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 1.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
131
CalendarrWidgets/ThreeDaysWidgetView.swift
Normal file
131
CalendarrWidgets/ThreeDaysWidgetView.swift
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
struct ThreeDaysWidgetView: View {
|
||||||
|
let entry: CalendarrEntry
|
||||||
|
|
||||||
|
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||||
|
private var lang: String { snapshot?.language ?? "system" }
|
||||||
|
|
||||||
|
private var cal: Calendar {
|
||||||
|
var c = Calendar(identifier: .gregorian)
|
||||||
|
c.locale = WidgetL10n.locale(lang)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
private var days: [Date] {
|
||||||
|
let today = cal.startOfDay(for: entry.date)
|
||||||
|
return (0..<3).compactMap { cal.date(byAdding: .day, value: $0, to: today) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var monthHeader: String {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = WidgetL10n.locale(lang)
|
||||||
|
f.dateFormat = "LLLL yyyy"
|
||||||
|
return f.string(from: entry.date).uppercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var weekdayFmt: DateFormatter {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = WidgetL10n.locale(lang)
|
||||||
|
f.dateFormat = "EEE"
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
private var timeFmt: DateFormatter {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = WidgetL10n.locale(lang)
|
||||||
|
f.dateFormat = "HH:mm"
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let s = snapshot {
|
||||||
|
let primary = Color(widgetHex: s.primaryColorHex)
|
||||||
|
let accent = Color(widgetHex: s.accentColorHex)
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||||
|
Text(monthHeader.split(separator: " ").first.map(String.init) ?? "")
|
||||||
|
.font(.system(size: 11, weight: .bold))
|
||||||
|
.foregroundStyle(primary)
|
||||||
|
Text(monthHeader.split(separator: " ").dropFirst().joined(separator: " "))
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
}
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(Array(days.enumerated()), id: \.offset) { idx, day in
|
||||||
|
column(for: day, snapshot: s, primary: primary, accent: accent)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
|
.overlay(alignment: .trailing) {
|
||||||
|
if idx < 2 {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color(widgetHex: s.lineColorHex).opacity(0.4))
|
||||||
|
.frame(width: 0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(WidgetL10n.t("widget.no_data", lang))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func column(for day: Date, snapshot: WidgetSnapshot, primary: Color, accent: Color) -> some View {
|
||||||
|
let isToday = cal.isDateInToday(day)
|
||||||
|
let evs = WidgetHelpers.events(for: day, in: snapshot)
|
||||||
|
return VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack {
|
||||||
|
Text(weekdayFmt.string(from: day).uppercased() + ".")
|
||||||
|
.font(.system(size: 9, weight: .bold))
|
||||||
|
.foregroundStyle(isToday ? accent : .secondary)
|
||||||
|
Spacer()
|
||||||
|
Text("\(cal.component(.day, from: day))")
|
||||||
|
.font(.system(size: 11, weight: isToday ? .bold : .semibold))
|
||||||
|
.foregroundStyle(isToday ? Color.white : Color.primary)
|
||||||
|
.frame(width: 17, height: 17)
|
||||||
|
.background(isToday ? primary : Color.clear)
|
||||||
|
.clipShape(Circle())
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 3)
|
||||||
|
if evs.isEmpty {
|
||||||
|
Text(WidgetL10n.t("widget.no_events", lang))
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.padding(.horizontal, 3)
|
||||||
|
} else {
|
||||||
|
ForEach(evs.prefix(4)) { ev in
|
||||||
|
eventRow(ev)
|
||||||
|
}
|
||||||
|
if evs.count > 4 {
|
||||||
|
Text("+\(evs.count - 4)")
|
||||||
|
.font(.system(size: 8))
|
||||||
|
.foregroundStyle(accent)
|
||||||
|
.padding(.leading, 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func eventRow(_ ev: WidgetEvent) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
RoundedRectangle(cornerRadius: 1)
|
||||||
|
.fill(Color(widgetHex: ev.colorHex))
|
||||||
|
.frame(width: 2)
|
||||||
|
Text(ev.title)
|
||||||
|
.font(.system(size: 9, weight: .medium))
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start))
|
||||||
|
.font(.system(size: 8))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.leading, 5)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
84
CalendarrWidgets/TodayWidgetView.swift
Normal file
84
CalendarrWidgets/TodayWidgetView.swift
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
struct TodayWidgetView: View {
|
||||||
|
let entry: CalendarrEntry
|
||||||
|
|
||||||
|
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||||
|
|
||||||
|
private var todayEvents: [WidgetEvent] {
|
||||||
|
guard let s = snapshot else { return [] }
|
||||||
|
return WidgetHelpers.events(for: entry.date, in: s)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lang: String { snapshot?.language ?? "system" }
|
||||||
|
|
||||||
|
private var timeFmt: DateFormatter {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = WidgetL10n.locale(lang)
|
||||||
|
f.dateFormat = "HH:mm"
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let primary = Color(widgetHex: snapshot?.primaryColorHex ?? "#4285f4")
|
||||||
|
let accent = Color(widgetHex: snapshot?.accentColorHex ?? "#ea4335")
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Text(WidgetL10n.t("widget.today", lang))
|
||||||
|
.font(.caption.weight(.bold))
|
||||||
|
.foregroundStyle(primary)
|
||||||
|
Spacer()
|
||||||
|
Text(headerDate)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if snapshot == nil {
|
||||||
|
Text(WidgetL10n.t("widget.no_data", lang))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else if todayEvents.isEmpty {
|
||||||
|
Spacer()
|
||||||
|
Text(WidgetL10n.t("widget.no_events", lang))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
} else {
|
||||||
|
ForEach(todayEvents.prefix(3)) { ev in
|
||||||
|
eventRow(ev)
|
||||||
|
}
|
||||||
|
if todayEvents.count > 3 {
|
||||||
|
Text(String(format: WidgetL10n.t("widget.more", lang), todayEvents.count - 3))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(accent)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var headerDate: String {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = WidgetL10n.locale(lang)
|
||||||
|
f.dateFormat = "d. MMM"
|
||||||
|
return f.string(from: entry.date)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func eventRow(_ ev: WidgetEvent) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(Color(widgetHex: ev.colorHex))
|
||||||
|
.frame(width: 3)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(ev.title)
|
||||||
|
.font(.caption.weight(.medium))
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
109
CalendarrWidgets/TwoDaysWidgetView.swift
Normal file
109
CalendarrWidgets/TwoDaysWidgetView.swift
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
struct TwoDaysWidgetView: View {
|
||||||
|
let entry: CalendarrEntry
|
||||||
|
|
||||||
|
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||||
|
private var lang: String { snapshot?.language ?? "system" }
|
||||||
|
|
||||||
|
private var today: Date { Calendar.current.startOfDay(for: entry.date) }
|
||||||
|
private var tomorrow: Date {
|
||||||
|
Calendar.current.date(byAdding: .day, value: 1, to: today) ?? today
|
||||||
|
}
|
||||||
|
|
||||||
|
private var timeFmt: DateFormatter {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = WidgetL10n.locale(lang)
|
||||||
|
f.dateFormat = "HH:mm"
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let s = snapshot {
|
||||||
|
let primary = Color(widgetHex: s.primaryColorHex)
|
||||||
|
let accent = Color(widgetHex: s.accentColorHex)
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
column(for: today,
|
||||||
|
title: WidgetL10n.t("widget.today", lang),
|
||||||
|
isToday: true,
|
||||||
|
events: WidgetHelpers.events(for: today, in: s),
|
||||||
|
primary: primary, accent: accent,
|
||||||
|
lineColor: Color(widgetHex: s.lineColorHex))
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color(widgetHex: s.lineColorHex).opacity(0.4))
|
||||||
|
.frame(width: 0.5)
|
||||||
|
column(for: tomorrow,
|
||||||
|
title: WidgetL10n.t("widget.tomorrow", lang),
|
||||||
|
isToday: false,
|
||||||
|
events: WidgetHelpers.events(for: tomorrow, in: s),
|
||||||
|
primary: primary, accent: accent,
|
||||||
|
lineColor: Color(widgetHex: s.lineColorHex))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(WidgetL10n.t("widget.no_data", lang))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func column(for day: Date,
|
||||||
|
title: String,
|
||||||
|
isToday: Bool,
|
||||||
|
events: [WidgetEvent],
|
||||||
|
primary: Color,
|
||||||
|
accent: Color,
|
||||||
|
lineColor: Color) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||||
|
Text(title)
|
||||||
|
.font(.caption.weight(.bold))
|
||||||
|
.foregroundStyle(isToday ? primary : accent)
|
||||||
|
Text(shortDate(day))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
if events.isEmpty {
|
||||||
|
Text(WidgetL10n.t("widget.no_events", lang))
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(events.prefix(4)) { ev in
|
||||||
|
eventRow(ev)
|
||||||
|
}
|
||||||
|
if events.count > 4 {
|
||||||
|
Text(String(format: WidgetL10n.t("widget.more", lang), events.count - 4))
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func eventRow(_ ev: WidgetEvent) -> some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
RoundedRectangle(cornerRadius: 1.5)
|
||||||
|
.fill(Color(widgetHex: ev.colorHex))
|
||||||
|
.frame(width: 2)
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(ev.title)
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start))
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shortDate(_ d: Date) -> String {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = WidgetL10n.locale(lang)
|
||||||
|
f.dateFormat = "d. MMM"
|
||||||
|
return f.string(from: d)
|
||||||
|
}
|
||||||
|
}
|
||||||
170
CalendarrWidgets/TwoMonthWidgetView.swift
Normal file
170
CalendarrWidgets/TwoMonthWidgetView.swift
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
struct TwoMonthWidgetView: View {
|
||||||
|
let entry: CalendarrEntry
|
||||||
|
@Environment(\.widgetFamily) private var family
|
||||||
|
|
||||||
|
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||||
|
private var lang: String { snapshot?.language ?? "system" }
|
||||||
|
|
||||||
|
private var cal: Calendar {
|
||||||
|
var c = Calendar(identifier: .gregorian)
|
||||||
|
c.locale = WidgetL10n.locale(lang)
|
||||||
|
c.firstWeekday = 2
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
private var thisMonth: Date {
|
||||||
|
cal.date(from: cal.dateComponents([.year, .month], from: entry.date)) ?? entry.date
|
||||||
|
}
|
||||||
|
|
||||||
|
private var nextMonth: Date {
|
||||||
|
cal.date(byAdding: .month, value: 1, to: thisMonth) ?? thisMonth
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekday header labels (M T W T F S S)
|
||||||
|
private var weekdayHeaders: [String] {
|
||||||
|
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang)
|
||||||
|
let symbols = f.veryShortWeekdaySymbols ?? cal.veryShortWeekdaySymbols
|
||||||
|
let start = cal.firstWeekday - 1
|
||||||
|
return (0..<7).map { String(symbols[(start + $0) % 7]).uppercased() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number of date rows to show (5 for medium, 6 for large)
|
||||||
|
private var rowCount: Int { family == .systemLarge ? 6 : 5 }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let s = snapshot {
|
||||||
|
let primary = Color(widgetHex: s.primaryColorHex)
|
||||||
|
let accent = Color(widgetHex: s.accentColorHex)
|
||||||
|
let line = Color(widgetHex: s.lineColorHex)
|
||||||
|
HStack(alignment: .top, spacing: 0) {
|
||||||
|
monthColumn(monthDate: thisMonth, snapshot: s,
|
||||||
|
primary: primary, accent: accent, line: line)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
line.opacity(0.35).frame(width: 0.5)
|
||||||
|
.padding(.horizontal, 3)
|
||||||
|
monthColumn(monthDate: nextMonth, snapshot: s,
|
||||||
|
primary: primary, accent: accent, line: line)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(WidgetL10n.t("widget.no_data", lang))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – One month column
|
||||||
|
|
||||||
|
private func monthColumn(monthDate: Date,
|
||||||
|
snapshot: WidgetSnapshot,
|
||||||
|
primary: Color,
|
||||||
|
accent: Color,
|
||||||
|
line: Color) -> some View {
|
||||||
|
let monthFmt = DateFormatter()
|
||||||
|
monthFmt.locale = WidgetL10n.locale(lang)
|
||||||
|
monthFmt.dateFormat = "LLLL"
|
||||||
|
let name = monthFmt.string(from: monthDate).uppercased()
|
||||||
|
let start = gridStart(for: monthDate)
|
||||||
|
let wn = WidgetL10n.t("widget.cw", lang)
|
||||||
|
|
||||||
|
return VStack(alignment: .leading, spacing: 1) {
|
||||||
|
// Month name
|
||||||
|
Text(name)
|
||||||
|
.font(.system(size: 9, weight: .bold))
|
||||||
|
.foregroundStyle(primary)
|
||||||
|
|
||||||
|
// Column headers: KW + 7 weekdays
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Text(wn)
|
||||||
|
.font(.system(size: 6, weight: .bold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 14, alignment: .center)
|
||||||
|
ForEach(weekdayHeaders, id: \.self) { h in
|
||||||
|
Text(h)
|
||||||
|
.font(.system(size: 6.5, weight: .bold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date rows
|
||||||
|
ForEach(0..<rowCount, id: \.self) { row in
|
||||||
|
let rowStart = cal.date(byAdding: .day, value: row * 7, to: start)!
|
||||||
|
let weekNum = cal.component(.weekOfYear, from: rowStart)
|
||||||
|
let inMonth = cal.isDate(rowStart, equalTo: monthDate, toGranularity: .month)
|
||||||
|
|| cal.isDate(cal.date(byAdding: .day, value: 6, to: rowStart)!,
|
||||||
|
equalTo: monthDate, toGranularity: .month)
|
||||||
|
if inMonth {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Text("\(weekNum)")
|
||||||
|
.font(.system(size: 6))
|
||||||
|
.foregroundStyle(.secondary.opacity(0.6))
|
||||||
|
.frame(width: 14, alignment: .center)
|
||||||
|
ForEach(0..<7, id: \.self) { col in
|
||||||
|
let day = cal.date(byAdding: .day, value: col, to: rowStart)!
|
||||||
|
dayCell(day, monthDate: monthDate, snapshot: snapshot,
|
||||||
|
primary: primary, accent: accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Day cell
|
||||||
|
|
||||||
|
private func dayCell(_ day: Date,
|
||||||
|
monthDate: Date,
|
||||||
|
snapshot: WidgetSnapshot,
|
||||||
|
primary: Color,
|
||||||
|
accent: Color) -> some View {
|
||||||
|
let isToday = cal.isDateInToday(day)
|
||||||
|
let inMonth = cal.isDate(day, equalTo: monthDate, toGranularity: .month)
|
||||||
|
let evs = inMonth ? WidgetHelpers.events(for: day, in: snapshot) : []
|
||||||
|
let isWeekend = { () -> Bool in
|
||||||
|
let wd = cal.component(.weekday, from: day)
|
||||||
|
return wd == 1 || wd == 7
|
||||||
|
}()
|
||||||
|
|
||||||
|
return VStack(spacing: 1) {
|
||||||
|
ZStack {
|
||||||
|
if isToday { Circle().fill(primary) }
|
||||||
|
Text("\(cal.component(.day, from: day))")
|
||||||
|
.font(.system(size: 7.5, weight: isToday ? .bold : .medium))
|
||||||
|
.foregroundStyle(
|
||||||
|
isToday ? .white :
|
||||||
|
!inMonth ? Color.secondary.opacity(0.3) :
|
||||||
|
isWeekend ? Color.primary.opacity(0.5) :
|
||||||
|
Color.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 11)
|
||||||
|
|
||||||
|
// Event dots
|
||||||
|
HStack(spacing: 1) {
|
||||||
|
ForEach(evs.prefix(3).indices, id: \.self) { i in
|
||||||
|
Circle()
|
||||||
|
.fill(Color(widgetHex: evs[i].colorHex))
|
||||||
|
.frame(width: 2.5, height: 2.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 3)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Grid helpers
|
||||||
|
|
||||||
|
private func gridStart(for monthDate: Date) -> Date {
|
||||||
|
let first = cal.date(from: cal.dateComponents([.year, .month], from: monthDate)) ?? monthDate
|
||||||
|
let weekday = cal.component(.weekday, from: first)
|
||||||
|
let offset = ((weekday - cal.firstWeekday) + 7) % 7
|
||||||
|
return cal.date(byAdding: .day, value: -offset, to: first) ?? first
|
||||||
|
}
|
||||||
|
}
|
||||||
132
CalendarrWidgets/TwoWeeksWidgetView.swift
Normal file
132
CalendarrWidgets/TwoWeeksWidgetView.swift
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
struct TwoWeeksWidgetView: View {
|
||||||
|
let entry: CalendarrEntry
|
||||||
|
|
||||||
|
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||||
|
private var lang: String { snapshot?.language ?? "system" }
|
||||||
|
|
||||||
|
private var cal: Calendar {
|
||||||
|
var c = Calendar(identifier: .gregorian)
|
||||||
|
c.locale = WidgetL10n.locale(lang)
|
||||||
|
c.firstWeekday = 2
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
private var weekStart: Date {
|
||||||
|
cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: entry.date)) ?? entry.date
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fortnight: [Date] {
|
||||||
|
(0..<14).compactMap { cal.date(byAdding: .day, value: $0, to: weekStart) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var monthHeader: String {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = WidgetL10n.locale(lang)
|
||||||
|
f.dateFormat = "LLLL yyyy"
|
||||||
|
return f.string(from: weekStart).uppercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var weekdayHeaders: [String] {
|
||||||
|
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang)
|
||||||
|
let symbols = f.veryShortWeekdaySymbols ?? cal.veryShortWeekdaySymbols
|
||||||
|
let start = cal.firstWeekday - 1
|
||||||
|
return (0..<7).map { symbols[(start + $0) % 7] }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let s = snapshot {
|
||||||
|
let primary = Color(widgetHex: s.primaryColorHex)
|
||||||
|
let accent = Color(widgetHex: s.accentColorHex)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||||
|
Text(monthHeader.split(separator: " ").first.map(String.init) ?? "")
|
||||||
|
.font(.system(size: 9, weight: .bold))
|
||||||
|
.foregroundStyle(primary)
|
||||||
|
Text(monthHeader.split(separator: " ").dropFirst().joined(separator: " "))
|
||||||
|
.font(.system(size: 9, weight: .semibold))
|
||||||
|
}
|
||||||
|
weekdayRow(accent: accent)
|
||||||
|
GeometryReader { geo in
|
||||||
|
let colW = geo.size.width / 7
|
||||||
|
let rowH = geo.size.height / 2
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(0..<2, id: \.self) { row in
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(0..<7, id: \.self) { col in
|
||||||
|
let day = fortnight[row * 7 + col]
|
||||||
|
dayCell(day, snapshot: s, primary: primary, accent: accent)
|
||||||
|
.frame(width: colW, height: rowH)
|
||||||
|
.overlay(alignment: .trailing) {
|
||||||
|
if col < 6 {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color(widgetHex: s.lineColorHex).opacity(0.35))
|
||||||
|
.frame(width: 0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
if row == 1 {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color(widgetHex: s.lineColorHex).opacity(0.35))
|
||||||
|
.frame(height: 0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(WidgetL10n.t("widget.no_data", lang))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func weekdayRow(accent: Color) -> some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(weekdayHeaders, id: \.self) { h in
|
||||||
|
Text(h)
|
||||||
|
.font(.system(size: 7, weight: .bold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dayCell(_ day: Date,
|
||||||
|
snapshot: WidgetSnapshot,
|
||||||
|
primary: Color,
|
||||||
|
accent: Color) -> some View {
|
||||||
|
let isToday = cal.isDateInToday(day)
|
||||||
|
let evs = WidgetHelpers.events(for: day, in: snapshot)
|
||||||
|
return VStack(alignment: .center, spacing: 0.5) {
|
||||||
|
Text("\(cal.component(.day, from: day))")
|
||||||
|
.font(.system(size: 8.5, weight: isToday ? .bold : .semibold))
|
||||||
|
.foregroundStyle(isToday ? Color.white : Color.primary)
|
||||||
|
.frame(width: 12, height: 12)
|
||||||
|
.background(isToday ? primary : Color.clear)
|
||||||
|
.clipShape(Circle())
|
||||||
|
// Up to 3 colored dots
|
||||||
|
HStack(spacing: 1) {
|
||||||
|
ForEach(evs.prefix(3).indices, id: \.self) { i in
|
||||||
|
Circle()
|
||||||
|
.fill(Color(widgetHex: evs[i].colorHex))
|
||||||
|
.frame(width: 3, height: 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 3)
|
||||||
|
if evs.count > 3 {
|
||||||
|
Text("+\(evs.count - 3)")
|
||||||
|
.font(.system(size: 6))
|
||||||
|
.foregroundStyle(accent)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.top, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
173
CalendarrWidgets/UpNextWidgetView.swift
Normal file
173
CalendarrWidgets/UpNextWidgetView.swift
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
struct UpNextWidgetView: View {
|
||||||
|
let entry: CalendarrEntry
|
||||||
|
|
||||||
|
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||||
|
private var lang: String { snapshot?.language ?? "system" }
|
||||||
|
|
||||||
|
private var cal: Calendar {
|
||||||
|
var c = Calendar(identifier: .gregorian)
|
||||||
|
c.locale = WidgetL10n.locale(lang)
|
||||||
|
c.firstWeekday = 2
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
private var todayEvents: [WidgetEvent] {
|
||||||
|
guard let s = snapshot else { return [] }
|
||||||
|
return WidgetHelpers.events(for: entry.date, in: s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mini-month grid: 6 rows × 7 cols starting from the first weekday of the
|
||||||
|
/// month, padded with neighbouring days where necessary.
|
||||||
|
private var monthGrid: [Date] {
|
||||||
|
let firstOfMonth = cal.date(from: cal.dateComponents([.year, .month], from: entry.date)) ?? entry.date
|
||||||
|
let weekday = cal.component(.weekday, from: firstOfMonth)
|
||||||
|
let offset = ((weekday - cal.firstWeekday) + 7) % 7
|
||||||
|
let gridStart = cal.date(byAdding: .day, value: -offset, to: firstOfMonth) ?? firstOfMonth
|
||||||
|
return (0..<42).compactMap { cal.date(byAdding: .day, value: $0, to: gridStart) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var weekdayHeaders: [String] {
|
||||||
|
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang)
|
||||||
|
let symbols = f.veryShortWeekdaySymbols ?? cal.veryShortWeekdaySymbols
|
||||||
|
let start = cal.firstWeekday - 1
|
||||||
|
return (0..<7).map { symbols[(start + $0) % 7] }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var weekdayFmt: DateFormatter {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = WidgetL10n.locale(lang)
|
||||||
|
f.dateFormat = "EEE"
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
private var monthNameFmt: DateFormatter {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = WidgetL10n.locale(lang)
|
||||||
|
f.dateFormat = "LLL"
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
private var timeFmt: DateFormatter {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = WidgetL10n.locale(lang)
|
||||||
|
f.dateFormat = "HH:mm"
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let s = snapshot {
|
||||||
|
let primary = Color(widgetHex: s.primaryColorHex)
|
||||||
|
let accent = Color(widgetHex: s.accentColorHex)
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
leftPanel(snapshot: s, primary: primary, accent: accent)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
|
miniMonth(snapshot: s, primary: primary, accent: accent)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(WidgetL10n.t("widget.no_data", lang))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func leftPanel(snapshot: WidgetSnapshot, primary: Color, accent: Color) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||||
|
Text("\(cal.component(.day, from: entry.date))")
|
||||||
|
.font(.system(size: 17, weight: .bold))
|
||||||
|
.foregroundStyle(Color.white)
|
||||||
|
.frame(width: 26, height: 26)
|
||||||
|
.background(primary)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(weekdayFmt.string(from: entry.date).uppercased() + ".")
|
||||||
|
.font(.system(size: 11, weight: .bold))
|
||||||
|
.foregroundStyle(accent)
|
||||||
|
Text(monthNameFmt.string(from: entry.date))
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if todayEvents.isEmpty {
|
||||||
|
Text(WidgetL10n.t("widget.no_events", lang))
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(todayEvents.prefix(3)) { ev in
|
||||||
|
HStack(alignment: .top, spacing: 4) {
|
||||||
|
Circle()
|
||||||
|
.fill(Color(widgetHex: ev.colorHex))
|
||||||
|
.frame(width: 5, height: 5)
|
||||||
|
.padding(.top, 4)
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(ev.title)
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start))
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func miniMonth(snapshot: WidgetSnapshot, primary: Color, accent: Color) -> some View {
|
||||||
|
VStack(spacing: 1) {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(weekdayHeaders, id: \.self) { h in
|
||||||
|
Text(h)
|
||||||
|
.font(.system(size: 8, weight: .bold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GeometryReader { geo in
|
||||||
|
let cellW = geo.size.width / 7
|
||||||
|
let cellH = geo.size.height / 6
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(0..<6, id: \.self) { row in
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(0..<7, id: \.self) { col in
|
||||||
|
miniDay(monthGrid[row * 7 + col],
|
||||||
|
snapshot: snapshot,
|
||||||
|
primary: primary,
|
||||||
|
accent: accent)
|
||||||
|
.frame(width: cellW, height: cellH)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func miniDay(_ day: Date, snapshot: WidgetSnapshot, primary: Color, accent: Color) -> some View {
|
||||||
|
let isToday = cal.isDateInToday(day)
|
||||||
|
let inMonth = cal.isDate(day, equalTo: entry.date, toGranularity: .month)
|
||||||
|
let hasEvents = !WidgetHelpers.events(for: day, in: snapshot).isEmpty
|
||||||
|
return ZStack {
|
||||||
|
if isToday {
|
||||||
|
RoundedRectangle(cornerRadius: 3)
|
||||||
|
.fill(primary)
|
||||||
|
} else if hasEvents && inMonth {
|
||||||
|
RoundedRectangle(cornerRadius: 3)
|
||||||
|
.fill(accent.opacity(0.20))
|
||||||
|
}
|
||||||
|
Text("\(cal.component(.day, from: day))")
|
||||||
|
.font(.system(size: 9, weight: isToday ? .bold : .medium))
|
||||||
|
.foregroundStyle(
|
||||||
|
isToday ? Color.white :
|
||||||
|
inMonth ? Color.primary : Color.secondary.opacity(0.4)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
130
CalendarrWidgets/UpcomingWidgetView.swift
Normal file
130
CalendarrWidgets/UpcomingWidgetView.swift
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
private let rowHeight: CGFloat = 16
|
||||||
|
private let dayHeaderHeight: CGFloat = 14
|
||||||
|
private let maxEventsPerDay: Int = 3
|
||||||
|
private let maxTotalRows: Int = 15
|
||||||
|
|
||||||
|
struct UpcomingWidgetView: View {
|
||||||
|
let entry: CalendarrEntry
|
||||||
|
|
||||||
|
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||||
|
private var lang: String { snapshot?.language ?? "system" }
|
||||||
|
|
||||||
|
private var groupedWithLimits: [(Date, [WidgetEvent], Int)] {
|
||||||
|
guard let s = snapshot else { return [] }
|
||||||
|
let cal = Calendar.current
|
||||||
|
let now = entry.date
|
||||||
|
let events = WidgetHelpers.upcoming(from: now, daysAhead: 5, in: s)
|
||||||
|
var buckets: [Date: [WidgetEvent]] = [:]
|
||||||
|
for ev in events {
|
||||||
|
let key = cal.startOfDay(for: ev.start)
|
||||||
|
buckets[key, default: []].append(ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result: [(Date, [WidgetEvent], Int)] = []
|
||||||
|
var totalRows = 0
|
||||||
|
|
||||||
|
for date in buckets.keys.sorted() {
|
||||||
|
let allEventsForDay = buckets[date] ?? []
|
||||||
|
let eventsToShow = Array(allEventsForDay.prefix(maxEventsPerDay))
|
||||||
|
let hiddenCount = allEventsForDay.count - eventsToShow.count
|
||||||
|
|
||||||
|
// Account for day header + event rows + potential "more" row
|
||||||
|
let rowsForThisDay = 1 + eventsToShow.count + (hiddenCount > 0 ? 1 : 0)
|
||||||
|
|
||||||
|
if totalRows + rowsForThisDay <= maxTotalRows {
|
||||||
|
result.append((date, eventsToShow, hiddenCount))
|
||||||
|
totalRows += rowsForThisDay
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private var timeFmt: DateFormatter {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = WidgetL10n.locale(lang)
|
||||||
|
f.dateFormat = "HH:mm"
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dayFmt: DateFormatter {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.locale = WidgetL10n.locale(lang)
|
||||||
|
f.dateFormat = "EEE d. MMM"
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let primary = Color(widgetHex: snapshot?.primaryColorHex ?? "#4285f4")
|
||||||
|
let accent = Color(widgetHex: snapshot?.accentColorHex ?? "#ea4335")
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(WidgetL10n.t("widget.upcoming", lang))
|
||||||
|
.font(.caption.weight(.bold))
|
||||||
|
.foregroundStyle(primary)
|
||||||
|
.padding(.bottom, 2)
|
||||||
|
if snapshot == nil {
|
||||||
|
Text(WidgetL10n.t("widget.no_data", lang))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else if groupedWithLimits.isEmpty {
|
||||||
|
Text(WidgetL10n.t("widget.no_events", lang))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
ForEach(groupedWithLimits, id: \.0) { day, evs, hiddenCount in
|
||||||
|
dayHeader(d: day, accent: accent)
|
||||||
|
ForEach(evs) { ev in
|
||||||
|
eventRow(ev)
|
||||||
|
}
|
||||||
|
if hiddenCount > 0 {
|
||||||
|
moreRow(count: hiddenCount, accent: accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dayHeader(d: Date, accent: Color) -> some View {
|
||||||
|
let cal = Calendar.current
|
||||||
|
let isToday = cal.isDateInToday(d)
|
||||||
|
return Text(dayFmt.string(from: d))
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundStyle(isToday ? accent : .secondary)
|
||||||
|
.frame(height: dayHeaderHeight, alignment: .bottomLeading)
|
||||||
|
.padding(.top, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func eventRow(_ ev: WidgetEvent) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
RoundedRectangle(cornerRadius: 1.5)
|
||||||
|
.fill(Color(widgetHex: ev.colorHex))
|
||||||
|
.frame(width: 2.5)
|
||||||
|
Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start))
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 38, alignment: .leading)
|
||||||
|
Text(ev.title)
|
||||||
|
.font(.system(size: 10, weight: .medium))
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.frame(height: rowHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func moreRow(count: Int, accent: Color) -> some View {
|
||||||
|
Text(String(format: WidgetL10n.t("widget.more", lang), count))
|
||||||
|
.font(.system(size: 9, weight: .semibold))
|
||||||
|
.foregroundStyle(accent)
|
||||||
|
.frame(height: rowHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
120
CalendarrWidgets/WidgetSupport.swift
Normal file
120
CalendarrWidgets/WidgetSupport.swift
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// Local copy of the Color(hex:) initializer, since the widget extension
|
||||||
|
// is a separate target and cannot import the main app's Color extension.
|
||||||
|
extension Color {
|
||||||
|
init(widgetHex hex: String) {
|
||||||
|
let cleaned = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||||
|
var int: UInt64 = 0
|
||||||
|
Scanner(string: cleaned).scanHexInt64(&int)
|
||||||
|
let r, g, b: UInt64
|
||||||
|
switch cleaned.count {
|
||||||
|
case 6:
|
||||||
|
(r, g, b) = ((int >> 16) & 0xFF, (int >> 8) & 0xFF, int & 0xFF)
|
||||||
|
default:
|
||||||
|
(r, g, b) = (0, 0, 0)
|
||||||
|
}
|
||||||
|
self.init(red: Double(r) / 255, green: Double(g) / 255, blue: Double(b) / 255)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WidgetL10n {
|
||||||
|
static func t(_ key: String, _ stored: String) -> String {
|
||||||
|
let lang: String
|
||||||
|
if stored == "de" || stored == "en" { lang = stored }
|
||||||
|
else {
|
||||||
|
let pref = Locale.preferredLanguages.first ?? "en"
|
||||||
|
lang = pref.lowercased().hasPrefix("de") ? "de" : "en"
|
||||||
|
}
|
||||||
|
return strings[lang]?[key] ?? strings["en"]?[key] ?? key
|
||||||
|
}
|
||||||
|
|
||||||
|
static func locale(_ stored: String) -> Locale {
|
||||||
|
let lang: String
|
||||||
|
if stored == "de" || stored == "en" { lang = stored }
|
||||||
|
else {
|
||||||
|
let pref = Locale.preferredLanguages.first ?? "en"
|
||||||
|
lang = pref.lowercased().hasPrefix("de") ? "de" : "en"
|
||||||
|
}
|
||||||
|
return Locale(identifier: lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let strings: [String: [String: String]] = [
|
||||||
|
"de": [
|
||||||
|
"widget.today": "Heute",
|
||||||
|
"widget.tomorrow": "Morgen",
|
||||||
|
"widget.no_events": "Keine Termine",
|
||||||
|
"widget.allday": "Ganztägig",
|
||||||
|
"widget.more": "+%d weitere",
|
||||||
|
"widget.upcoming": "Nächste 5 Tage",
|
||||||
|
"widget.no_data": "Keine Daten – App einmal öffnen",
|
||||||
|
"widget.display.today_title": "Heute",
|
||||||
|
"widget.display.today_desc": "Heutige Termine auf einen Blick.",
|
||||||
|
"widget.display.days_title": "Heute & Morgen",
|
||||||
|
"widget.display.days_desc": "Termine der nächsten zwei Tage.",
|
||||||
|
"widget.display.upcoming_title": "Nächste 5 Tage",
|
||||||
|
"widget.display.upcoming_desc": "Termine der nächsten 5 Tage.",
|
||||||
|
"widget.display.thisweek_title": "Diese Woche",
|
||||||
|
"widget.display.thisweek_desc": "Wochenraster mit Terminen.",
|
||||||
|
"widget.display.twoweeks_title": "Zwei Wochen",
|
||||||
|
"widget.display.twoweeks_desc": "Zwei-Wochen-Raster mit Terminen.",
|
||||||
|
"widget.display.threedays_title": "Drei Tage",
|
||||||
|
"widget.display.threedays_desc": "Drei-Tages-Ansicht mit Terminen.",
|
||||||
|
"widget.display.upnext_title": "Up Next + Kalender",
|
||||||
|
"widget.display.upnext_desc": "Nächste Termine mit Monatsübersicht.",
|
||||||
|
"widget.display.calday_title": "Tag & Termine",
|
||||||
|
"widget.display.calday_desc": "Datum, Wochenübersicht und nächste Termine.",
|
||||||
|
"widget.display.lockscreen_title": "Datum",
|
||||||
|
"widget.display.lockscreen_desc": "Aktuelles Datum und nächster Termin.",
|
||||||
|
"widget.display.twomonth_title": "Zwei Monate",
|
||||||
|
"widget.display.twomonth_desc": "Aktueller und nächster Monat auf einen Blick.",
|
||||||
|
"widget.display.nownext_title": "Jetzt & Nächstes",
|
||||||
|
"widget.display.nownext_desc": "Aktueller Termin und nächste Ereignisse.",
|
||||||
|
"widget.cw": "KW",
|
||||||
|
"widget.running": "Läuft",
|
||||||
|
"widget.events_count": "Termine",
|
||||||
|
"widget.display.lockscreen_count_title": "Termine heute",
|
||||||
|
"widget.display.lockscreen_count_desc": "Anzahl und Liste heutiger Termine.",
|
||||||
|
"widget.display.lockscreen_countdown_title": "Countdown",
|
||||||
|
"widget.display.lockscreen_countdown_desc": "Zeit bis zum nächsten Termin."
|
||||||
|
],
|
||||||
|
"en": [
|
||||||
|
"widget.today": "Today",
|
||||||
|
"widget.tomorrow": "Tomorrow",
|
||||||
|
"widget.no_events": "No events",
|
||||||
|
"widget.allday": "All-day",
|
||||||
|
"widget.more": "+%d more",
|
||||||
|
"widget.upcoming": "Next 5 days",
|
||||||
|
"widget.no_data": "No data – open the app once",
|
||||||
|
"widget.display.today_title": "Today",
|
||||||
|
"widget.display.today_desc": "Today's events at a glance.",
|
||||||
|
"widget.display.days_title": "Today & tomorrow",
|
||||||
|
"widget.display.days_desc": "Events for the next two days.",
|
||||||
|
"widget.display.upcoming_title": "Next 5 days",
|
||||||
|
"widget.display.upcoming_desc": "Events for the next 5 days.",
|
||||||
|
"widget.display.thisweek_title": "This Week",
|
||||||
|
"widget.display.thisweek_desc": "Week grid with events.",
|
||||||
|
"widget.display.twoweeks_title": "Two Weeks",
|
||||||
|
"widget.display.twoweeks_desc": "Two-week grid with events.",
|
||||||
|
"widget.display.threedays_title": "Three Days",
|
||||||
|
"widget.display.threedays_desc": "Three-day view with events.",
|
||||||
|
"widget.display.upnext_title": "Up Next + Calendar",
|
||||||
|
"widget.display.upnext_desc": "Next events with month overview.",
|
||||||
|
"widget.display.calday_title": "Day & Events",
|
||||||
|
"widget.display.calday_desc": "Date, week overview and upcoming events.",
|
||||||
|
"widget.display.lockscreen_title": "Date",
|
||||||
|
"widget.display.lockscreen_desc": "Current date and next event.",
|
||||||
|
"widget.display.twomonth_title": "Two Months",
|
||||||
|
"widget.display.twomonth_desc": "Current and next month at a glance.",
|
||||||
|
"widget.display.nownext_title": "Now & Next",
|
||||||
|
"widget.display.nownext_desc": "Current event and upcoming events.",
|
||||||
|
"widget.cw": "W",
|
||||||
|
"widget.running": "Running",
|
||||||
|
"widget.events_count": "Events",
|
||||||
|
"widget.display.lockscreen_count_title": "Today's Events",
|
||||||
|
"widget.display.lockscreen_count_desc": "Count and list of today's events.",
|
||||||
|
"widget.display.lockscreen_countdown_title": "Countdown",
|
||||||
|
"widget.display.lockscreen_countdown_desc": "Time until your next event."
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
135
Shared/WidgetData.swift
Normal file
135
Shared/WidgetData.swift
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import Foundation
|
||||||
|
#if canImport(WidgetKit)
|
||||||
|
import WidgetKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// App-Group identifier shared between the main app and the widget extension.
|
||||||
|
/// IMPORTANT: This must match the App Group capability in BOTH targets
|
||||||
|
/// and the App Group ID registered in the Apple Developer Portal.
|
||||||
|
let widgetAppGroupID = "group.com.scarriffleservices.calendarr"
|
||||||
|
|
||||||
|
/// Lightweight event representation that lives inside the widget cache.
|
||||||
|
/// We strip everything the widget doesn't need (notes, calendar IDs, URLs).
|
||||||
|
struct WidgetEvent: Codable, Hashable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let start: Date
|
||||||
|
let end: Date
|
||||||
|
let isAllDay: Bool
|
||||||
|
let colorHex: String
|
||||||
|
let location: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot blob the app writes to the App-Group container and the widget reads.
|
||||||
|
struct WidgetSnapshot: Codable {
|
||||||
|
let writtenAt: Date
|
||||||
|
let events: [WidgetEvent]
|
||||||
|
/// Mirrors the user's chosen visual settings so the widget looks the same
|
||||||
|
/// as the app even when its own AppStorage in the extension is empty.
|
||||||
|
let todayColorHex: String
|
||||||
|
let textColorHex: String
|
||||||
|
let backgroundColorHex: String
|
||||||
|
let lineColorHex: String
|
||||||
|
let primaryColorHex: String
|
||||||
|
let accentColorHex: String
|
||||||
|
let language: String
|
||||||
|
|
||||||
|
init(writtenAt: Date,
|
||||||
|
events: [WidgetEvent],
|
||||||
|
todayColorHex: String,
|
||||||
|
textColorHex: String,
|
||||||
|
backgroundColorHex: String,
|
||||||
|
lineColorHex: String,
|
||||||
|
primaryColorHex: String,
|
||||||
|
accentColorHex: String,
|
||||||
|
language: String) {
|
||||||
|
self.writtenAt = writtenAt
|
||||||
|
self.events = events
|
||||||
|
self.todayColorHex = todayColorHex
|
||||||
|
self.textColorHex = textColorHex
|
||||||
|
self.backgroundColorHex = backgroundColorHex
|
||||||
|
self.lineColorHex = lineColorHex
|
||||||
|
self.primaryColorHex = primaryColorHex
|
||||||
|
self.accentColorHex = accentColorHex
|
||||||
|
self.language = language
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Custom decoder so older caches without the new colour fields still load.
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
writtenAt = try c.decode(Date.self, forKey: .writtenAt)
|
||||||
|
events = try c.decode([WidgetEvent].self, forKey: .events)
|
||||||
|
todayColorHex = try c.decode(String.self, forKey: .todayColorHex)
|
||||||
|
textColorHex = try c.decode(String.self, forKey: .textColorHex)
|
||||||
|
backgroundColorHex = try c.decode(String.self, forKey: .backgroundColorHex)
|
||||||
|
lineColorHex = try c.decode(String.self, forKey: .lineColorHex)
|
||||||
|
language = try c.decode(String.self, forKey: .language)
|
||||||
|
primaryColorHex = try c.decodeIfPresent(String.self, forKey: .primaryColorHex) ?? "#4285f4"
|
||||||
|
accentColorHex = try c.decodeIfPresent(String.self, forKey: .accentColorHex) ?? "#ea4335"
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case writtenAt, events, todayColorHex, textColorHex, backgroundColorHex
|
||||||
|
case lineColorHex, primaryColorHex, accentColorHex, language
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WidgetStore {
|
||||||
|
private static let cacheFilename = "widget-cache.json"
|
||||||
|
|
||||||
|
private static var containerURL: URL? {
|
||||||
|
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: widgetAppGroupID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static var cacheURL: URL? {
|
||||||
|
containerURL?.appendingPathComponent(cacheFilename)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called by the app whenever the event cache changes.
|
||||||
|
static func write(_ snapshot: WidgetSnapshot) {
|
||||||
|
guard let url = cacheURL else { return }
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.dateEncodingStrategy = .iso8601
|
||||||
|
if let data = try? encoder.encode(snapshot) {
|
||||||
|
try? data.write(to: url, options: .atomic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called by the widget timeline provider to load the latest snapshot.
|
||||||
|
static func read() -> WidgetSnapshot? {
|
||||||
|
guard let url = cacheURL, let data = try? Data(contentsOf: url) else { return nil }
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.dateDecodingStrategy = .iso8601
|
||||||
|
return try? decoder.decode(WidgetSnapshot.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrite the existing snapshot with the latest colour / language values
|
||||||
|
/// from UserDefaults. Used when the user tweaks an appearance setting and
|
||||||
|
/// we want the widgets to refresh immediately, without needing a new event
|
||||||
|
/// sync. No-op if there's no cached snapshot yet.
|
||||||
|
static func republishAppearanceOnly() {
|
||||||
|
guard let existing = read() else { return }
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
let updated = WidgetSnapshot(
|
||||||
|
writtenAt: Date(),
|
||||||
|
events: existing.events,
|
||||||
|
todayColorHex: defaults.string(forKey: "todayColor") ?? existing.todayColorHex,
|
||||||
|
textColorHex: defaults.string(forKey: "textColor") ?? existing.textColorHex,
|
||||||
|
backgroundColorHex: defaults.string(forKey: "backgroundColor") ?? existing.backgroundColorHex,
|
||||||
|
lineColorHex: defaults.string(forKey: "lineColor") ?? existing.lineColorHex,
|
||||||
|
primaryColorHex: defaults.string(forKey: "primaryColor") ?? existing.primaryColorHex,
|
||||||
|
accentColorHex: defaults.string(forKey: "accentColor") ?? existing.accentColorHex,
|
||||||
|
language: defaults.string(forKey: "appLanguage") ?? existing.language
|
||||||
|
)
|
||||||
|
write(updated)
|
||||||
|
WidgetTimelineNotifier.reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WidgetTimelineNotifier {
|
||||||
|
static func reload() {
|
||||||
|
#if canImport(WidgetKit)
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user