WIP: Widget-, Sync- & Event-Editor-Änderungen
Zwischenstand vor den Sharing/Gruppen/Import-Export-Features (gesichert, damit die neuen Features sauber darauf aufbauen).
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -213,6 +213,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",
|
||||||
|
|
||||||
@@ -472,6 +474,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",
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -42,14 +42,10 @@ 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 {
|
|
||||||
canEdit || event.source == "homeassistant"
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ struct EventEditorSheet: View {
|
|||||||
let store: CalendarStore
|
let store: CalendarStore
|
||||||
let initialDate: Date
|
let initialDate: Date
|
||||||
let editingEvent: CalEvent?
|
let editingEvent: CalEvent?
|
||||||
let copyFrom: CalEvent?
|
var copyFrom: CalEvent? = nil
|
||||||
let onSaved: () async -> Void
|
let onSaved: () async -> Void
|
||||||
|
|
||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@@ -133,16 +133,27 @@ 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 ?? ""
|
||||||
|
// 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
|
selectedCalendarId = ev.calendarId
|
||||||
|
}
|
||||||
} else if let ev = copyFrom {
|
} else if let ev = copyFrom {
|
||||||
title = ev.title
|
title = ev.title
|
||||||
isAllDay = ev.isAllDay
|
isAllDay = ev.isAllDay
|
||||||
startDate = ev.startDate
|
startDate = ev.startDate
|
||||||
endDate = ev.endDate
|
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 ?? ""
|
||||||
@@ -168,10 +179,19 @@ 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 {
|
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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ struct CalendarrWidgetBundle: WidgetBundle {
|
|||||||
UpcomingWidget()
|
UpcomingWidget()
|
||||||
UpNextWidget()
|
UpNextWidget()
|
||||||
CalendarDayWidget()
|
CalendarDayWidget()
|
||||||
|
TwoMonthWidget()
|
||||||
|
NowNextEventsWidget()
|
||||||
LockScreenWidget()
|
LockScreenWidget()
|
||||||
|
LockScreenCountWidget()
|
||||||
|
LockScreenCountdownWidget()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +161,37 @@ struct CalendarDayWidget: Widget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: – Lock Screen (circular, rectangular, inline)
|
// 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 {
|
struct LockScreenWidget: Widget {
|
||||||
let kind: String = "LockScreenWidget"
|
let kind: String = "LockScreenWidget"
|
||||||
@@ -173,3 +207,37 @@ struct LockScreenWidget: Widget {
|
|||||||
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
|
.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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
|
|
||||||
|
// MARK: – Date widget (existing)
|
||||||
|
|
||||||
struct LockScreenWidgetView: View {
|
struct LockScreenWidgetView: View {
|
||||||
let entry: CalendarrEntry
|
let entry: CalendarrEntry
|
||||||
@Environment(\.widgetFamily) private var family
|
@Environment(\.widgetFamily) private var family
|
||||||
@@ -97,3 +99,190 @@ struct LockScreenWidgetView: View {
|
|||||||
return Label(text, systemImage: "calendar")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ struct NowNextWidgetView: View {
|
|||||||
|
|
||||||
private func featuredCard(snapshot: WidgetSnapshot) -> some View {
|
private func featuredCard(snapshot: WidgetSnapshot) -> some View {
|
||||||
let ev = featuredEvent
|
let ev = featuredEvent
|
||||||
let baseColor = ev.map { Color(widgetHex: $0.colorHex) } ?? Color.accentColor.opacity(0.5)
|
let baseColor = ev.map { Color(widgetHex: $0.colorHex) } ?? Color(widgetHex: snapshot.primaryColorHex)
|
||||||
|
|
||||||
return ZStack(alignment: .leading) {
|
return ZStack(alignment: .leading) {
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
|
|||||||
@@ -64,8 +64,19 @@ enum WidgetL10n {
|
|||||||
"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_title": "Tag & Termine",
|
||||||
"widget.display.calday_desc": "Datum, Wochenübersicht und nächste Termine.",
|
"widget.display.calday_desc": "Datum, Wochenübersicht und nächste Termine.",
|
||||||
"widget.display.lockscreen_title": "Sperrbildschirm",
|
"widget.display.lockscreen_title": "Datum",
|
||||||
"widget.display.lockscreen_desc": "Datum und nächster Termin auf dem Sperrbildschirm."
|
"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",
|
||||||
@@ -91,8 +102,19 @@ enum WidgetL10n {
|
|||||||
"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_title": "Day & Events",
|
||||||
"widget.display.calday_desc": "Date, week overview and upcoming events.",
|
"widget.display.calday_desc": "Date, week overview and upcoming events.",
|
||||||
"widget.display.lockscreen_title": "Lock Screen",
|
"widget.display.lockscreen_title": "Date",
|
||||||
"widget.display.lockscreen_desc": "Date and next event on the lock screen."
|
"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