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;
|
||||
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
|
||||
INFOPLIST_KEY_CFBundleName = Calendarr;
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
@@ -509,7 +510,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.1;
|
||||
MARKETING_VERSION = 2.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
||||
PRODUCT_NAME = "Calendarr iOS";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -533,13 +534,14 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
|
||||
INFOPLIST_KEY_CFBundleName = Calendarr;
|
||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
@@ -551,7 +553,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.1;
|
||||
MARKETING_VERSION = 2.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
||||
PRODUCT_NAME = "Calendarr iOS";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
<key>CalendarrWidgets.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
<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 backgroundColor: String = "#000000"
|
||||
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 {
|
||||
case defaultView = "default_view"
|
||||
@@ -33,6 +36,39 @@ struct AppSettings: Codable {
|
||||
case textColor = "text_color"
|
||||
case backgroundColor = "background_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 color: String
|
||||
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 {
|
||||
@@ -124,16 +181,29 @@ struct HACalendar: Codable, Identifiable {
|
||||
var entityId: String
|
||||
var color: String?
|
||||
var enabled: Bool
|
||||
var sidebarHidden: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, name, color, enabled
|
||||
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 {
|
||||
let id: Int
|
||||
let username: String
|
||||
var displayName: String?
|
||||
var email: String?
|
||||
let isAdmin: Bool
|
||||
let hasAvatar: Bool
|
||||
@@ -141,12 +211,61 @@ struct UserProfile: Codable {
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, username, email
|
||||
case displayName = "display_name"
|
||||
case isAdmin = "is_admin"
|
||||
case hasAvatar = "has_avatar"
|
||||
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 {
|
||||
init(hex: String) {
|
||||
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import Foundation
|
||||
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 {
|
||||
let id: String
|
||||
let url: String
|
||||
@@ -15,8 +32,20 @@ struct CalEvent: Identifiable, Hashable {
|
||||
var calendarName: String
|
||||
var calendarColor: 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? {
|
||||
guard
|
||||
@@ -49,7 +78,14 @@ struct CalEvent: Identifiable, Hashable {
|
||||
calendarId: json["calendar_id"].map { "\($0)" } ?? "",
|
||||
calendarName: json["calendar_name"] as? String ?? "",
|
||||
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 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 {
|
||||
case month, week, day, quarter, agenda
|
||||
|
||||
@@ -39,17 +51,179 @@ class CalendarStore {
|
||||
var events: [CalEvent] = []
|
||||
var viewType: CalViewType = .month
|
||||
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 isCachingBackground = false
|
||||
var lastError: String? = nil
|
||||
var weekStartsOnMonday = true
|
||||
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
|
||||
private var cachedStart: Date? = nil
|
||||
private var cachedEnd: Date? = nil
|
||||
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 cal = Calendar.current
|
||||
cal.firstWeekday = weekStartsOnMonday ? 2 : 1
|
||||
@@ -63,19 +237,61 @@ class CalendarStore {
|
||||
return cs <= start && ce >= end
|
||||
}
|
||||
|
||||
/// Fast in-memory refresh of `events` for the current visible range.
|
||||
/// Call this after navigation without hitting the network.
|
||||
/// Republish the full cached event set, applying only visibility filters
|
||||
/// (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) {
|
||||
_ = (start, end)
|
||||
// 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
|
||||
ev.startDate < end && ev.endDate > start
|
||||
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
|
||||
|
||||
/// Load events for a specific range – skips network if already cached.
|
||||
func loadEvents(api: CalendarrAPI, start: Date, end: Date) async {
|
||||
if isCached(start: start, end: end) {
|
||||
/// Load events for a specific range. Skips the network if already cached,
|
||||
/// unless `force` is set (used after create/edit to pull fresh server data
|
||||
/// 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)
|
||||
return
|
||||
}
|
||||
@@ -83,7 +299,7 @@ class CalendarStore {
|
||||
lastError = nil
|
||||
defer { isLoading = false }
|
||||
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)
|
||||
refreshFromCache(start: start, end: end)
|
||||
} 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.
|
||||
func prefetchBackground(api: CalendarrAPI, months: Int) async {
|
||||
let cal = userCalendar
|
||||
@@ -102,7 +353,7 @@ class CalendarStore {
|
||||
isCachingBackground = true
|
||||
defer { isCachingBackground = false }
|
||||
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)
|
||||
// Refresh visible range from newly expanded cache
|
||||
let (vs, ve) = rangeForCurrentView()
|
||||
@@ -113,11 +364,13 @@ class CalendarStore {
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
cachedStart = nil
|
||||
cachedEnd = nil
|
||||
allCachedEvents = []
|
||||
events = []
|
||||
}
|
||||
|
||||
private func mergeIntoCache(_ newEvents: [CalEvent], rangeStart: Date, rangeEnd: Date) {
|
||||
@@ -135,6 +388,53 @@ class CalendarStore {
|
||||
cachedStart = rangeStart
|
||||
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
|
||||
|
||||
@@ -64,6 +64,8 @@ private let strings: [String: [String: String]] = [
|
||||
"menu.server": "Server",
|
||||
"menu.logout": "Abmelden",
|
||||
"menu.admin": "Admin",
|
||||
"menu.sync": "Mit Server synchronisieren",
|
||||
"menu.sync.section": "Synchronisierung",
|
||||
|
||||
// Settings – chrome
|
||||
"settings.title": "Darstellung",
|
||||
@@ -76,6 +78,9 @@ private let strings: [String: [String: String]] = [
|
||||
"settings.liquidglass": "Liquid Glass",
|
||||
"settings.liquidglass.desc": "Verwendet die neue iOS\u{202F}26 Glasoptik mit transparenter Navigationsleiste",
|
||||
"settings.liquidglass.footer": "Änderung wirkt sofort – kein Neustart nötig.",
|
||||
"settings.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.title": "Vorladen",
|
||||
@@ -122,6 +127,46 @@ private let strings: [String: [String: String]] = [
|
||||
"settings.monday": "Montag",
|
||||
"settings.sunday": "Sonntag",
|
||||
"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.desc": "Platz pro Stunde in der Wochen- & Tagesansicht",
|
||||
@@ -196,6 +241,7 @@ private let strings: [String: [String: String]] = [
|
||||
// Event editor
|
||||
"event.title_placeholder": "Titel",
|
||||
"event.allday": "Ganztägig",
|
||||
"event.private": "Privat",
|
||||
"event.start": "Start",
|
||||
"event.end": "Ende",
|
||||
"event.location": "Ort",
|
||||
@@ -208,6 +254,8 @@ private let strings: [String: [String: String]] = [
|
||||
"event.reset_color": "Zurücksetzen",
|
||||
"event.edit_title": "Termin bearbeiten",
|
||||
"event.new_title": "Neuer Termin",
|
||||
"event.copy_title": "Termin kopieren",
|
||||
"event.copy_to": "In Kalender kopieren",
|
||||
"event.save": "Sichern",
|
||||
"event.add": "Hinzufügen",
|
||||
|
||||
@@ -234,6 +282,20 @@ private let strings: [String: [String: String]] = [
|
||||
"accounts.ha.header": "Home Assistant",
|
||||
"accounts.ha.empty": "Keine Home Assistant-Konten",
|
||||
"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.section": "Konto-Details",
|
||||
@@ -306,6 +368,8 @@ private let strings: [String: [String: String]] = [
|
||||
"menu.server": "Server",
|
||||
"menu.logout": "Sign out",
|
||||
"menu.admin": "Admin",
|
||||
"menu.sync": "Sync with server",
|
||||
"menu.sync.section": "Synchronization",
|
||||
|
||||
"settings.title": "Appearance",
|
||||
"settings.loading": "Loading settings…",
|
||||
@@ -316,6 +380,9 @@ private let strings: [String: [String: String]] = [
|
||||
"settings.liquidglass": "Liquid Glass",
|
||||
"settings.liquidglass.desc": "Uses the new iOS\u{202F}26 glass look with a translucent navigation bar",
|
||||
"settings.liquidglass.footer": "Takes effect immediately – no restart required.",
|
||||
"settings.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.title": "Preloading",
|
||||
@@ -362,6 +429,46 @@ private let strings: [String: [String: String]] = [
|
||||
"settings.monday": "Monday",
|
||||
"settings.sunday": "Sunday",
|
||||
"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.desc": "Space per hour in week & day view",
|
||||
@@ -436,6 +543,7 @@ private let strings: [String: [String: String]] = [
|
||||
// Event editor
|
||||
"event.title_placeholder": "Title",
|
||||
"event.allday": "All-day",
|
||||
"event.private": "Private",
|
||||
"event.start": "Start",
|
||||
"event.end": "End",
|
||||
"event.location": "Location",
|
||||
@@ -448,6 +556,8 @@ private let strings: [String: [String: String]] = [
|
||||
"event.reset_color": "Reset",
|
||||
"event.edit_title": "Edit event",
|
||||
"event.new_title": "New event",
|
||||
"event.copy_title": "Copy event",
|
||||
"event.copy_to": "Copy to calendar",
|
||||
"event.save": "Save",
|
||||
"event.add": "Add",
|
||||
|
||||
@@ -474,6 +584,20 @@ private let strings: [String: [String: String]] = [
|
||||
"accounts.ha.header": "Home Assistant",
|
||||
"accounts.ha.empty": "No Home Assistant accounts",
|
||||
"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.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
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -225,7 +228,8 @@ class CalendarrAPI {
|
||||
}
|
||||
|
||||
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] = [
|
||||
"calendar_id": calendarId,
|
||||
"title": title,
|
||||
@@ -233,9 +237,11 @@ class CalendarrAPI {
|
||||
"end": formatISO(end, allDay: isAllDay),
|
||||
"allDay": isAllDay,
|
||||
"location": location,
|
||||
"description": description
|
||||
"description": description,
|
||||
"private": isPrivate
|
||||
]
|
||||
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)
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
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,
|
||||
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] = [
|
||||
"title": title,
|
||||
"start": formatISO(start, allDay: isAllDay),
|
||||
"end": formatISO(end, allDay: isAllDay),
|
||||
"allDay": isAllDay,
|
||||
"location": location,
|
||||
"description": description
|
||||
"description": description,
|
||||
"private": isPrivate
|
||||
]
|
||||
if let c = color { body["color"] = c }
|
||||
if let reminders { body["reminders"] = reminders }
|
||||
_ = 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)
|
||||
}
|
||||
|
||||
/// 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 UniformTypeIdentifiers
|
||||
|
||||
struct AccountsView: View {
|
||||
let api: CalendarrAPI
|
||||
@@ -14,6 +15,14 @@ struct AccountsView: View {
|
||||
@State private var showAddICal = false
|
||||
@State private var showAddHA = false
|
||||
@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"
|
||||
|
||||
@@ -24,6 +33,7 @@ struct AccountsView: View {
|
||||
ProgressView(L10n.t("accounts.loading", appLang))
|
||||
} else {
|
||||
List {
|
||||
if !banishedKeys.isEmpty { banishedSection }
|
||||
caldavSection
|
||||
localSection
|
||||
icalSection
|
||||
@@ -63,10 +73,55 @@ struct AccountsView: View {
|
||||
}, message: {
|
||||
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() }
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
var caldavSection: some View {
|
||||
@@ -76,16 +131,22 @@ struct AccountsView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(caldavAccounts) { acc in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: acc.color))
|
||||
.frame(width: 12, height: 12)
|
||||
Circle().fill(Color(hex: acc.color)).frame(width: 12, height: 12)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(acc.name).font(.body)
|
||||
Text(acc.url)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Text(acc.url).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
}
|
||||
ForEach(acc.calendars ?? []) { cal in
|
||||
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 {
|
||||
ForEach(localCalendars) { cal in
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: cal.color))
|
||||
.frame(width: 12, height: 12)
|
||||
CalendarColorDot(hex: cal.color, editable: cal.owned) { hex in
|
||||
try? await api.updateLocalCalendarColor(id: cal.id, color: hex)
|
||||
}
|
||||
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
|
||||
@@ -133,9 +219,9 @@ struct AccountsView: View {
|
||||
} else {
|
||||
ForEach(icalSubs) { sub in
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: sub.color))
|
||||
.frame(width: 12, height: 12)
|
||||
CalendarColorDot(hex: sub.color) { hex in
|
||||
try? await api.updateICalColor(id: sub.id, color: hex)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(sub.name).font(.body)
|
||||
Text(String(format: L10n.t("accounts.ical.every", appLang), sub.refreshMinutes))
|
||||
@@ -162,11 +248,21 @@ struct AccountsView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(googleAccounts) { acc in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Image(systemName: "g.circle.fill")
|
||||
.foregroundStyle(.red)
|
||||
Image(systemName: "g.circle.fill").foregroundStyle(.red)
|
||||
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
|
||||
Task { await deleteGoogle(offsets: offsets) }
|
||||
@@ -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 {
|
||||
Section {
|
||||
if haAccounts.isEmpty {
|
||||
@@ -187,12 +363,20 @@ struct AccountsView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(haAccounts) { acc in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(acc.name).font(.body)
|
||||
Text(acc.url)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Text(acc.url).font(.caption).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
|
||||
@@ -210,12 +394,29 @@ struct AccountsView: View {
|
||||
|
||||
private func load() async {
|
||||
isLoading = true
|
||||
banishedKeys = CalendarStore.loadBanishedKeys()
|
||||
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 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
|
||||
}
|
||||
|
||||
@@ -499,3 +700,127 @@ struct AddHASheet: View {
|
||||
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
|
||||
|
||||
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 {
|
||||
let api: CalendarrAPI
|
||||
@Binding var showMenu: Bool
|
||||
@@ -8,21 +19,24 @@ struct CalendarHostView: View {
|
||||
@AppStorage("cacheMonths") private var cacheMonths = 3
|
||||
@AppStorage("appLanguage") private var appLang = "system"
|
||||
@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 showEditor = false
|
||||
@State private var editorDate: Date = .now
|
||||
@State private var editingEvent: CalEvent? = nil
|
||||
@State private var editorContext: CalEditorContext? = nil
|
||||
@State private var selectedEvent: CalEvent? = nil
|
||||
@State private var visibleMonth: Date = .now
|
||||
@State private var showFilter = false
|
||||
@State private var didApplyDefaultView = false
|
||||
@State private var groups: [CalGroup] = []
|
||||
|
||||
private var titleString: String {
|
||||
if store.viewType == .month {
|
||||
let f = DateFormatter()
|
||||
f.locale = L10n.locale(appLang)
|
||||
f.dateFormat = "LLLL yyyy"
|
||||
return f.string(from: visibleMonth).capitalized(with: L10n.locale(appLang))
|
||||
return f.string(from: store.visibleMonth).capitalized(with: L10n.locale(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
|
||||
|
||||
private var flatVariant: some View {
|
||||
VStack(spacing: 0) {
|
||||
topBar
|
||||
groupBanner
|
||||
Divider()
|
||||
errorBanner
|
||||
calendarContent
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(hex: bgHex))
|
||||
.overlay(alignment: .top) {
|
||||
if store.isLoading {
|
||||
ProgressView().padding(.top, 10).transition(.opacity)
|
||||
}
|
||||
loadingIndicator.padding(.top, 12)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: store.isLoading || store.isCachingBackground)
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) { solidFAB }
|
||||
// Subtle background cache indicator (top-leading)
|
||||
.overlay(alignment: .topLeading) {
|
||||
if store.isCachingBackground {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(6)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
.modifier(calendarSheets)
|
||||
.task { await startup() }
|
||||
.onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } }
|
||||
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
|
||||
.onChange(of: cacheMonths) { _, _ in Task { await recache() } }
|
||||
.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
|
||||
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
|
||||
|
||||
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 {
|
||||
calendarContent
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(hex: bgHex))
|
||||
.overlay(alignment: .top) {
|
||||
if store.isLoading {
|
||||
ProgressView().padding(.top, 10).transition(.opacity)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
if let err = store.lastError { errorBannerView(err).padding(.top, 8) }
|
||||
loadingIndicator.padding(.top, 12)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: store.isLoading || store.isCachingBackground)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
HStack(spacing: 2) {
|
||||
Button { store.navigatePrev() } label: { Image(systemName: "chevron.left") }
|
||||
Button { store.navigateNext() } label: { Image(systemName: "chevron.right") }
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack(spacing: 8) {
|
||||
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout)
|
||||
menuButton
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .principal) {
|
||||
}
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
VStack(spacing: 0) {
|
||||
Text(titleString)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack(spacing: 8) {
|
||||
viewPickerMenu
|
||||
Button { showFilter = true } label: {
|
||||
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") }
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
.background(.bar)
|
||||
groupBanner
|
||||
errorBanner
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,15 +151,28 @@ struct CalendarHostView: View {
|
||||
.onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } }
|
||||
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
|
||||
.onChange(of: cacheMonths) { _, _ in Task { await recache() } }
|
||||
.onChange(of: visibleMonth) { _, new in Task { await ensureLoaded(around: new) } }
|
||||
.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
|
||||
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)
|
||||
|
||||
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: 2) {
|
||||
Button { store.navigatePrev() } label: {
|
||||
@@ -143,53 +185,103 @@ struct CalendarHostView: View {
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }
|
||||
.font(.callout).padding(.horizontal, 6)
|
||||
}
|
||||
.padding(.leading, 8)
|
||||
Spacer(minLength: 8)
|
||||
.padding(.leading, 6)
|
||||
Spacer(minLength: 6)
|
||||
Text(titleString)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
Spacer(minLength: 8)
|
||||
viewPickerMenu
|
||||
filterButton
|
||||
Button { showMenu = true } label: {
|
||||
Image(systemName: "line.3.horizontal")
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.frame(width: 40, height: 40)
|
||||
}
|
||||
.padding(.trailing, 4)
|
||||
.layoutPriority(1)
|
||||
Spacer(minLength: 6)
|
||||
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }
|
||||
.font(.callout).padding(.horizontal, 6)
|
||||
.lineLimit(1).fixedSize()
|
||||
menuButton
|
||||
.padding(.trailing, 2)
|
||||
}
|
||||
.frame(height: 48)
|
||||
.background(.bar)
|
||||
}
|
||||
|
||||
private var filterButton: some View {
|
||||
Button { showFilter = true } label: {
|
||||
Image(systemName: "line.3.horizontal.decrease.circle")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(store.hiddenCalendarKeys.isEmpty ? .primary : Color.accentColor)
|
||||
.frame(width: 40, height: 40)
|
||||
}
|
||||
.accessibilityLabel(L10n.t("filter.button", appLang))
|
||||
private var topBar: some View {
|
||||
barContents.background(.bar)
|
||||
}
|
||||
|
||||
private var viewPickerMenu: some View {
|
||||
@ViewBuilder private var groupBanner: some View {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// View (fixed icon, not per-view)
|
||||
Menu {
|
||||
ForEach(CalViewType.allCases, id: \.self) { vt in
|
||||
Button { store.viewType = vt } label: {
|
||||
Label(vt.label(appLang), systemImage: vt.systemImage)
|
||||
Label(vt.label(appLang), systemImage: store.viewType == vt ? "checkmark" : vt.systemImage)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: store.viewType.systemImage)
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(.primary)
|
||||
.frame(width: 40, height: 40)
|
||||
Label(L10n.t("view.change", appLang), systemImage: "rectangle.3.group")
|
||||
}
|
||||
.accessibilityLabel(L10n.t("view.change", appLang))
|
||||
// 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: {
|
||||
Image(systemName: "line.3.horizontal")
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundStyle(store.activeGroup == nil && store.hiddenCalendarKeys.isEmpty ? .primary : Color.accentColor)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
.accessibilityLabel(L10n.t("nav.menu", appLang))
|
||||
}
|
||||
|
||||
// MARK: – Error banner
|
||||
@@ -228,13 +320,9 @@ struct CalendarHostView: View {
|
||||
case .month:
|
||||
// Month view uses vertical scroll – no horizontal swipe.
|
||||
MonthView(store: store,
|
||||
onDayTap: { editorDate = $0 },
|
||||
onDayTap: { store.currentDate = $0 },
|
||||
onEventTap: { selectedEvent = $0 },
|
||||
onCreateEvent: { day in
|
||||
editingEvent = nil
|
||||
editorDate = day
|
||||
showEditor = true
|
||||
},
|
||||
onCreateEvent: { day in editorContext = .create(day) },
|
||||
onShowWeek: { day in
|
||||
store.currentDate = day
|
||||
store.viewType = .week
|
||||
@@ -242,16 +330,11 @@ struct CalendarHostView: View {
|
||||
onShowDay: { day in
|
||||
store.currentDate = day
|
||||
store.viewType = .day
|
||||
},
|
||||
visibleMonth: $visibleMonth)
|
||||
})
|
||||
case .week:
|
||||
WeekView(store: store,
|
||||
onEventTap: { selectedEvent = $0 },
|
||||
onCreateEvent: { date in
|
||||
editingEvent = nil
|
||||
editorDate = date
|
||||
showEditor = true
|
||||
},
|
||||
onCreateEvent: { date in editorContext = .create(date) },
|
||||
onShowMonth: { date in
|
||||
store.currentDate = date
|
||||
store.viewType = .month
|
||||
@@ -264,11 +347,7 @@ struct CalendarHostView: View {
|
||||
case .day:
|
||||
DayView(store: store,
|
||||
onEventTap: { selectedEvent = $0 },
|
||||
onCreateEvent: { date in
|
||||
editingEvent = nil
|
||||
editorDate = date
|
||||
showEditor = true
|
||||
})
|
||||
onCreateEvent: { date in editorContext = .create(date) })
|
||||
.simultaneousGesture(swipe)
|
||||
case .quarter:
|
||||
QuarterView(store: store, onEventTap: { selectedEvent = $0 })
|
||||
@@ -283,7 +362,7 @@ struct CalendarHostView: View {
|
||||
/// Standard solid FAB (flat mode)
|
||||
private var solidFAB: some View {
|
||||
Button {
|
||||
editingEvent = nil; editorDate = .now; showEditor = true
|
||||
editorContext = .create(.now)
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
@@ -301,7 +380,7 @@ struct CalendarHostView: View {
|
||||
private var glassFAB: some View {
|
||||
if #available(iOS 26, *) {
|
||||
Button {
|
||||
editingEvent = nil; editorDate = .now; showEditor = true
|
||||
editorContext = .create(.now)
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
@@ -319,16 +398,25 @@ struct CalendarHostView: View {
|
||||
// MARK: – Sheets modifier
|
||||
|
||||
private var calendarSheets: CalendarSheets {
|
||||
CalendarSheets(store: store, showEditor: $showEditor,
|
||||
editorDate: $editorDate, editingEvent: $editingEvent,
|
||||
CalendarSheets(store: store, editorContext: $editorContext,
|
||||
selectedEvent: $selectedEvent, showFilter: $showFilter,
|
||||
api: api, reload: { await onNavigate() })
|
||||
api: api,
|
||||
reload: { await onNavigate() },
|
||||
reloadForce: { await reloadVisible(force: true) })
|
||||
}
|
||||
|
||||
// MARK: – Loading logic
|
||||
|
||||
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)
|
||||
groups = (try? await api.getGroups()) ?? []
|
||||
// 1. Load current view immediately (visible)
|
||||
let (s, e) = store.rangeForCurrentView()
|
||||
await store.loadEvents(api: api, start: s, end: e)
|
||||
@@ -336,6 +424,25 @@ struct CalendarHostView: View {
|
||||
Task(priority: .background) {
|
||||
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.
|
||||
@@ -344,12 +451,31 @@ struct CalendarHostView: View {
|
||||
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.
|
||||
private func recache() async {
|
||||
store.invalidateCache()
|
||||
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
|
||||
/// immediately from cache, then fetches on demand if needed.
|
||||
private func ensureLoaded(around month: Date) async {
|
||||
@@ -373,27 +499,32 @@ struct CalendarHostView: View {
|
||||
|
||||
private struct CalendarSheets: ViewModifier {
|
||||
let store: CalendarStore
|
||||
@Binding var showEditor: Bool
|
||||
@Binding var editorDate: Date
|
||||
@Binding var editingEvent: CalEvent?
|
||||
@Binding var editorContext: CalEditorContext?
|
||||
@Binding var selectedEvent: CalEvent?
|
||||
@Binding var showFilter: Bool
|
||||
let api: CalendarrAPI
|
||||
let reload: () async -> Void
|
||||
let reloadForce: () async -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
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,
|
||||
initialDate: editorDate, editingEvent: editingEvent) {
|
||||
editingEvent = nil; await reload()
|
||||
initialDate: date, editingEvent: editingEv) {
|
||||
editorContext = nil
|
||||
await reloadForce()
|
||||
}
|
||||
}
|
||||
.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
|
||||
if let u = updated { editingEvent = u; showEditor = true }
|
||||
await reload()
|
||||
if let u = updated { editorContext = .edit(u) }
|
||||
if needsForce { await reloadForce() } else { await reload() }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showFilter) {
|
||||
|
||||
@@ -9,10 +9,16 @@ struct DayView: View {
|
||||
@AppStorage("todayColor") private var todayHex = "#4285f4"
|
||||
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
||||
@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 allDayEvents: [CalEvent] { store.events(on: store.currentDate).filter(\.isAllDay) }
|
||||
private var timedEvents: [CalEvent] { store.events(on: store.currentDate).filter { !$0.isAllDay } }
|
||||
private var allDayEvents: [CalEvent] {
|
||||
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 {
|
||||
VStack(spacing: 0) {
|
||||
@@ -97,7 +103,7 @@ struct DayView: View {
|
||||
Color.clear.frame(height: hourHeight)
|
||||
Text(String(format: "%02d:00", h))
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(Color(hex: textHex).opacity(0.6))
|
||||
.foregroundStyle(Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
|
||||
.offset(y: -6)
|
||||
}
|
||||
}
|
||||
@@ -132,6 +138,7 @@ private struct DayHourSlot: View {
|
||||
let onCreateEvent: (Date) -> Void
|
||||
|
||||
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
|
||||
@AppStorage("lineContrast") private var lineContrast = 3
|
||||
|
||||
private var date: Date {
|
||||
Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day
|
||||
@@ -139,7 +146,7 @@ private struct DayHourSlot: View {
|
||||
|
||||
var body: some View {
|
||||
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)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
|
||||
@@ -4,11 +4,16 @@ struct EventDetailSheet: View {
|
||||
let event: CalEvent
|
||||
let api: CalendarrAPI
|
||||
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
|
||||
@State private var showDeleteConfirm = false
|
||||
@State private var isDeleting = false
|
||||
@State private var showCopySheet = false
|
||||
|
||||
private let timeFmt: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
@@ -37,7 +42,14 @@ struct EventDetailSheet: View {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -84,9 +96,31 @@ struct EventDetailSheet: View {
|
||||
Text(event.source.capitalized)
|
||||
.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 {
|
||||
Button(role: .destructive) {
|
||||
showDeleteConfirm = true
|
||||
@@ -104,18 +138,18 @@ struct EventDetailSheet: View {
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Schliessen") {
|
||||
Task { await onDone(nil) }
|
||||
Task { await onDone(nil, false) }
|
||||
}
|
||||
}
|
||||
if canEdit {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
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) {
|
||||
Task { await deleteEvent() }
|
||||
}
|
||||
@@ -123,19 +157,39 @@ struct EventDetailSheet: View {
|
||||
} message: {
|
||||
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 {
|
||||
isDeleting = true
|
||||
do {
|
||||
if event.source == "local" {
|
||||
switch event.source {
|
||||
case "local":
|
||||
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)
|
||||
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 {
|
||||
isDeleting = false
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ struct EventEditorSheet: View {
|
||||
let store: CalendarStore
|
||||
let initialDate: Date
|
||||
let editingEvent: CalEvent?
|
||||
var copyFrom: CalEvent? = nil
|
||||
let onSaved: () async -> Void
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@AppStorage("appLanguage") private var appLang = "system"
|
||||
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
|
||||
@State private var title = ""
|
||||
@State private var isAllDay = false
|
||||
@State private var startDate = Date()
|
||||
@@ -17,10 +19,13 @@ struct EventEditorSheet: View {
|
||||
@State private var notes = ""
|
||||
@State private var selectedCalendarId: String = ""
|
||||
@State private var color = ""
|
||||
@State private var isPrivate = false
|
||||
@State private var reminders: [Int] = []
|
||||
@State private var isSaving = false
|
||||
@State private var error = ""
|
||||
|
||||
private var isEditing: Bool { editingEvent != nil }
|
||||
private var isCopying: Bool { copyFrom != nil && editingEvent == nil }
|
||||
|
||||
private var selectedCal: WritableCalendar? {
|
||||
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)) {
|
||||
HStack {
|
||||
Text(L10n.t("event.color", appLang))
|
||||
@@ -96,9 +129,11 @@ struct EventEditorSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(isEditing
|
||||
? L10n.t("event.edit_title", appLang)
|
||||
: L10n.t("event.new_title", appLang))
|
||||
.navigationTitle(
|
||||
isEditing ? L10n.t("event.edit_title", appLang) :
|
||||
isCopying ? L10n.t("event.copy_title", appLang) :
|
||||
L10n.t("event.new_title", appLang)
|
||||
)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
@@ -116,6 +151,12 @@ struct EventEditorSheet: View {
|
||||
}
|
||||
}
|
||||
.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() {
|
||||
@@ -123,17 +164,43 @@ struct EventEditorSheet: View {
|
||||
title = ev.title
|
||||
isAllDay = ev.isAllDay
|
||||
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
|
||||
notes = ev.notes
|
||||
color = ev.color ?? ""
|
||||
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 {
|
||||
let cal = Calendar.current
|
||||
startDate = cal.date(bySettingHour: cal.component(.hour, from: initialDate),
|
||||
minute: 0, second: 0, of: initialDate) ?? initialDate
|
||||
endDate = startDate.addingTimeInterval(3600)
|
||||
selectedCalendarId = store.writableCalendars.first?.id ?? ""
|
||||
// New events inherit the user's default reminder (editable).
|
||||
if defaultReminderMinutes >= 0 { reminders = [defaultReminderMinutes] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,10 +216,20 @@ struct EventEditorSheet: View {
|
||||
|
||||
do {
|
||||
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,
|
||||
isAllDay: isAllDay, location: location, description: notes, color: colorVal)
|
||||
} else {
|
||||
isAllDay: isAllDay, location: location, description: notes, color: colorVal,
|
||||
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)
|
||||
try await api.updateCalDAVEvent(uid: ev.id, url: ev.url, calendarId: calId,
|
||||
title: title, start: start, end: end, isAllDay: isAllDay,
|
||||
@@ -163,7 +240,8 @@ struct EventEditorSheet: View {
|
||||
case "local":
|
||||
_ = try await api.createLocalEvent(calendarId: cal.numericId, title: title,
|
||||
start: start, end: end, isAllDay: isAllDay,
|
||||
location: location, description: notes, color: colorVal)
|
||||
location: location, description: notes, color: colorVal,
|
||||
isPrivate: isPrivate, reminders: reminders)
|
||||
case "google":
|
||||
try await api.createGoogleEvent(calendarDbId: cal.numericId, title: title,
|
||||
start: start, end: end, isAllDay: isAllDay,
|
||||
|
||||
@@ -19,13 +19,13 @@ struct MonthView: View {
|
||||
let onCreateEvent: (Date) -> Void
|
||||
let onShowWeek: (Date) -> Void
|
||||
let onShowDay: (Date) -> Void
|
||||
@Binding var visibleMonth: Date
|
||||
|
||||
@AppStorage("appLanguage") private var appLang = "system"
|
||||
@AppStorage("monthDividerColor") private var dividerHex = "#7090c0"
|
||||
@AppStorage("monthLabelColor") private var labelHex = "#7090c0"
|
||||
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
||||
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
|
||||
@AppStorage("textContrast") private var textContrast = 3
|
||||
|
||||
@State private var scrolledWeek: Date? = nil
|
||||
@State private var didInitialScroll = false
|
||||
@@ -52,8 +52,7 @@ struct MonthView: View {
|
||||
headerRow
|
||||
Divider()
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(weekStarts, id: \.self) { ws in
|
||||
LazyVStack(spacing: 0) { ForEach(weekStarts, id: \.self) { ws in
|
||||
WeekRow(weekStart: ws,
|
||||
store: store,
|
||||
dividerColor: Color(hex: dividerHex),
|
||||
@@ -71,6 +70,7 @@ struct MonthView: View {
|
||||
}
|
||||
.scrollTargetLayout()
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
.scrollPosition(id: $scrolledWeek, anchor: .top)
|
||||
.onAppear {
|
||||
if !didInitialScroll {
|
||||
@@ -98,7 +98,7 @@ struct MonthView: View {
|
||||
ForEach(weekdayHeaders, id: \.self) { d in
|
||||
Text(d)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(Color(hex: textHex).opacity(0.7))
|
||||
.foregroundStyle(Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
|
||||
.frame(maxWidth: .infinity, minHeight: weekdayHeaderHeight)
|
||||
}
|
||||
}
|
||||
@@ -108,23 +108,16 @@ struct MonthView: View {
|
||||
cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date))!
|
||||
}
|
||||
|
||||
/// Determine the visible month from the currently-scrolled week.
|
||||
/// Instead of switching as soon as a few days of the next month appear,
|
||||
/// we count the month affiliation of the visible week rows and keep the
|
||||
/// month that occupies the majority of the viewport.
|
||||
/// Determine the header month from the currently-scrolled week.
|
||||
/// Rule: take the month of the topmost visible week's start day. This
|
||||
/// means as long as the "1." of the next month is still visible in the
|
||||
/// 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?) {
|
||||
guard let w = week else { return }
|
||||
|
||||
let visibleWeeks = (0..<6).compactMap { cal.date(byAdding: .weekOfYear, value: $0, to: w) }
|
||||
let monthCounts = visibleWeeks.reduce(into: [Date: Int]()) { acc, weekStart in
|
||||
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
|
||||
let month = cal.date(from: cal.dateComponents([.year, .month], from: w)) ?? w
|
||||
if store.visibleMonth != month {
|
||||
store.visibleMonth = month
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,6 +291,9 @@ private struct DayCell: View {
|
||||
let onShowWeek: () -> Void
|
||||
let onShowDay: () -> Void
|
||||
|
||||
@AppStorage("textContrast") private var textContrast = 3
|
||||
@AppStorage("lineContrast") private var lineContrast = 3
|
||||
|
||||
private var cal: Calendar { Calendar.current }
|
||||
private var dayNum: Int { cal.component(.day, from: date) }
|
||||
private var isFirstOfMonth: Bool { dayNum == 1 }
|
||||
@@ -337,14 +333,14 @@ private struct DayCell: View {
|
||||
if extraCount > 0 {
|
||||
Text("+\(extraCount)")
|
||||
.font(.system(size: 9, weight: .medium))
|
||||
.foregroundStyle(textColor.opacity(0.6))
|
||||
.foregroundStyle(textColor.opacity(secondaryTextOpacity(textContrast)))
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if let wn = weekNumber {
|
||||
Text("\(cwLabel) \(wn)")
|
||||
.font(.system(size: 9, weight: .medium))
|
||||
.foregroundStyle(textColor.opacity(0.6))
|
||||
.foregroundStyle(textColor.opacity(secondaryTextOpacity(textContrast)))
|
||||
.padding(.trailing, 4)
|
||||
}
|
||||
}
|
||||
@@ -352,11 +348,11 @@ private struct DayCell: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.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) {
|
||||
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)
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
@@ -384,6 +380,9 @@ private struct DayCell: View {
|
||||
|
||||
private struct EventBar: View {
|
||||
let event: CalEvent
|
||||
@AppStorage("dimPastEvents") private var dimPast = false
|
||||
|
||||
private var isPast: Bool { event.endDate < .now }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 3) {
|
||||
@@ -397,5 +396,6 @@ private struct EventBar: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(hex: event.effectiveColor))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 3))
|
||||
.opacity(dimPast && isPast ? 0.5 : 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,54 @@
|
||||
import SwiftUI
|
||||
|
||||
// Shared constants used by WeekView, DayView, EventEditorSheet
|
||||
let hourHeight: CGFloat = 60
|
||||
let timeColumnWidth: CGFloat = 44
|
||||
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
|
||||
func eventTop(_ ev: CalEvent) -> CGFloat {
|
||||
let cal = Calendar.current
|
||||
@@ -21,6 +65,9 @@ func eventHeight(_ ev: CalEvent) -> CGFloat {
|
||||
// Shared event block used in WeekView and DayView
|
||||
struct EventBlock: View {
|
||||
let event: CalEvent
|
||||
@AppStorage("dimPastEvents") private var dimPast = false
|
||||
|
||||
private var isPast: Bool { event.endDate < .now }
|
||||
|
||||
var body: some View {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
@@ -41,5 +88,6 @@ struct EventBlock: View {
|
||||
.padding(4)
|
||||
}
|
||||
.padding(.horizontal, 1)
|
||||
.opacity(dimPast && isPast ? 0.5 : 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ struct WeekView: View {
|
||||
@AppStorage("todayColor") private var todayHex = "#4285f4"
|
||||
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
||||
@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 }
|
||||
|
||||
@@ -21,14 +24,16 @@ struct WeekView: View {
|
||||
|
||||
private var timedEvents: [(Int, CalEvent)] {
|
||||
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] {
|
||||
let s = weekDays.first ?? .now
|
||||
let e = cal.date(byAdding: .day, value: 1, to: weekDays.last ?? .now)!
|
||||
return store.events(in: s, end: e).filter(\.isAllDay)
|
||||
return store.events(in: s, end: e).filter { $0.isAllDay || eventSpansMultipleDays($0) }
|
||||
}
|
||||
|
||||
private var todayIndex: Int? {
|
||||
@@ -56,10 +61,10 @@ struct WeekView: View {
|
||||
ForEach(weekDays, id: \.self) { day in
|
||||
Text(headerFmt.string(from: day).uppercased())
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(cal.isDateInToday(day) ? Color.accentColor : Color(hex: textHex).opacity(0.7))
|
||||
.foregroundStyle(cal.isDateInToday(day) ? Color(hex: todayHex) : Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
|
||||
.frame(maxWidth: .infinity, minHeight: 36)
|
||||
.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)
|
||||
.frame(maxWidth: .infinity)
|
||||
.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)
|
||||
.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)
|
||||
Text(String(format: "%02d:00", h))
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(Color(hex: textHex).opacity(0.6))
|
||||
.foregroundStyle(Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
|
||||
.offset(y: -6)
|
||||
}
|
||||
}
|
||||
@@ -209,6 +214,7 @@ struct HourSlot: View {
|
||||
let onShowDay: (Date) -> Void
|
||||
|
||||
@AppStorage("lineColor") private var lineHex = "#3A3A3C"
|
||||
@AppStorage("lineContrast") private var lineContrast = 3
|
||||
|
||||
private var date: Date {
|
||||
Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day
|
||||
@@ -216,7 +222,7 @@ struct HourSlot: View {
|
||||
|
||||
var body: some View {
|
||||
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)
|
||||
}
|
||||
.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(\.dismiss) var dismiss
|
||||
@AppStorage("appLanguage") private var appLang = "system"
|
||||
@State private var isSyncing = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -61,6 +62,12 @@ struct MenuSheet: View {
|
||||
Label(L10n.t("menu.accounts", appLang), systemImage: "tray.2")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
GroupsView(api: api)
|
||||
} label: {
|
||||
Label(L10n.t("groups.title", appLang), systemImage: "person.2")
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
ServerView()
|
||||
} 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 {
|
||||
Button(role: .destructive) {
|
||||
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)
|
||||
passwordSection
|
||||
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 {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
@@ -2,12 +2,8 @@ import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
let api: CalendarrAPI
|
||||
@State private var settings = AppSettings()
|
||||
@State private var isLoading = true
|
||||
@State private var isSaving = false
|
||||
@State private var toast = ""
|
||||
@State private var showToast = false
|
||||
@AppStorage("liquidGlass") private var liquidGlass = false
|
||||
@AppStorage("settingsSync") private var settingsSync = false
|
||||
@AppStorage("cacheMonths") private var cacheMonths = 3
|
||||
@AppStorage("appLanguage") private var appLang = "system"
|
||||
@AppStorage("monthDividerColor") private var dividerHex = "#7090c0"
|
||||
@@ -16,14 +12,34 @@ struct SettingsView: View {
|
||||
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
||||
@AppStorage("backgroundColor") private var bgHex = "#000000"
|
||||
@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 {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView(L10n.t("settings.loading", appLang))
|
||||
} else {
|
||||
Form {
|
||||
profilSection
|
||||
privatsphaereSection
|
||||
benachrichtigungenSection
|
||||
geteilterKalenderSection
|
||||
liquidGlassSection
|
||||
cacheSection
|
||||
spracheSection
|
||||
@@ -33,38 +49,148 @@ struct SettingsView: View {
|
||||
ansichtSection
|
||||
stundenSection
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(L10n.t("settings.title", appLang))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
Task { await save() }
|
||||
} label: {
|
||||
if isSaving {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text(L10n.t("settings.save", appLang)).bold()
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
.disabled(isSaving)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
.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))
|
||||
.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)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut, value: showToast)
|
||||
|
||||
// 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
|
||||
}
|
||||
.task { await load() }
|
||||
}
|
||||
|
||||
// MARK: – Liquid Glass
|
||||
@@ -85,10 +211,25 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
.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: {
|
||||
Text(L10n.t("settings.appdesign", appLang))
|
||||
} footer: {
|
||||
Text(L10n.t("settings.liquidglass.footer", appLang))
|
||||
Text(L10n.t("settings.sync.footer", appLang))
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
@@ -143,8 +284,8 @@ struct SettingsView: View {
|
||||
|
||||
var farbenSection: some View {
|
||||
Section(L10n.t("settings.colors", appLang)) {
|
||||
ColorPickerRow(label: L10n.t("settings.color.primary", appLang), hex: $settings.primaryColor)
|
||||
ColorPickerRow(label: L10n.t("settings.color.accent", appLang), hex: $settings.accentColor)
|
||||
ColorPickerRow(label: L10n.t("settings.color.primary", appLang), hex: $primaryHex)
|
||||
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.text", appLang), hex: $textHex)
|
||||
ColorPickerRow(label: L10n.t("settings.color.background", appLang), hex: $bgHex)
|
||||
@@ -165,7 +306,7 @@ struct SettingsView: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ContrastSelector(
|
||||
value: $settings.textContrast,
|
||||
value: $textContrast,
|
||||
options: [
|
||||
(1, L10n.t("settings.contrast.dark", appLang)),
|
||||
(2, L10n.t("settings.contrast.medium", appLang)),
|
||||
@@ -189,7 +330,7 @@ struct SettingsView: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ContrastSelector(
|
||||
value: $settings.lineContrast,
|
||||
value: $lineContrast,
|
||||
options: [
|
||||
(1, L10n.t("settings.linecontrast.barely", appLang)),
|
||||
(2, L10n.t("settings.linecontrast.subtle", appLang)),
|
||||
@@ -206,18 +347,18 @@ struct SettingsView: View {
|
||||
|
||||
var ansichtSection: some View {
|
||||
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.week", appLang)).tag("week")
|
||||
Text(L10n.t("view.day", appLang)).tag("day")
|
||||
Text(L10n.t("view.quarter", appLang)).tag("quarter")
|
||||
Text(L10n.t("view.agenda", appLang)).tag("agenda")
|
||||
}
|
||||
Picker(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.sunday", appLang)).tag("sunday")
|
||||
}
|
||||
Toggle(L10n.t("settings.dimpast", appLang), isOn: $settings.dimPastEvents)
|
||||
Toggle(L10n.t("settings.dimpast", appLang), isOn: $dimPastEvents)
|
||||
.tint(Color.accentColor)
|
||||
}
|
||||
}
|
||||
@@ -233,7 +374,7 @@ struct SettingsView: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ContrastSelector(
|
||||
value: $settings.hourHeight,
|
||||
value: $hourHeight,
|
||||
options: [
|
||||
(28, L10n.t("settings.hourheight.compact", 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
|
||||
|
||||
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