Compare commits
6 Commits
4125bfc728
...
9fac13f99c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fac13f99c | ||
|
|
da2e39911c | ||
|
|
023f90be3b | ||
|
|
e7e4998fb9 | ||
|
|
b1e0cf1fdc | ||
|
|
e71fd7512f |
@@ -498,6 +498,7 @@
|
|||||||
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
|
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
|
||||||
INFOPLIST_KEY_CFBundleName = Calendarr;
|
INFOPLIST_KEY_CFBundleName = Calendarr;
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES;
|
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
@@ -509,7 +510,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2;
|
MARKETING_VERSION = 1.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
||||||
PRODUCT_NAME = "Calendarr iOS";
|
PRODUCT_NAME = "Calendarr iOS";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -540,6 +541,7 @@
|
|||||||
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
|
INFOPLIST_KEY_CFBundleDisplayName = Calendarr;
|
||||||
INFOPLIST_KEY_CFBundleName = Calendarr;
|
INFOPLIST_KEY_CFBundleName = Calendarr;
|
||||||
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||||
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES;
|
INFOPLIST_KEY_NSAppTransportSecurity_NSAllowsArbitraryLoads = YES;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
@@ -551,7 +553,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2;
|
MARKETING_VERSION = 1.4;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
||||||
PRODUCT_NAME = "Calendarr iOS";
|
PRODUCT_NAME = "Calendarr iOS";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ struct AppSettings: Codable {
|
|||||||
var textColor: String = "#FFFFFF"
|
var textColor: String = "#FFFFFF"
|
||||||
var backgroundColor: String = "#000000"
|
var backgroundColor: String = "#000000"
|
||||||
var lineColor: String = "#3A3A3C"
|
var lineColor: String = "#3A3A3C"
|
||||||
|
var privateEventVisibility: String = "busy" // 'hidden' | 'busy'
|
||||||
|
var groupVisibleCalendarId: Int? = nil
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case defaultView = "default_view"
|
case defaultView = "default_view"
|
||||||
@@ -33,6 +35,8 @@ struct AppSettings: Codable {
|
|||||||
case textColor = "text_color"
|
case textColor = "text_color"
|
||||||
case backgroundColor = "background_color"
|
case backgroundColor = "background_color"
|
||||||
case lineColor = "line_color"
|
case lineColor = "line_color"
|
||||||
|
case privateEventVisibility = "private_event_visibility"
|
||||||
|
case groupVisibleCalendarId = "group_visible_calendar_id"
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {}
|
init() {}
|
||||||
@@ -60,6 +64,8 @@ struct AppSettings: Codable {
|
|||||||
textColor = try c.decodeIfPresent(String.self, forKey: .textColor) ?? d.textColor
|
textColor = try c.decodeIfPresent(String.self, forKey: .textColor) ?? d.textColor
|
||||||
backgroundColor = try c.decodeIfPresent(String.self, forKey: .backgroundColor) ?? d.backgroundColor
|
backgroundColor = try c.decodeIfPresent(String.self, forKey: .backgroundColor) ?? d.backgroundColor
|
||||||
lineColor = try c.decodeIfPresent(String.self, forKey: .lineColor) ?? d.lineColor
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +101,27 @@ struct LocalCalendar: Codable, Identifiable {
|
|||||||
var name: String
|
var name: String
|
||||||
var color: String
|
var color: String
|
||||||
var enabled: Bool
|
var enabled: Bool
|
||||||
|
var owned: Bool = true
|
||||||
|
var sharedBy: String? = nil
|
||||||
|
var permission: String? = nil
|
||||||
|
var group: Bool = false
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, color, enabled, owned, permission, group
|
||||||
|
case sharedBy = "shared_by"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try c.decode(Int.self, forKey: .id)
|
||||||
|
name = try c.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||||
|
color = try c.decodeIfPresent(String.self, forKey: .color) ?? "#34a853"
|
||||||
|
enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? true
|
||||||
|
owned = try c.decodeIfPresent(Bool.self, forKey: .owned) ?? true
|
||||||
|
sharedBy = try c.decodeIfPresent(String.self, forKey: .sharedBy)
|
||||||
|
permission = try c.decodeIfPresent(String.self, forKey: .permission)
|
||||||
|
group = try c.decodeIfPresent(Bool.self, forKey: .group) ?? false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ICalSubscription: Codable, Identifiable {
|
struct ICalSubscription: Codable, Identifiable {
|
||||||
@@ -173,6 +200,7 @@ struct HACalendar: Codable, Identifiable {
|
|||||||
struct UserProfile: Codable {
|
struct UserProfile: Codable {
|
||||||
let id: Int
|
let id: Int
|
||||||
let username: String
|
let username: String
|
||||||
|
var displayName: String?
|
||||||
var email: String?
|
var email: String?
|
||||||
let isAdmin: Bool
|
let isAdmin: Bool
|
||||||
let hasAvatar: Bool
|
let hasAvatar: Bool
|
||||||
@@ -180,12 +208,61 @@ struct UserProfile: Codable {
|
|||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, username, email
|
case id, username, email
|
||||||
|
case displayName = "display_name"
|
||||||
case isAdmin = "is_admin"
|
case isAdmin = "is_admin"
|
||||||
case hasAvatar = "has_avatar"
|
case hasAvatar = "has_avatar"
|
||||||
case totpEnabled = "totp_enabled"
|
case totpEnabled = "totp_enabled"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Sharing & groups
|
||||||
|
|
||||||
|
struct DirectoryUser: Codable, Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let displayName: String
|
||||||
|
enum CodingKeys: String, CodingKey { case id; case displayName = "display_name" }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CalendarShare: Codable, Identifiable {
|
||||||
|
let userId: Int
|
||||||
|
let displayName: String?
|
||||||
|
var permission: String
|
||||||
|
var id: Int { userId }
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case userId = "user_id"
|
||||||
|
case displayName = "display_name"
|
||||||
|
case permission
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GroupMember: Codable, Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let displayName: String?
|
||||||
|
var role: String
|
||||||
|
var color: String?
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, role, color
|
||||||
|
case displayName = "display_name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CalGroup: Codable, Identifiable {
|
||||||
|
let id: Int
|
||||||
|
var name: String
|
||||||
|
var icon: String?
|
||||||
|
var role: String?
|
||||||
|
var memberCount: Int?
|
||||||
|
var groupCalendarId: Int?
|
||||||
|
var groupCalendarColor: String?
|
||||||
|
var members: [GroupMember]?
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, icon, role, members
|
||||||
|
case memberCount = "member_count"
|
||||||
|
case groupCalendarId = "group_calendar_id"
|
||||||
|
case groupCalendarColor = "group_calendar_color"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension Color {
|
extension Color {
|
||||||
init(hex: String) {
|
init(hex: String) {
|
||||||
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||||
|
|||||||
@@ -1,6 +1,23 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Creator (or owner, in the group combined view) of an event.
|
||||||
|
/// `id` is nil for imported events.
|
||||||
|
struct EventPerson: Hashable {
|
||||||
|
let id: Int?
|
||||||
|
let displayName: String
|
||||||
|
|
||||||
|
static func from(_ json: Any?) -> EventPerson? {
|
||||||
|
guard let obj = json as? [String: Any],
|
||||||
|
let name = obj["display_name"] as? String, !name.isEmpty else { return nil }
|
||||||
|
let id: Int?
|
||||||
|
if let n = obj["id"] as? Int { id = n }
|
||||||
|
else if let s = obj["id"] as? String { id = Int(s) }
|
||||||
|
else { id = nil }
|
||||||
|
return EventPerson(id: id, displayName: name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct CalEvent: Identifiable, Hashable {
|
struct CalEvent: Identifiable, Hashable {
|
||||||
let id: String
|
let id: String
|
||||||
let url: String
|
let url: String
|
||||||
@@ -15,8 +32,15 @@ struct CalEvent: Identifiable, Hashable {
|
|||||||
var calendarName: String
|
var calendarName: String
|
||||||
var calendarColor: String
|
var calendarColor: String
|
||||||
var source: String
|
var source: String
|
||||||
|
var creator: EventPerson? = nil
|
||||||
|
var isPrivate: Bool = false
|
||||||
|
// Only set in the group combined view:
|
||||||
|
var owner: EventPerson? = nil
|
||||||
|
var isGroupEvent: Bool = false
|
||||||
|
var displayColor: String? = nil
|
||||||
|
|
||||||
var effectiveColor: String { color ?? calendarColor }
|
// Group view supplies a server-resolved colour; otherwise per-event then calendar colour.
|
||||||
|
var effectiveColor: String { displayColor ?? color ?? calendarColor }
|
||||||
|
|
||||||
static func from(json: [String: Any]) -> CalEvent? {
|
static func from(json: [String: Any]) -> CalEvent? {
|
||||||
guard
|
guard
|
||||||
@@ -49,7 +73,12 @@ struct CalEvent: Identifiable, Hashable {
|
|||||||
calendarId: json["calendar_id"].map { "\($0)" } ?? "",
|
calendarId: json["calendar_id"].map { "\($0)" } ?? "",
|
||||||
calendarName: json["calendar_name"] as? String ?? "",
|
calendarName: json["calendar_name"] as? String ?? "",
|
||||||
calendarColor: json["calendarColor"] as? String ?? "#4285f4",
|
calendarColor: json["calendarColor"] as? String ?? "#4285f4",
|
||||||
source: json["source"] as? String ?? "local"
|
source: json["source"] as? String ?? "local",
|
||||||
|
creator: EventPerson.from(json["creator"]),
|
||||||
|
isPrivate: json["private"] as? Bool ?? false,
|
||||||
|
owner: EventPerson.from(json["owner"]),
|
||||||
|
isGroupEvent: json["is_group_event"] as? Bool ?? false,
|
||||||
|
displayColor: (json["display_color"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,11 +277,13 @@ class CalendarStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Trigger a full cache reload (e.g. when cache-range setting changes).
|
/// Trigger a full cache reload (e.g. when cache-range setting changes).
|
||||||
|
/// Intentionally keeps `events` intact so the UI stays populated while
|
||||||
|
/// the network fetch runs; `refreshFromCache` will swap in fresh data
|
||||||
|
/// atomically once it arrives.
|
||||||
func invalidateCache() {
|
func invalidateCache() {
|
||||||
cachedStart = nil
|
cachedStart = nil
|
||||||
cachedEnd = nil
|
cachedEnd = nil
|
||||||
allCachedEvents = []
|
allCachedEvents = []
|
||||||
events = []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func mergeIntoCache(_ newEvents: [CalEvent], rangeStart: Date, rangeEnd: Date) {
|
private func mergeIntoCache(_ newEvents: [CalEvent], rangeStart: Date, rangeEnd: Date) {
|
||||||
|
|||||||
@@ -127,6 +127,43 @@ private let strings: [String: [String: String]] = [
|
|||||||
"settings.monday": "Montag",
|
"settings.monday": "Montag",
|
||||||
"settings.sunday": "Sonntag",
|
"settings.sunday": "Sonntag",
|
||||||
"settings.dimpast": "Vergangene Termine ausgrauen",
|
"settings.dimpast": "Vergangene Termine ausgrauen",
|
||||||
|
"settings.nav.profile": "Profil",
|
||||||
|
"settings.saved": "Gespeichert",
|
||||||
|
"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.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.member_colors": "Farben der Mitglieder",
|
||||||
|
"group.delete": "Gruppe löschen",
|
||||||
|
|
||||||
"settings.hourheight": "Stundenhöhe",
|
"settings.hourheight": "Stundenhöhe",
|
||||||
"settings.hourheight.desc": "Platz pro Stunde in der Wochen- & Tagesansicht",
|
"settings.hourheight.desc": "Platz pro Stunde in der Wochen- & Tagesansicht",
|
||||||
@@ -201,6 +238,7 @@ private let strings: [String: [String: String]] = [
|
|||||||
// Event editor
|
// Event editor
|
||||||
"event.title_placeholder": "Titel",
|
"event.title_placeholder": "Titel",
|
||||||
"event.allday": "Ganztägig",
|
"event.allday": "Ganztägig",
|
||||||
|
"event.private": "Privat",
|
||||||
"event.start": "Start",
|
"event.start": "Start",
|
||||||
"event.end": "Ende",
|
"event.end": "Ende",
|
||||||
"event.location": "Ort",
|
"event.location": "Ort",
|
||||||
@@ -213,6 +251,8 @@ private let strings: [String: [String: String]] = [
|
|||||||
"event.reset_color": "Zurücksetzen",
|
"event.reset_color": "Zurücksetzen",
|
||||||
"event.edit_title": "Termin bearbeiten",
|
"event.edit_title": "Termin bearbeiten",
|
||||||
"event.new_title": "Neuer Termin",
|
"event.new_title": "Neuer Termin",
|
||||||
|
"event.copy_title": "Termin kopieren",
|
||||||
|
"event.copy_to": "In Kalender kopieren",
|
||||||
"event.save": "Sichern",
|
"event.save": "Sichern",
|
||||||
"event.add": "Hinzufügen",
|
"event.add": "Hinzufügen",
|
||||||
|
|
||||||
@@ -386,6 +426,43 @@ private let strings: [String: [String: String]] = [
|
|||||||
"settings.monday": "Monday",
|
"settings.monday": "Monday",
|
||||||
"settings.sunday": "Sunday",
|
"settings.sunday": "Sunday",
|
||||||
"settings.dimpast": "Dim past events",
|
"settings.dimpast": "Dim past events",
|
||||||
|
"settings.nav.profile": "Profile",
|
||||||
|
"settings.saved": "Saved",
|
||||||
|
"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.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.member_colors": "Member colours",
|
||||||
|
"group.delete": "Delete group",
|
||||||
|
|
||||||
"settings.hourheight": "Hour height",
|
"settings.hourheight": "Hour height",
|
||||||
"settings.hourheight.desc": "Space per hour in week & day view",
|
"settings.hourheight.desc": "Space per hour in week & day view",
|
||||||
@@ -460,6 +537,7 @@ private let strings: [String: [String: String]] = [
|
|||||||
// Event editor
|
// Event editor
|
||||||
"event.title_placeholder": "Title",
|
"event.title_placeholder": "Title",
|
||||||
"event.allday": "All-day",
|
"event.allday": "All-day",
|
||||||
|
"event.private": "Private",
|
||||||
"event.start": "Start",
|
"event.start": "Start",
|
||||||
"event.end": "End",
|
"event.end": "End",
|
||||||
"event.location": "Location",
|
"event.location": "Location",
|
||||||
@@ -472,6 +550,8 @@ private let strings: [String: [String: String]] = [
|
|||||||
"event.reset_color": "Reset",
|
"event.reset_color": "Reset",
|
||||||
"event.edit_title": "Edit event",
|
"event.edit_title": "Edit event",
|
||||||
"event.new_title": "New event",
|
"event.new_title": "New event",
|
||||||
|
"event.copy_title": "Copy event",
|
||||||
|
"event.copy_to": "Copy to calendar",
|
||||||
"event.save": "Save",
|
"event.save": "Save",
|
||||||
"event.add": "Add",
|
"event.add": "Add",
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ class CalendarrAPI {
|
|||||||
throw APIError.decodingError
|
throw APIError.decodingError
|
||||||
}
|
}
|
||||||
let admin = user["is_admin"] as? Bool ?? false
|
let admin = user["is_admin"] as? Bool ?? false
|
||||||
|
// Persist id + display name for creator/owner comparisons and display.
|
||||||
|
UserDefaults.standard.set(user["id"] as? Int ?? 0, forKey: "userId")
|
||||||
|
UserDefaults.standard.set(user["display_name"] as? String ?? uname, forKey: "displayName")
|
||||||
return (token, uname, admin)
|
return (token, uname, admin)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +228,8 @@ class CalendarrAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date,
|
func createLocalEvent(calendarId: Int, title: String, start: Date, end: Date,
|
||||||
isAllDay: Bool, location: String, description: String, color: String?) async throws -> CalEvent {
|
isAllDay: Bool, location: String, description: String, color: String?,
|
||||||
|
isPrivate: Bool = false) async throws -> CalEvent {
|
||||||
var body: [String: Any] = [
|
var body: [String: Any] = [
|
||||||
"calendar_id": calendarId,
|
"calendar_id": calendarId,
|
||||||
"title": title,
|
"title": title,
|
||||||
@@ -233,7 +237,8 @@ class CalendarrAPI {
|
|||||||
"end": formatISO(end, allDay: isAllDay),
|
"end": formatISO(end, allDay: isAllDay),
|
||||||
"allDay": isAllDay,
|
"allDay": isAllDay,
|
||||||
"location": location,
|
"location": location,
|
||||||
"description": description
|
"description": description,
|
||||||
|
"private": isPrivate
|
||||||
]
|
]
|
||||||
if let c = color, !c.isEmpty { body["color"] = c }
|
if let c = color, !c.isEmpty { body["color"] = c }
|
||||||
let data = try await request("/api/local/events", method: "POST", body: body)
|
let data = try await request("/api/local/events", method: "POST", body: body)
|
||||||
@@ -243,14 +248,16 @@ class CalendarrAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateLocalEvent(uid: String, title: String, start: Date, end: Date,
|
func updateLocalEvent(uid: String, title: String, start: Date, end: Date,
|
||||||
isAllDay: Bool, location: String, description: String, color: String?) async throws {
|
isAllDay: Bool, location: String, description: String, color: String?,
|
||||||
|
isPrivate: Bool = false) async throws {
|
||||||
var body: [String: Any] = [
|
var body: [String: Any] = [
|
||||||
"title": title,
|
"title": title,
|
||||||
"start": formatISO(start, allDay: isAllDay),
|
"start": formatISO(start, allDay: isAllDay),
|
||||||
"end": formatISO(end, allDay: isAllDay),
|
"end": formatISO(end, allDay: isAllDay),
|
||||||
"allDay": isAllDay,
|
"allDay": isAllDay,
|
||||||
"location": location,
|
"location": location,
|
||||||
"description": description
|
"description": description,
|
||||||
|
"private": isPrivate
|
||||||
]
|
]
|
||||||
if let c = color { body["color"] = c }
|
if let c = color { body["color"] = c }
|
||||||
_ = try await request("/api/local/events/\(uid)", method: "PUT", body: body)
|
_ = try await request("/api/local/events/\(uid)", method: "PUT", body: body)
|
||||||
@@ -392,4 +399,145 @@ class CalendarrAPI {
|
|||||||
_ = try await request(path, method: "PUT",
|
_ = try await request(path, method: "PUT",
|
||||||
body: ["enabled": !hidden, "sidebar_hidden": hidden])
|
body: ["enabled": !hidden, "sidebar_hidden": hidden])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
struct AccountsView: View {
|
struct AccountsView: View {
|
||||||
let api: CalendarrAPI
|
let api: CalendarrAPI
|
||||||
@@ -16,6 +17,13 @@ struct AccountsView: View {
|
|||||||
@State private var errorAlert: String?
|
@State private var errorAlert: String?
|
||||||
@State private var banishedKeys: Set<String> = CalendarStore.loadBanishedKeys()
|
@State private var banishedKeys: Set<String> = CalendarStore.loadBanishedKeys()
|
||||||
|
|
||||||
|
// Sharing / import / export
|
||||||
|
@State private var shareCalId: Int?
|
||||||
|
@State private var showImporter = false
|
||||||
|
@State private var importTargetCalId: Int?
|
||||||
|
@State private var exportDoc: ExportedICS?
|
||||||
|
@State private var infoMessage: String?
|
||||||
|
|
||||||
@AppStorage("appLanguage") private var appLang = "system"
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -65,10 +73,55 @@ struct AccountsView: View {
|
|||||||
}, message: {
|
}, message: {
|
||||||
Text(errorAlert ?? "")
|
Text(errorAlert ?? "")
|
||||||
})
|
})
|
||||||
|
.sheet(item: Binding(get: { shareCalId.map { IdentifiableInt(id: $0) } },
|
||||||
|
set: { shareCalId = $0?.id })) { wrap in
|
||||||
|
SharingView(api: api, calendarId: wrap.id)
|
||||||
|
}
|
||||||
|
.sheet(item: $exportDoc) { doc in
|
||||||
|
ActivityView(items: [doc.url])
|
||||||
|
}
|
||||||
|
.fileImporter(isPresented: $showImporter,
|
||||||
|
allowedContentTypes: [UTType(filenameExtension: "ics") ?? .data, .data],
|
||||||
|
allowsMultipleSelection: false) { result in
|
||||||
|
Task { await handleImport(result) }
|
||||||
|
}
|
||||||
|
.alert(L10n.t("common.info", appLang), isPresented: .constant(infoMessage != nil), actions: {
|
||||||
|
Button(L10n.t("common.ok", appLang)) { infoMessage = nil }
|
||||||
|
}, message: { Text(infoMessage ?? "") })
|
||||||
}
|
}
|
||||||
.task { await load() }
|
.task { await load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func exportCalendar(_ cal: LocalCalendar) async {
|
||||||
|
do {
|
||||||
|
let data = try await api.exportICS(calendarId: cal.id)
|
||||||
|
let safe = cal.name.components(separatedBy: CharacterSet.alphanumerics.inverted).joined(separator: "_")
|
||||||
|
let url = FileManager.default.temporaryDirectory.appendingPathComponent("\(safe.isEmpty ? "calendar" : safe).ics")
|
||||||
|
try data.write(to: url)
|
||||||
|
exportDoc = ExportedICS(url: url)
|
||||||
|
} catch {
|
||||||
|
errorAlert = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleImport(_ result: Result<[URL], Error>) async {
|
||||||
|
guard let calId = importTargetCalId else { return }
|
||||||
|
switch result {
|
||||||
|
case .success(let urls):
|
||||||
|
guard let url = urls.first else { return }
|
||||||
|
let scoped = url.startAccessingSecurityScopedResource()
|
||||||
|
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
|
||||||
|
do {
|
||||||
|
let r = try await api.importICS(calendarId: calId, fileURL: url)
|
||||||
|
infoMessage = String(format: L10n.t("ics.import_result", appLang), r.imported, r.skipped)
|
||||||
|
} catch {
|
||||||
|
errorAlert = error.localizedDescription
|
||||||
|
}
|
||||||
|
case .failure(let err):
|
||||||
|
errorAlert = err.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: – Sections
|
// MARK: – Sections
|
||||||
|
|
||||||
var caldavSection: some View {
|
var caldavSection: some View {
|
||||||
@@ -114,6 +167,31 @@ struct AccountsView: View {
|
|||||||
.fill(Color(hex: cal.color))
|
.fill(Color(hex: cal.color))
|
||||||
.frame(width: 12, height: 12)
|
.frame(width: 12, height: 12)
|
||||||
Text(cal.name)
|
Text(cal.name)
|
||||||
|
if cal.group {
|
||||||
|
Image(systemName: "person.2.fill").font(.caption2).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if !cal.owned, let by = cal.sharedBy {
|
||||||
|
Text(String(format: L10n.t("accounts.shared_by", appLang), by))
|
||||||
|
.font(.caption2).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Menu {
|
||||||
|
if cal.owned && !cal.group {
|
||||||
|
Button { shareCalId = cal.id } label: {
|
||||||
|
Label(L10n.t("share.title", appLang), systemImage: "person.crop.circle.badge.plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cal.owned || cal.permission == "read_write" {
|
||||||
|
Button { importTargetCalId = cal.id; showImporter = true } label: {
|
||||||
|
Label(L10n.t("ics.import", appLang), systemImage: "square.and.arrow.down")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button { Task { await exportCalendar(cal) } } label: {
|
||||||
|
Label(L10n.t("ics.export", appLang), systemImage: "square.and.arrow.up")
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDelete { offsets in
|
.onDelete { offsets in
|
||||||
@@ -598,3 +676,102 @@ struct AddHASheet: View {
|
|||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: – Sharing / Import-Export helpers
|
||||||
|
|
||||||
|
struct IdentifiableInt: Identifiable { let id: Int }
|
||||||
|
struct ExportedICS: Identifiable { let id = UUID(); let url: URL }
|
||||||
|
|
||||||
|
/// Wraps UIActivityViewController so an exported .ics can be shared/saved.
|
||||||
|
struct ActivityView: UIViewControllerRepresentable {
|
||||||
|
let items: [Any]
|
||||||
|
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||||
|
UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||||
|
}
|
||||||
|
func updateUIViewController(_ vc: UIActivityViewController, context: Context) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manage who a local calendar is shared with (owner only).
|
||||||
|
struct SharingView: View {
|
||||||
|
let api: CalendarrAPI
|
||||||
|
let calendarId: Int
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
|
|
||||||
|
@State private var shares: [CalendarShare] = []
|
||||||
|
@State private var directory: [DirectoryUser] = []
|
||||||
|
@State private var search = ""
|
||||||
|
@State private var permission = "read"
|
||||||
|
@State private var error = ""
|
||||||
|
|
||||||
|
private var candidates: [DirectoryUser] {
|
||||||
|
let sharedIds = Set(shares.map { $0.userId })
|
||||||
|
return directory.filter { !sharedIds.contains($0.id) &&
|
||||||
|
(search.isEmpty || $0.displayName.localizedCaseInsensitiveContains(search)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section(L10n.t("share.current", appLang)) {
|
||||||
|
if shares.isEmpty {
|
||||||
|
Text(L10n.t("share.none", appLang)).foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(shares) { s in
|
||||||
|
HStack {
|
||||||
|
Text(s.displayName ?? "—")
|
||||||
|
Spacer()
|
||||||
|
Text(s.permission == "read_write"
|
||||||
|
? L10n.t("perm.read_write", appLang)
|
||||||
|
: L10n.t("perm.read", appLang))
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDelete { offsets in
|
||||||
|
Task { await removeShares(offsets) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section(L10n.t("share.add", appLang)) {
|
||||||
|
Picker(L10n.t("share.permission", appLang), selection: $permission) {
|
||||||
|
Text(L10n.t("perm.read", appLang)).tag("read")
|
||||||
|
Text(L10n.t("perm.read_write", appLang)).tag("read_write")
|
||||||
|
}
|
||||||
|
TextField(L10n.t("share.search", appLang), text: $search)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
ForEach(candidates) { u in
|
||||||
|
Button { Task { await addShare(u.id) } } label: {
|
||||||
|
HStack {
|
||||||
|
Text(u.displayName)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "plus.circle").foregroundStyle(Color.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !error.isEmpty { Section { Text(error).foregroundStyle(.red) } }
|
||||||
|
}
|
||||||
|
.navigationTitle(L10n.t("share.title", appLang))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button(L10n.t("common.done", appLang)) { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
shares = (try? await api.getShares(calendarId: calendarId)) ?? []
|
||||||
|
directory = (try? await api.getUserDirectory()) ?? []
|
||||||
|
}
|
||||||
|
private func addShare(_ userId: Int) async {
|
||||||
|
do { try await api.addShare(calendarId: calendarId, userId: userId, permission: permission); await load() }
|
||||||
|
catch { self.error = error.localizedDescription }
|
||||||
|
}
|
||||||
|
private func removeShares(_ offsets: IndexSet) async {
|
||||||
|
for i in offsets { try? await api.removeShare(calendarId: calendarId, userId: shares[i].userId) }
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
private enum CalEditorContext: Identifiable {
|
||||||
|
case create(Date)
|
||||||
|
case edit(CalEvent)
|
||||||
|
var id: String {
|
||||||
|
switch self {
|
||||||
|
case .create(let d): return "new-\(d.timeIntervalSince1970)"
|
||||||
|
case .edit(let ev): return "edit-\(ev.id)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct CalendarHostView: View {
|
struct CalendarHostView: View {
|
||||||
let api: CalendarrAPI
|
let api: CalendarrAPI
|
||||||
@Binding var showMenu: Bool
|
@Binding var showMenu: Bool
|
||||||
@@ -14,9 +25,7 @@ struct CalendarHostView: View {
|
|||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
@State private var store = CalendarStore()
|
@State private var store = CalendarStore()
|
||||||
@State private var showEditor = false
|
@State private var editorContext: CalEditorContext? = nil
|
||||||
@State private var editorDate: Date = .now
|
|
||||||
@State private var editingEvent: CalEvent? = nil
|
|
||||||
@State private var selectedEvent: CalEvent? = nil
|
@State private var selectedEvent: CalEvent? = nil
|
||||||
@State private var visibleMonth: Date = .now
|
@State private var visibleMonth: Date = .now
|
||||||
@State private var showFilter = false
|
@State private var showFilter = false
|
||||||
@@ -40,6 +49,19 @@ struct CalendarHostView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: – Loading indicator
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var loadingIndicator: some View {
|
||||||
|
if store.isLoading || store.isCachingBackground {
|
||||||
|
ProgressView()
|
||||||
|
.padding(14)
|
||||||
|
.background(.regularMaterial, in: Circle())
|
||||||
|
.shadow(color: .black.opacity(0.12), radius: 6, y: 2)
|
||||||
|
.transition(.opacity.combined(with: .scale(scale: 0.85)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: – Flat variant
|
// MARK: – Flat variant
|
||||||
|
|
||||||
private var flatVariant: some View {
|
private var flatVariant: some View {
|
||||||
@@ -51,22 +73,11 @@ struct CalendarHostView: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(Color(hex: bgHex))
|
.background(Color(hex: bgHex))
|
||||||
.overlay(alignment: .top) {
|
.overlay(alignment: .top) {
|
||||||
if store.isLoading {
|
loadingIndicator.padding(.top, 12)
|
||||||
ProgressView().padding(.top, 10).transition(.opacity)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: store.isLoading || store.isCachingBackground)
|
||||||
}
|
}
|
||||||
.overlay(alignment: .bottomTrailing) { solidFAB }
|
.overlay(alignment: .bottomTrailing) { solidFAB }
|
||||||
// Subtle background cache indicator (top-leading)
|
|
||||||
.overlay(alignment: .topLeading) {
|
|
||||||
if store.isCachingBackground {
|
|
||||||
Image(systemName: "arrow.triangle.2.circlepath")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.padding(6)
|
|
||||||
.transition(.opacity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.modifier(calendarSheets)
|
.modifier(calendarSheets)
|
||||||
.task { await startup() }
|
.task { await startup() }
|
||||||
.onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } }
|
.onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } }
|
||||||
@@ -93,10 +104,9 @@ struct CalendarHostView: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(Color(hex: bgHex))
|
.background(Color(hex: bgHex))
|
||||||
.overlay(alignment: .top) {
|
.overlay(alignment: .top) {
|
||||||
if store.isLoading {
|
loadingIndicator.padding(.top, 12)
|
||||||
ProgressView().padding(.top, 10).transition(.opacity)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: store.isLoading || store.isCachingBackground)
|
||||||
.overlay(alignment: .top) {
|
.overlay(alignment: .top) {
|
||||||
if let err = store.lastError { errorBannerView(err).padding(.top, 8) }
|
if let err = store.lastError { errorBannerView(err).padding(.top, 8) }
|
||||||
}
|
}
|
||||||
@@ -247,13 +257,9 @@ struct CalendarHostView: View {
|
|||||||
case .month:
|
case .month:
|
||||||
// Month view uses vertical scroll – no horizontal swipe.
|
// Month view uses vertical scroll – no horizontal swipe.
|
||||||
MonthView(store: store,
|
MonthView(store: store,
|
||||||
onDayTap: { editorDate = $0 },
|
onDayTap: { store.currentDate = $0 },
|
||||||
onEventTap: { selectedEvent = $0 },
|
onEventTap: { selectedEvent = $0 },
|
||||||
onCreateEvent: { day in
|
onCreateEvent: { day in editorContext = .create(day) },
|
||||||
editingEvent = nil
|
|
||||||
editorDate = day
|
|
||||||
showEditor = true
|
|
||||||
},
|
|
||||||
onShowWeek: { day in
|
onShowWeek: { day in
|
||||||
store.currentDate = day
|
store.currentDate = day
|
||||||
store.viewType = .week
|
store.viewType = .week
|
||||||
@@ -266,11 +272,7 @@ struct CalendarHostView: View {
|
|||||||
case .week:
|
case .week:
|
||||||
WeekView(store: store,
|
WeekView(store: store,
|
||||||
onEventTap: { selectedEvent = $0 },
|
onEventTap: { selectedEvent = $0 },
|
||||||
onCreateEvent: { date in
|
onCreateEvent: { date in editorContext = .create(date) },
|
||||||
editingEvent = nil
|
|
||||||
editorDate = date
|
|
||||||
showEditor = true
|
|
||||||
},
|
|
||||||
onShowMonth: { date in
|
onShowMonth: { date in
|
||||||
store.currentDate = date
|
store.currentDate = date
|
||||||
store.viewType = .month
|
store.viewType = .month
|
||||||
@@ -283,11 +285,7 @@ struct CalendarHostView: View {
|
|||||||
case .day:
|
case .day:
|
||||||
DayView(store: store,
|
DayView(store: store,
|
||||||
onEventTap: { selectedEvent = $0 },
|
onEventTap: { selectedEvent = $0 },
|
||||||
onCreateEvent: { date in
|
onCreateEvent: { date in editorContext = .create(date) })
|
||||||
editingEvent = nil
|
|
||||||
editorDate = date
|
|
||||||
showEditor = true
|
|
||||||
})
|
|
||||||
.simultaneousGesture(swipe)
|
.simultaneousGesture(swipe)
|
||||||
case .quarter:
|
case .quarter:
|
||||||
QuarterView(store: store, onEventTap: { selectedEvent = $0 })
|
QuarterView(store: store, onEventTap: { selectedEvent = $0 })
|
||||||
@@ -302,7 +300,7 @@ struct CalendarHostView: View {
|
|||||||
/// Standard solid FAB (flat mode)
|
/// Standard solid FAB (flat mode)
|
||||||
private var solidFAB: some View {
|
private var solidFAB: some View {
|
||||||
Button {
|
Button {
|
||||||
editingEvent = nil; editorDate = .now; showEditor = true
|
editorContext = .create(.now)
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
.font(.system(size: 22, weight: .semibold))
|
.font(.system(size: 22, weight: .semibold))
|
||||||
@@ -320,7 +318,7 @@ struct CalendarHostView: View {
|
|||||||
private var glassFAB: some View {
|
private var glassFAB: some View {
|
||||||
if #available(iOS 26, *) {
|
if #available(iOS 26, *) {
|
||||||
Button {
|
Button {
|
||||||
editingEvent = nil; editorDate = .now; showEditor = true
|
editorContext = .create(.now)
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
.font(.system(size: 22, weight: .semibold))
|
.font(.system(size: 22, weight: .semibold))
|
||||||
@@ -338,8 +336,7 @@ struct CalendarHostView: View {
|
|||||||
// MARK: – Sheets modifier
|
// MARK: – Sheets modifier
|
||||||
|
|
||||||
private var calendarSheets: CalendarSheets {
|
private var calendarSheets: CalendarSheets {
|
||||||
CalendarSheets(store: store, showEditor: $showEditor,
|
CalendarSheets(store: store, editorContext: $editorContext,
|
||||||
editorDate: $editorDate, editingEvent: $editingEvent,
|
|
||||||
selectedEvent: $selectedEvent, showFilter: $showFilter,
|
selectedEvent: $selectedEvent, showFilter: $showFilter,
|
||||||
api: api,
|
api: api,
|
||||||
reload: { await onNavigate() },
|
reload: { await onNavigate() },
|
||||||
@@ -437,9 +434,7 @@ struct CalendarHostView: View {
|
|||||||
|
|
||||||
private struct CalendarSheets: ViewModifier {
|
private struct CalendarSheets: ViewModifier {
|
||||||
let store: CalendarStore
|
let store: CalendarStore
|
||||||
@Binding var showEditor: Bool
|
@Binding var editorContext: CalEditorContext?
|
||||||
@Binding var editorDate: Date
|
|
||||||
@Binding var editingEvent: CalEvent?
|
|
||||||
@Binding var selectedEvent: CalEvent?
|
@Binding var selectedEvent: CalEvent?
|
||||||
@Binding var showFilter: Bool
|
@Binding var showFilter: Bool
|
||||||
let api: CalendarrAPI
|
let api: CalendarrAPI
|
||||||
@@ -448,21 +443,23 @@ private struct CalendarSheets: ViewModifier {
|
|||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
content
|
content
|
||||||
.sheet(isPresented: $showEditor) {
|
// Use sheet(item:) so the editing event is captured atomically –
|
||||||
|
// avoiding the race where sheet(isPresented:) evaluates its content
|
||||||
|
// before the editingEvent state update propagates.
|
||||||
|
.sheet(item: $editorContext) { ctx in
|
||||||
|
let editingEv: CalEvent? = { if case .edit(let ev) = ctx { return ev }; return nil }()
|
||||||
|
let date: Date = { if case .create(let d) = ctx { return d }; return .now }()
|
||||||
EventEditorSheet(api: api, store: store,
|
EventEditorSheet(api: api, store: store,
|
||||||
initialDate: editorDate, editingEvent: editingEvent) {
|
initialDate: date, editingEvent: editingEv) {
|
||||||
// Create/edit changed server state → bust the cache so the
|
editorContext = nil
|
||||||
// new/updated event appears without a manual sync.
|
await reloadForce()
|
||||||
editingEvent = nil; await reloadForce()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(item: $selectedEvent) { ev in
|
.sheet(item: $selectedEvent) { ev in
|
||||||
EventDetailSheet(event: ev, api: api, store: store) { updated in
|
EventDetailSheet(event: ev, api: api, store: store) { updated, needsForce in
|
||||||
selectedEvent = nil
|
selectedEvent = nil
|
||||||
if let u = updated { editingEvent = u; showEditor = true }
|
if let u = updated { editorContext = .edit(u) }
|
||||||
// Delete already removed the event from the cache optimistically;
|
if needsForce { await reloadForce() } else { await reload() }
|
||||||
// a light cache refresh is enough here.
|
|
||||||
await reload()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showFilter) {
|
.sheet(isPresented: $showFilter) {
|
||||||
|
|||||||
@@ -4,11 +4,16 @@ struct EventDetailSheet: View {
|
|||||||
let event: CalEvent
|
let event: CalEvent
|
||||||
let api: CalendarrAPI
|
let api: CalendarrAPI
|
||||||
let store: CalendarStore
|
let store: CalendarStore
|
||||||
let onDone: (CalEvent?) async -> Void
|
/// Called when the sheet should close.
|
||||||
|
/// - `editEvent`: non-nil when the user wants to edit this event
|
||||||
|
/// - `forceReload`: true when server data changed (create/copy) and the
|
||||||
|
/// caller must bypass the cache to fetch fresh events
|
||||||
|
let onDone: (_ editEvent: CalEvent?, _ forceReload: Bool) async -> Void
|
||||||
|
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@State private var showDeleteConfirm = false
|
@State private var showDeleteConfirm = false
|
||||||
@State private var isDeleting = false
|
@State private var isDeleting = false
|
||||||
|
@State private var showCopySheet = false
|
||||||
|
|
||||||
private let timeFmt: DateFormatter = {
|
private let timeFmt: DateFormatter = {
|
||||||
let f = DateFormatter()
|
let f = DateFormatter()
|
||||||
@@ -37,13 +42,14 @@ struct EventDetailSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var canEdit: Bool {
|
private var canEdit: Bool {
|
||||||
event.source == "local" || event.source == "caldav"
|
event.source == "local" || event.source == "caldav" || event.source == "homeassistant"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Home Assistant events can't be edited in-app (no editor support), but
|
private var canDelete: Bool { canEdit }
|
||||||
/// the server does support deleting them.
|
|
||||||
private var canDelete: Bool {
|
private var currentUserId: Int? {
|
||||||
canEdit || event.source == "homeassistant"
|
let id = UserDefaults.standard.integer(forKey: "userId")
|
||||||
|
return id == 0 ? nil : id
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -90,6 +96,28 @@ struct EventDetailSheet: View {
|
|||||||
Text(event.source.capitalized)
|
Text(event.source.capitalized)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
if let creator = event.creator, creator.id != currentUserId {
|
||||||
|
HStack {
|
||||||
|
Label("Erstellt von", systemImage: "person")
|
||||||
|
Spacer()
|
||||||
|
Text(creator.displayName)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if event.isPrivate {
|
||||||
|
Label("Privat", systemImage: "lock")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !store.writableCalendars.isEmpty {
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
showCopySheet = true
|
||||||
|
} label: {
|
||||||
|
Label("Termin kopieren", systemImage: "doc.on.doc")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if canDelete {
|
if canDelete {
|
||||||
@@ -110,13 +138,13 @@ struct EventDetailSheet: View {
|
|||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button("Schliessen") {
|
Button("Schliessen") {
|
||||||
Task { await onDone(nil) }
|
Task { await onDone(nil, false) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if canEdit {
|
if canEdit {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
Button("Bearbeiten") {
|
Button("Bearbeiten") {
|
||||||
Task { await onDone(event) }
|
Task { await onDone(event, false) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,6 +157,18 @@ struct EventDetailSheet: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text("\"\(event.title)\" wird dauerhaft gelöscht.")
|
Text("\"\(event.title)\" wird dauerhaft gelöscht.")
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showCopySheet) {
|
||||||
|
EventEditorSheet(
|
||||||
|
api: api,
|
||||||
|
store: store,
|
||||||
|
initialDate: event.startDate,
|
||||||
|
editingEvent: nil,
|
||||||
|
copyFrom: event
|
||||||
|
) {
|
||||||
|
// Copy created a new server-side event → force reload so it appears
|
||||||
|
await onDone(nil, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +189,7 @@ struct EventDetailSheet: View {
|
|||||||
// Optimistically drop it from the cache so it vanishes immediately,
|
// Optimistically drop it from the cache so it vanishes immediately,
|
||||||
// regardless of how long the source takes to propagate the delete.
|
// regardless of how long the source takes to propagate the delete.
|
||||||
store.removeCachedEvent(id: event.id)
|
store.removeCachedEvent(id: event.id)
|
||||||
await onDone(nil)
|
await onDone(nil, false)
|
||||||
} catch {
|
} catch {
|
||||||
isDeleting = false
|
isDeleting = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ struct EventEditorSheet: View {
|
|||||||
let store: CalendarStore
|
let store: CalendarStore
|
||||||
let initialDate: Date
|
let initialDate: Date
|
||||||
let editingEvent: CalEvent?
|
let editingEvent: CalEvent?
|
||||||
|
var copyFrom: CalEvent? = nil
|
||||||
let onSaved: () async -> Void
|
let onSaved: () async -> Void
|
||||||
|
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@@ -17,10 +18,12 @@ struct EventEditorSheet: View {
|
|||||||
@State private var notes = ""
|
@State private var notes = ""
|
||||||
@State private var selectedCalendarId: String = ""
|
@State private var selectedCalendarId: String = ""
|
||||||
@State private var color = ""
|
@State private var color = ""
|
||||||
|
@State private var isPrivate = false
|
||||||
@State private var isSaving = false
|
@State private var isSaving = false
|
||||||
@State private var error = ""
|
@State private var error = ""
|
||||||
|
|
||||||
private var isEditing: Bool { editingEvent != nil }
|
private var isEditing: Bool { editingEvent != nil }
|
||||||
|
private var isCopying: Bool { copyFrom != nil && editingEvent == nil }
|
||||||
|
|
||||||
private var selectedCal: WritableCalendar? {
|
private var selectedCal: WritableCalendar? {
|
||||||
store.writableCalendars.first { $0.id == selectedCalendarId }
|
store.writableCalendars.first { $0.id == selectedCalendarId }
|
||||||
@@ -73,6 +76,13 @@ struct EventEditorSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if selectedCal?.source == "local" {
|
||||||
|
Section {
|
||||||
|
Toggle(L10n.t("event.private", appLang), isOn: $isPrivate)
|
||||||
|
.tint(Color.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Section(L10n.t("event.color_section", appLang)) {
|
Section(L10n.t("event.color_section", appLang)) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(L10n.t("event.color", appLang))
|
Text(L10n.t("event.color", appLang))
|
||||||
@@ -96,9 +106,11 @@ struct EventEditorSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(isEditing
|
.navigationTitle(
|
||||||
? L10n.t("event.edit_title", appLang)
|
isEditing ? L10n.t("event.edit_title", appLang) :
|
||||||
: L10n.t("event.new_title", appLang))
|
isCopying ? L10n.t("event.copy_title", appLang) :
|
||||||
|
L10n.t("event.new_title", appLang)
|
||||||
|
)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
@@ -116,6 +128,12 @@ struct EventEditorSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear { setup() }
|
.onAppear { setup() }
|
||||||
|
.onChange(of: startDate) { oldStart, newStart in
|
||||||
|
guard newStart >= endDate else { return }
|
||||||
|
let duration = endDate.timeIntervalSince(oldStart)
|
||||||
|
let minDuration: TimeInterval = isAllDay ? 86400 : 3600
|
||||||
|
endDate = newStart.addingTimeInterval(max(duration, minDuration))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setup() {
|
private func setup() {
|
||||||
@@ -123,11 +141,33 @@ struct EventEditorSheet: View {
|
|||||||
title = ev.title
|
title = ev.title
|
||||||
isAllDay = ev.isAllDay
|
isAllDay = ev.isAllDay
|
||||||
startDate = ev.startDate
|
startDate = ev.startDate
|
||||||
endDate = ev.endDate
|
// All-day end dates are stored as exclusive (day after last); subtract 1 for the picker.
|
||||||
|
endDate = ev.isAllDay
|
||||||
|
? Calendar.current.date(byAdding: .day, value: -1, to: ev.endDate) ?? ev.endDate
|
||||||
|
: ev.endDate
|
||||||
location = ev.location
|
location = ev.location
|
||||||
notes = ev.notes
|
notes = ev.notes
|
||||||
color = ev.color ?? ""
|
color = ev.color ?? ""
|
||||||
selectedCalendarId = ev.calendarId
|
isPrivate = ev.isPrivate
|
||||||
|
// 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
|
||||||
|
selectedCalendarId = store.writableCalendars.first?.id ?? ""
|
||||||
} else {
|
} else {
|
||||||
let cal = Calendar.current
|
let cal = Calendar.current
|
||||||
startDate = cal.date(bySettingHour: cal.component(.hour, from: initialDate),
|
startDate = cal.date(bySettingHour: cal.component(.hour, from: initialDate),
|
||||||
@@ -149,10 +189,20 @@ struct EventEditorSheet: View {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
if let ev = editingEvent {
|
if let ev = editingEvent {
|
||||||
if ev.source == "local" {
|
switch ev.source {
|
||||||
|
case "local":
|
||||||
try await api.updateLocalEvent(uid: ev.id, title: title, start: start, end: end,
|
try await api.updateLocalEvent(uid: ev.id, title: title, start: start, end: end,
|
||||||
isAllDay: isAllDay, location: location, description: notes, color: colorVal)
|
isAllDay: isAllDay, location: location, description: notes, color: colorVal,
|
||||||
} else {
|
isPrivate: isPrivate)
|
||||||
|
case "homeassistant":
|
||||||
|
// No update API exists – delete the old event and recreate with new data.
|
||||||
|
let rawId = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
|
||||||
|
let haCalId = Int(rawId) ?? 0
|
||||||
|
try await api.deleteHAEvent(calendarId: haCalId, uid: ev.id)
|
||||||
|
try await api.createHAEvent(calendarId: haCalId, title: title,
|
||||||
|
start: start, end: end, isAllDay: isAllDay,
|
||||||
|
location: location, description: notes)
|
||||||
|
default: // caldav
|
||||||
let calId = Int(ev.calendarId)
|
let calId = Int(ev.calendarId)
|
||||||
try await api.updateCalDAVEvent(uid: ev.id, url: ev.url, calendarId: calId,
|
try await api.updateCalDAVEvent(uid: ev.id, url: ev.url, calendarId: calId,
|
||||||
title: title, start: start, end: end, isAllDay: isAllDay,
|
title: title, start: start, end: end, isAllDay: isAllDay,
|
||||||
@@ -163,7 +213,8 @@ struct EventEditorSheet: View {
|
|||||||
case "local":
|
case "local":
|
||||||
_ = try await api.createLocalEvent(calendarId: cal.numericId, title: title,
|
_ = try await api.createLocalEvent(calendarId: cal.numericId, title: title,
|
||||||
start: start, end: end, isAllDay: isAllDay,
|
start: start, end: end, isAllDay: isAllDay,
|
||||||
location: location, description: notes, color: colorVal)
|
location: location, description: notes, color: colorVal,
|
||||||
|
isPrivate: isPrivate)
|
||||||
case "google":
|
case "google":
|
||||||
try await api.createGoogleEvent(calendarDbId: cal.numericId, title: title,
|
try await api.createGoogleEvent(calendarDbId: cal.numericId, title: title,
|
||||||
start: start, end: end, isAllDay: isAllDay,
|
start: start, end: end, isAllDay: isAllDay,
|
||||||
|
|||||||
@@ -53,8 +53,7 @@ struct MonthView: View {
|
|||||||
headerRow
|
headerRow
|
||||||
Divider()
|
Divider()
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(spacing: 0) {
|
LazyVStack(spacing: 0) { ForEach(weekStarts, id: \.self) { ws in
|
||||||
ForEach(weekStarts, id: \.self) { ws in
|
|
||||||
WeekRow(weekStart: ws,
|
WeekRow(weekStart: ws,
|
||||||
store: store,
|
store: store,
|
||||||
dividerColor: Color(hex: dividerHex),
|
dividerColor: Color(hex: dividerHex),
|
||||||
@@ -72,6 +71,7 @@ struct MonthView: View {
|
|||||||
}
|
}
|
||||||
.scrollTargetLayout()
|
.scrollTargetLayout()
|
||||||
}
|
}
|
||||||
|
.scrollIndicators(.hidden)
|
||||||
.scrollPosition(id: $scrolledWeek, anchor: .top)
|
.scrollPosition(id: $scrolledWeek, anchor: .top)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if !didInitialScroll {
|
if !didInitialScroll {
|
||||||
|
|||||||
365
Calendarr iOS/Views/GroupsView.swift
Normal file
365
Calendarr iOS/Views/GroupsView.swift
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
Text(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 = "👥"
|
||||||
|
@State private var directory: [DirectoryUser] = []
|
||||||
|
@State private var selected: Set<Int> = []
|
||||||
|
@State private var error = ""
|
||||||
|
|
||||||
|
private let icons = ["👥", "👨👩👧", "🏠", "❤️", "🧑🤝🧑", "⚽", "🎓", "💼", "🎉", "🐶", "✈️", "🎵", "🍕", "📚", "🌳", "⭐"]
|
||||||
|
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
|
||||||
|
Text(ic).font(.title2)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.background(ic == icon ? Color.accentColor.opacity(0.25) : Color(.systemGray6))
|
||||||
|
.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 = "👥"
|
||||||
|
@State private var directory: [DirectoryUser] = []
|
||||||
|
@State private var memberIds: Set<Int> = []
|
||||||
|
@State private var showDeleteConfirm = false
|
||||||
|
@State private var error = ""
|
||||||
|
|
||||||
|
private let icons = ["👥", "👨👩👧", "🏠", "❤️", "🧑🤝🧑", "⚽", "🎓", "💼", "🎉", "🐶", "✈️", "🎵", "🍕", "📚", "🌳", "⭐"]
|
||||||
|
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
|
||||||
|
Text(ic).font(.title2)
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
.background(ic == icon ? Color.accentColor.opacity(0.25) : Color(.systemGray6))
|
||||||
|
.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 = g.icon ?? "👥"
|
||||||
|
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.icon ?? "👥") \(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() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefix others' events with their first name; group events with 👥 + creator.
|
||||||
|
private func displayTitle(_ ev: CalEvent) -> String {
|
||||||
|
let me = UserDefaults.standard.integer(forKey: "userId")
|
||||||
|
if ev.isGroupEvent {
|
||||||
|
if let c = ev.creator, c.id != me { return "👥 \(firstName(c.displayName)): \(ev.title)" }
|
||||||
|
return "👥 \(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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,12 @@ struct MenuSheet: View {
|
|||||||
Label(L10n.t("menu.accounts", appLang), systemImage: "tray.2")
|
Label(L10n.t("menu.accounts", appLang), systemImage: "tray.2")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NavigationLink {
|
||||||
|
GroupsView(api: api)
|
||||||
|
} label: {
|
||||||
|
Label(L10n.t("groups.title", appLang), systemImage: "person.2")
|
||||||
|
}
|
||||||
|
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
ServerView()
|
ServerView()
|
||||||
} label: {
|
} label: {
|
||||||
|
|||||||
@@ -23,9 +23,21 @@ struct SettingsView: View {
|
|||||||
@AppStorage("weekStartDay") private var weekStartDay = "monday"
|
@AppStorage("weekStartDay") private var weekStartDay = "monday"
|
||||||
@AppStorage("dimPastEvents") private var dimPastEvents = false
|
@AppStorage("dimPastEvents") private var dimPastEvents = false
|
||||||
|
|
||||||
|
// Profile chapter (server-backed; loaded on appear).
|
||||||
|
@State private var displayName = ""
|
||||||
|
@State private var loginName = ""
|
||||||
|
@State private var email = ""
|
||||||
|
@State private var privateVisibility = "busy"
|
||||||
|
@State private var groupVisibleId = 0 // 0 = none
|
||||||
|
@State private var ownLocalCals: [LocalCalendar] = []
|
||||||
|
@State private var profileMsg = ""
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
|
profilSection
|
||||||
|
privatsphaereSection
|
||||||
|
geteilterKalenderSection
|
||||||
liquidGlassSection
|
liquidGlassSection
|
||||||
cacheSection
|
cacheSection
|
||||||
spracheSection
|
spracheSection
|
||||||
@@ -40,6 +52,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
// Reflect the latest server values when opening the screen.
|
// Reflect the latest server values when opening the screen.
|
||||||
.task { await SettingsSync.pull(api: api) }
|
.task { await SettingsSync.pull(api: api) }
|
||||||
|
.task { await loadProfile() }
|
||||||
// Appearance changes update widgets live; synced values are also pushed
|
// Appearance changes update widgets live; synced values are also pushed
|
||||||
// to the server (debounced). `push` itself decides what actually gets
|
// to the server (debounced). `push` itself decides what actually gets
|
||||||
// sent based on the sync toggle, so every change can simply call it.
|
// sent based on the sync toggle, so every change can simply call it.
|
||||||
@@ -62,6 +75,101 @@ struct SettingsView: View {
|
|||||||
.onChange(of: settingsSync) { _, on in if on { Task { await SettingsSync.pull(api: api) } } }
|
.onChange(of: settingsSync) { _, on in if on { Task { await SettingsSync.pull(api: api) } } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: – Profil
|
||||||
|
|
||||||
|
var profilSection: some View {
|
||||||
|
Section(L10n.t("settings.nav.profile", appLang)) {
|
||||||
|
HStack {
|
||||||
|
Text(L10n.t("profile.display_name", appLang))
|
||||||
|
Spacer()
|
||||||
|
TextField(L10n.t("profile.display_name", appLang), text: $displayName)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text(L10n.t("profile.login_name", appLang))
|
||||||
|
Spacer()
|
||||||
|
Text(loginName).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("E-Mail")
|
||||||
|
Spacer()
|
||||||
|
TextField("E-Mail", text: $email)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.keyboardType(.emailAddress)
|
||||||
|
.autocapitalization(.none)
|
||||||
|
}
|
||||||
|
Button(L10n.t("event.save", appLang)) { Task { await saveProfile() } }
|
||||||
|
if !profileMsg.isEmpty {
|
||||||
|
Text(profileMsg).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Privatsphäre
|
||||||
|
|
||||||
|
var privatsphaereSection: some View {
|
||||||
|
Section {
|
||||||
|
Picker(L10n.t("settings.private_visibility", appLang), selection: $privateVisibility) {
|
||||||
|
Text(L10n.t("settings.private.busy", appLang)).tag("busy")
|
||||||
|
Text(L10n.t("settings.private.hidden", appLang)).tag("hidden")
|
||||||
|
}
|
||||||
|
.onChange(of: privateVisibility) { _, v in
|
||||||
|
Task { try? await api.updatePrivateVisibility(v) }
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text(L10n.t("settings.privacy", appLang))
|
||||||
|
} footer: {
|
||||||
|
Text(L10n.t("settings.private_visibility.desc", appLang)).font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: – Geteilter Kalender
|
||||||
|
|
||||||
|
var geteilterKalenderSection: some View {
|
||||||
|
Section {
|
||||||
|
Picker(L10n.t("settings.group_visible", appLang), selection: $groupVisibleId) {
|
||||||
|
Text(L10n.t("group.visible.none", appLang)).tag(0)
|
||||||
|
ForEach(ownLocalCals) { cal in
|
||||||
|
Text(cal.name).tag(cal.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: groupVisibleId) { _, id in
|
||||||
|
Task { try? await api.updateGroupVisibleCalendar(id == 0 ? nil : id) }
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text(L10n.t("settings.calendars", appLang))
|
||||||
|
} footer: {
|
||||||
|
Text(L10n.t("settings.group_visible.desc", appLang)).font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadProfile() async {
|
||||||
|
if let p = try? await api.getProfile() {
|
||||||
|
displayName = p.displayName ?? p.username
|
||||||
|
loginName = p.username
|
||||||
|
email = p.email ?? ""
|
||||||
|
}
|
||||||
|
if let s = try? await api.getSettings() {
|
||||||
|
privateVisibility = s.privateEventVisibility
|
||||||
|
groupVisibleId = s.groupVisibleCalendarId ?? 0
|
||||||
|
}
|
||||||
|
if let cals = try? await api.getLocalCalendars() {
|
||||||
|
ownLocalCals = cals.filter { $0.owned && !$0.group }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveProfile() async {
|
||||||
|
do {
|
||||||
|
_ = try await api.updateProfile(displayName: displayName.isEmpty ? nil : displayName,
|
||||||
|
username: nil,
|
||||||
|
email: email.isEmpty ? "" : email)
|
||||||
|
UserDefaults.standard.set(displayName, forKey: "displayName")
|
||||||
|
profileMsg = L10n.t("settings.saved", appLang)
|
||||||
|
} catch {
|
||||||
|
profileMsg = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: – Liquid Glass
|
// MARK: – Liquid Glass
|
||||||
|
|
||||||
var liquidGlassSection: some View {
|
var liquidGlassSection: some View {
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,10 +11,16 @@ struct CalendarrWidgetBundle: WidgetBundle {
|
|||||||
TwoWeeksWidget()
|
TwoWeeksWidget()
|
||||||
UpcomingWidget()
|
UpcomingWidget()
|
||||||
UpNextWidget()
|
UpNextWidget()
|
||||||
|
CalendarDayWidget()
|
||||||
|
TwoMonthWidget()
|
||||||
|
NowNextEventsWidget()
|
||||||
|
LockScreenWidget()
|
||||||
|
LockScreenCountWidget()
|
||||||
|
LockScreenCountdownWidget()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared chrome modifier — keeps every widget on the same theme.
|
// Shared chrome modifier — keeps every home-screen widget on the same theme.
|
||||||
private struct CalendarrWidgetChrome: ViewModifier {
|
private struct CalendarrWidgetChrome: ViewModifier {
|
||||||
let snapshot: WidgetSnapshot?
|
let snapshot: WidgetSnapshot?
|
||||||
|
|
||||||
@@ -139,3 +145,99 @@ struct UpNextWidget: Widget {
|
|||||||
.supportedFamilies([.systemMedium])
|
.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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ struct ThisWeekWidgetView: View {
|
|||||||
private var cal: Calendar {
|
private var cal: Calendar {
|
||||||
var c = Calendar(identifier: .gregorian)
|
var c = Calendar(identifier: .gregorian)
|
||||||
c.locale = WidgetL10n.locale(lang)
|
c.locale = WidgetL10n.locale(lang)
|
||||||
c.firstWeekday = 2 // Monday
|
c.firstWeekday = 2
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ struct ThisWeekWidgetView: View {
|
|||||||
if let s = snapshot {
|
if let s = snapshot {
|
||||||
let primary = Color(widgetHex: s.primaryColorHex)
|
let primary = Color(widgetHex: s.primaryColorHex)
|
||||||
let accent = Color(widgetHex: s.accentColorHex)
|
let accent = Color(widgetHex: s.accentColorHex)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||||
Text(monthHeader.split(separator: " ").first.map(String.init) ?? "")
|
Text(monthHeader.split(separator: " ").first.map(String.init) ?? "")
|
||||||
.font(.system(size: 10, weight: .bold))
|
.font(.system(size: 10, weight: .bold))
|
||||||
@@ -48,22 +48,21 @@ struct ThisWeekWidgetView: View {
|
|||||||
Text(monthHeader.split(separator: " ").dropFirst().joined(separator: " "))
|
Text(monthHeader.split(separator: " ").dropFirst().joined(separator: " "))
|
||||||
.font(.system(size: 10, weight: .semibold))
|
.font(.system(size: 10, weight: .semibold))
|
||||||
}
|
}
|
||||||
GeometryReader { geo in
|
// Equal-width columns via maxWidth — no GeometryReader needed
|
||||||
let colW = geo.size.width / 7
|
HStack(spacing: 0) {
|
||||||
HStack(spacing: 0) {
|
ForEach(Array(weekDays.enumerated()), id: \.offset) { idx, day in
|
||||||
ForEach(Array(weekDays.enumerated()), id: \.offset) { idx, day in
|
dayColumn(day, snapshot: s, primary: primary, accent: accent)
|
||||||
dayColumn(day, snapshot: s, primary: primary, accent: accent)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
.frame(width: colW)
|
.overlay(alignment: .trailing) {
|
||||||
.overlay(alignment: .trailing) {
|
if idx < 6 {
|
||||||
if idx < 6 {
|
Rectangle()
|
||||||
Rectangle()
|
.fill(Color(widgetHex: s.lineColorHex).opacity(0.35))
|
||||||
.fill(Color(widgetHex: s.lineColorHex).opacity(0.35))
|
.frame(width: 0.5)
|
||||||
.frame(width: 0.5)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text(WidgetL10n.t("widget.no_data", lang))
|
Text(WidgetL10n.t("widget.no_data", lang))
|
||||||
@@ -80,22 +79,22 @@ struct ThisWeekWidgetView: View {
|
|||||||
let isToday = cal.isDateInToday(day)
|
let isToday = cal.isDateInToday(day)
|
||||||
let evs = WidgetHelpers.events(for: day, in: snapshot)
|
let evs = WidgetHelpers.events(for: day, in: snapshot)
|
||||||
let dayIdx = (0..<7).firstIndex { i in cal.isDate(weekDays[i], inSameDayAs: day) } ?? 0
|
let dayIdx = (0..<7).firstIndex { i in cal.isDate(weekDays[i], inSameDayAs: day) } ?? 0
|
||||||
return VStack(spacing: 1) {
|
return VStack(alignment: .center, spacing: 1) {
|
||||||
Text(weekdayHeaders[dayIdx])
|
Text(weekdayHeaders[dayIdx])
|
||||||
.font(.system(size: 7.5, weight: .bold))
|
.font(.system(size: 7.5, weight: .bold))
|
||||||
.foregroundStyle(isToday ? accent : .secondary)
|
.foregroundStyle(isToday ? accent : .secondary)
|
||||||
Text("\(cal.component(.day, from: day))")
|
Text("\(cal.component(.day, from: day))")
|
||||||
.font(.system(size: 10, weight: isToday ? .bold : .semibold))
|
.font(.system(size: 10, weight: isToday ? .bold : .semibold))
|
||||||
.foregroundStyle(isToday ? Color.white : Color.primary)
|
.foregroundStyle(isToday ? Color.white : Color.primary)
|
||||||
.frame(width: 15, height: 15)
|
.frame(width: 16, height: 16)
|
||||||
.background(isToday ? primary : Color.clear)
|
.background(isToday ? primary : Color.clear)
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
ForEach(evs.prefix(2)) { ev in
|
ForEach(evs.prefix(3)) { ev in
|
||||||
eventPill(ev)
|
eventPill(ev)
|
||||||
}
|
}
|
||||||
if evs.count > 2 {
|
if evs.count > 3 {
|
||||||
Text("+\(evs.count - 2)")
|
Text("+\(evs.count - 3)")
|
||||||
.font(.system(size: 7))
|
.font(.system(size: 6.5))
|
||||||
.foregroundStyle(accent)
|
.foregroundStyle(accent)
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,20 +48,35 @@ enum WidgetL10n {
|
|||||||
"widget.more": "+%d weitere",
|
"widget.more": "+%d weitere",
|
||||||
"widget.upcoming": "Nächste 5 Tage",
|
"widget.upcoming": "Nächste 5 Tage",
|
||||||
"widget.no_data": "Keine Daten – App einmal öffnen",
|
"widget.no_data": "Keine Daten – App einmal öffnen",
|
||||||
"widget.display.today_title": "Heute",
|
"widget.display.today_title": "Heute",
|
||||||
"widget.display.today_desc": "Heutige Termine auf einen Blick.",
|
"widget.display.today_desc": "Heutige Termine auf einen Blick.",
|
||||||
"widget.display.days_title": "Heute & Morgen",
|
"widget.display.days_title": "Heute & Morgen",
|
||||||
"widget.display.days_desc": "Termine der nächsten zwei Tage.",
|
"widget.display.days_desc": "Termine der nächsten zwei Tage.",
|
||||||
"widget.display.upcoming_title": "Nächste 5 Tage",
|
"widget.display.upcoming_title": "Nächste 5 Tage",
|
||||||
"widget.display.upcoming_desc": "Termine der nächsten 5 Tage.",
|
"widget.display.upcoming_desc": "Termine der nächsten 5 Tage.",
|
||||||
"widget.display.thisweek_title": "Diese Woche",
|
"widget.display.thisweek_title": "Diese Woche",
|
||||||
"widget.display.thisweek_desc": "Wochenraster mit Terminen.",
|
"widget.display.thisweek_desc": "Wochenraster mit Terminen.",
|
||||||
"widget.display.twoweeks_title": "Zwei Wochen",
|
"widget.display.twoweeks_title": "Zwei Wochen",
|
||||||
"widget.display.twoweeks_desc": "Zwei-Wochen-Raster mit Terminen.",
|
"widget.display.twoweeks_desc": "Zwei-Wochen-Raster mit Terminen.",
|
||||||
"widget.display.threedays_title": "Drei Tage",
|
"widget.display.threedays_title": "Drei Tage",
|
||||||
"widget.display.threedays_desc": "Drei-Tages-Ansicht mit Terminen.",
|
"widget.display.threedays_desc": "Drei-Tages-Ansicht mit Terminen.",
|
||||||
"widget.display.upnext_title": "Up Next + Kalender",
|
"widget.display.upnext_title": "Up Next + Kalender",
|
||||||
"widget.display.upnext_desc": "Nächste Termine mit Monatsübersicht."
|
"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": [
|
"en": [
|
||||||
"widget.today": "Today",
|
"widget.today": "Today",
|
||||||
@@ -71,20 +86,35 @@ enum WidgetL10n {
|
|||||||
"widget.more": "+%d more",
|
"widget.more": "+%d more",
|
||||||
"widget.upcoming": "Next 5 days",
|
"widget.upcoming": "Next 5 days",
|
||||||
"widget.no_data": "No data – open the app once",
|
"widget.no_data": "No data – open the app once",
|
||||||
"widget.display.today_title": "Today",
|
"widget.display.today_title": "Today",
|
||||||
"widget.display.today_desc": "Today's events at a glance.",
|
"widget.display.today_desc": "Today's events at a glance.",
|
||||||
"widget.display.days_title": "Today & tomorrow",
|
"widget.display.days_title": "Today & tomorrow",
|
||||||
"widget.display.days_desc": "Events for the next two days.",
|
"widget.display.days_desc": "Events for the next two days.",
|
||||||
"widget.display.upcoming_title": "Next 5 days",
|
"widget.display.upcoming_title": "Next 5 days",
|
||||||
"widget.display.upcoming_desc": "Events for the next 5 days.",
|
"widget.display.upcoming_desc": "Events for the next 5 days.",
|
||||||
"widget.display.thisweek_title": "This Week",
|
"widget.display.thisweek_title": "This Week",
|
||||||
"widget.display.thisweek_desc": "Week grid with events.",
|
"widget.display.thisweek_desc": "Week grid with events.",
|
||||||
"widget.display.twoweeks_title": "Two Weeks",
|
"widget.display.twoweeks_title": "Two Weeks",
|
||||||
"widget.display.twoweeks_desc": "Two-week grid with events.",
|
"widget.display.twoweeks_desc": "Two-week grid with events.",
|
||||||
"widget.display.threedays_title": "Three Days",
|
"widget.display.threedays_title": "Three Days",
|
||||||
"widget.display.threedays_desc": "Three-day view with events.",
|
"widget.display.threedays_desc": "Three-day view with events.",
|
||||||
"widget.display.upnext_title": "Up Next + Calendar",
|
"widget.display.upnext_title": "Up Next + Calendar",
|
||||||
"widget.display.upnext_desc": "Next events with month overview."
|
"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."
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user