Widget änderungen, sync änderungen
This commit is contained in:
@@ -4,11 +4,16 @@ struct EventDetailSheet: View {
|
||||
let event: CalEvent
|
||||
let api: CalendarrAPI
|
||||
let store: CalendarStore
|
||||
let onDone: (CalEvent?) async -> Void
|
||||
/// Called when the sheet should close.
|
||||
/// - `editEvent`: non-nil when the user wants to edit this event
|
||||
/// - `forceReload`: true when server data changed (create/copy) and the
|
||||
/// caller must bypass the cache to fetch fresh events
|
||||
let onDone: (_ editEvent: CalEvent?, _ forceReload: Bool) async -> Void
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var showDeleteConfirm = false
|
||||
@State private var isDeleting = false
|
||||
@State private var showCopySheet = false
|
||||
|
||||
private let timeFmt: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
@@ -92,6 +97,16 @@ struct EventDetailSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
if !store.writableCalendars.isEmpty {
|
||||
Section {
|
||||
Button {
|
||||
showCopySheet = true
|
||||
} label: {
|
||||
Label("Termin kopieren", systemImage: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if canDelete {
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
@@ -110,13 +125,13 @@ struct EventDetailSheet: View {
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Schliessen") {
|
||||
Task { await onDone(nil) }
|
||||
Task { await onDone(nil, false) }
|
||||
}
|
||||
}
|
||||
if canEdit {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("Bearbeiten") {
|
||||
Task { await onDone(event) }
|
||||
Task { await onDone(event, false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,6 +144,18 @@ struct EventDetailSheet: View {
|
||||
} message: {
|
||||
Text("\"\(event.title)\" wird dauerhaft gelöscht.")
|
||||
}
|
||||
.sheet(isPresented: $showCopySheet) {
|
||||
EventEditorSheet(
|
||||
api: api,
|
||||
store: store,
|
||||
initialDate: event.startDate,
|
||||
editingEvent: nil,
|
||||
copyFrom: event
|
||||
) {
|
||||
// Copy created a new server-side event → force reload so it appears
|
||||
await onDone(nil, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +176,7 @@ struct EventDetailSheet: View {
|
||||
// Optimistically drop it from the cache so it vanishes immediately,
|
||||
// regardless of how long the source takes to propagate the delete.
|
||||
store.removeCachedEvent(id: event.id)
|
||||
await onDone(nil)
|
||||
await onDone(nil, false)
|
||||
} catch {
|
||||
isDeleting = false
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ struct EventEditorSheet: View {
|
||||
let store: CalendarStore
|
||||
let initialDate: Date
|
||||
let editingEvent: CalEvent?
|
||||
let copyFrom: CalEvent?
|
||||
let onSaved: () async -> Void
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@@ -21,6 +22,7 @@ struct EventEditorSheet: View {
|
||||
@State private var error = ""
|
||||
|
||||
private var isEditing: Bool { editingEvent != nil }
|
||||
private var isCopying: Bool { copyFrom != nil && editingEvent == nil }
|
||||
|
||||
private var selectedCal: WritableCalendar? {
|
||||
store.writableCalendars.first { $0.id == selectedCalendarId }
|
||||
@@ -96,9 +98,11 @@ struct EventEditorSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(isEditing
|
||||
? L10n.t("event.edit_title", appLang)
|
||||
: L10n.t("event.new_title", appLang))
|
||||
.navigationTitle(
|
||||
isEditing ? L10n.t("event.edit_title", appLang) :
|
||||
isCopying ? L10n.t("event.copy_title", appLang) :
|
||||
L10n.t("event.new_title", appLang)
|
||||
)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
@@ -116,6 +120,12 @@ struct EventEditorSheet: View {
|
||||
}
|
||||
}
|
||||
.onAppear { setup() }
|
||||
.onChange(of: startDate) { oldStart, newStart in
|
||||
guard newStart >= endDate else { return }
|
||||
let duration = endDate.timeIntervalSince(oldStart)
|
||||
let minDuration: TimeInterval = isAllDay ? 86400 : 3600
|
||||
endDate = newStart.addingTimeInterval(max(duration, minDuration))
|
||||
}
|
||||
}
|
||||
|
||||
private func setup() {
|
||||
@@ -128,6 +138,15 @@ struct EventEditorSheet: View {
|
||||
notes = ev.notes
|
||||
color = ev.color ?? ""
|
||||
selectedCalendarId = ev.calendarId
|
||||
} else if let ev = copyFrom {
|
||||
title = ev.title
|
||||
isAllDay = ev.isAllDay
|
||||
startDate = ev.startDate
|
||||
endDate = ev.endDate
|
||||
location = ev.location
|
||||
notes = ev.notes
|
||||
color = ev.color ?? ""
|
||||
selectedCalendarId = store.writableCalendars.first?.id ?? ""
|
||||
} else {
|
||||
let cal = Calendar.current
|
||||
startDate = cal.date(bySettingHour: cal.component(.hour, from: initialDate),
|
||||
|
||||
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,12 @@ struct CalendarrWidgetBundle: WidgetBundle {
|
||||
TwoWeeksWidget()
|
||||
UpcomingWidget()
|
||||
UpNextWidget()
|
||||
CalendarDayWidget()
|
||||
LockScreenWidget()
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
let snapshot: WidgetSnapshot?
|
||||
|
||||
@@ -139,3 +141,35 @@ struct UpNextWidget: Widget {
|
||||
.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: – Lock Screen (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])
|
||||
}
|
||||
}
|
||||
|
||||
99
CalendarrWidgets/LockScreenWidgetViews.swift
Normal file
99
CalendarrWidgets/LockScreenWidgetViews.swift
Normal file
@@ -0,0 +1,99 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
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.accentColor.opacity(0.5)
|
||||
|
||||
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 {
|
||||
var c = Calendar(identifier: .gregorian)
|
||||
c.locale = WidgetL10n.locale(lang)
|
||||
c.firstWeekday = 2 // Monday
|
||||
c.firstWeekday = 2
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ struct ThisWeekWidgetView: View {
|
||||
if let s = snapshot {
|
||||
let primary = Color(widgetHex: s.primaryColorHex)
|
||||
let accent = Color(widgetHex: s.accentColorHex)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text(monthHeader.split(separator: " ").first.map(String.init) ?? "")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
@@ -48,22 +48,21 @@ struct ThisWeekWidgetView: View {
|
||||
Text(monthHeader.split(separator: " ").dropFirst().joined(separator: " "))
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
}
|
||||
GeometryReader { geo in
|
||||
let colW = geo.size.width / 7
|
||||
HStack(spacing: 0) {
|
||||
ForEach(Array(weekDays.enumerated()), id: \.offset) { idx, day in
|
||||
dayColumn(day, snapshot: s, primary: primary, accent: accent)
|
||||
.frame(width: colW)
|
||||
.overlay(alignment: .trailing) {
|
||||
if idx < 6 {
|
||||
Rectangle()
|
||||
.fill(Color(widgetHex: s.lineColorHex).opacity(0.35))
|
||||
.frame(width: 0.5)
|
||||
}
|
||||
// Equal-width columns via maxWidth — no GeometryReader needed
|
||||
HStack(spacing: 0) {
|
||||
ForEach(Array(weekDays.enumerated()), id: \.offset) { idx, day in
|
||||
dayColumn(day, snapshot: s, primary: primary, accent: accent)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.overlay(alignment: .trailing) {
|
||||
if idx < 6 {
|
||||
Rectangle()
|
||||
.fill(Color(widgetHex: s.lineColorHex).opacity(0.35))
|
||||
.frame(width: 0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
} else {
|
||||
Text(WidgetL10n.t("widget.no_data", lang))
|
||||
@@ -80,22 +79,22 @@ struct ThisWeekWidgetView: View {
|
||||
let isToday = cal.isDateInToday(day)
|
||||
let evs = WidgetHelpers.events(for: day, in: snapshot)
|
||||
let dayIdx = (0..<7).firstIndex { i in cal.isDate(weekDays[i], inSameDayAs: day) } ?? 0
|
||||
return VStack(spacing: 1) {
|
||||
return VStack(alignment: .center, spacing: 1) {
|
||||
Text(weekdayHeaders[dayIdx])
|
||||
.font(.system(size: 7.5, weight: .bold))
|
||||
.foregroundStyle(isToday ? accent : .secondary)
|
||||
Text("\(cal.component(.day, from: day))")
|
||||
.font(.system(size: 10, weight: isToday ? .bold : .semibold))
|
||||
.foregroundStyle(isToday ? Color.white : Color.primary)
|
||||
.frame(width: 15, height: 15)
|
||||
.frame(width: 16, height: 16)
|
||||
.background(isToday ? primary : Color.clear)
|
||||
.clipShape(Circle())
|
||||
ForEach(evs.prefix(2)) { ev in
|
||||
ForEach(evs.prefix(3)) { ev in
|
||||
eventPill(ev)
|
||||
}
|
||||
if evs.count > 2 {
|
||||
Text("+\(evs.count - 2)")
|
||||
.font(.system(size: 7))
|
||||
if evs.count > 3 {
|
||||
Text("+\(evs.count - 3)")
|
||||
.font(.system(size: 6.5))
|
||||
.foregroundStyle(accent)
|
||||
}
|
||||
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,24 @@ enum WidgetL10n {
|
||||
"widget.more": "+%d weitere",
|
||||
"widget.upcoming": "Nächste 5 Tage",
|
||||
"widget.no_data": "Keine Daten – App einmal öffnen",
|
||||
"widget.display.today_title": "Heute",
|
||||
"widget.display.today_desc": "Heutige Termine auf einen Blick.",
|
||||
"widget.display.days_title": "Heute & Morgen",
|
||||
"widget.display.days_desc": "Termine der nächsten zwei Tage.",
|
||||
"widget.display.upcoming_title": "Nächste 5 Tage",
|
||||
"widget.display.upcoming_desc": "Termine der nächsten 5 Tage.",
|
||||
"widget.display.thisweek_title": "Diese Woche",
|
||||
"widget.display.thisweek_desc": "Wochenraster mit Terminen.",
|
||||
"widget.display.twoweeks_title": "Zwei Wochen",
|
||||
"widget.display.twoweeks_desc": "Zwei-Wochen-Raster mit Terminen.",
|
||||
"widget.display.threedays_title": "Drei Tage",
|
||||
"widget.display.threedays_desc": "Drei-Tages-Ansicht mit Terminen.",
|
||||
"widget.display.upnext_title": "Up Next + Kalender",
|
||||
"widget.display.upnext_desc": "Nächste Termine mit Monatsübersicht."
|
||||
"widget.display.today_title": "Heute",
|
||||
"widget.display.today_desc": "Heutige Termine auf einen Blick.",
|
||||
"widget.display.days_title": "Heute & Morgen",
|
||||
"widget.display.days_desc": "Termine der nächsten zwei Tage.",
|
||||
"widget.display.upcoming_title": "Nächste 5 Tage",
|
||||
"widget.display.upcoming_desc": "Termine der nächsten 5 Tage.",
|
||||
"widget.display.thisweek_title": "Diese Woche",
|
||||
"widget.display.thisweek_desc": "Wochenraster mit Terminen.",
|
||||
"widget.display.twoweeks_title": "Zwei Wochen",
|
||||
"widget.display.twoweeks_desc": "Zwei-Wochen-Raster mit Terminen.",
|
||||
"widget.display.threedays_title": "Drei Tage",
|
||||
"widget.display.threedays_desc": "Drei-Tages-Ansicht mit Terminen.",
|
||||
"widget.display.upnext_title": "Up Next + Kalender",
|
||||
"widget.display.upnext_desc": "Nächste Termine mit Monatsübersicht.",
|
||||
"widget.display.calday_title": "Tag & Termine",
|
||||
"widget.display.calday_desc": "Datum, Wochenübersicht und nächste Termine.",
|
||||
"widget.display.lockscreen_title": "Sperrbildschirm",
|
||||
"widget.display.lockscreen_desc": "Datum und nächster Termin auf dem Sperrbildschirm."
|
||||
],
|
||||
"en": [
|
||||
"widget.today": "Today",
|
||||
@@ -71,20 +75,24 @@ enum WidgetL10n {
|
||||
"widget.more": "+%d more",
|
||||
"widget.upcoming": "Next 5 days",
|
||||
"widget.no_data": "No data – open the app once",
|
||||
"widget.display.today_title": "Today",
|
||||
"widget.display.today_desc": "Today's events at a glance.",
|
||||
"widget.display.days_title": "Today & tomorrow",
|
||||
"widget.display.days_desc": "Events for the next two days.",
|
||||
"widget.display.upcoming_title": "Next 5 days",
|
||||
"widget.display.upcoming_desc": "Events for the next 5 days.",
|
||||
"widget.display.thisweek_title": "This Week",
|
||||
"widget.display.thisweek_desc": "Week grid with events.",
|
||||
"widget.display.twoweeks_title": "Two Weeks",
|
||||
"widget.display.twoweeks_desc": "Two-week grid with events.",
|
||||
"widget.display.threedays_title": "Three Days",
|
||||
"widget.display.threedays_desc": "Three-day view with events.",
|
||||
"widget.display.upnext_title": "Up Next + Calendar",
|
||||
"widget.display.upnext_desc": "Next events with month overview."
|
||||
"widget.display.today_title": "Today",
|
||||
"widget.display.today_desc": "Today's events at a glance.",
|
||||
"widget.display.days_title": "Today & tomorrow",
|
||||
"widget.display.days_desc": "Events for the next two days.",
|
||||
"widget.display.upcoming_title": "Next 5 days",
|
||||
"widget.display.upcoming_desc": "Events for the next 5 days.",
|
||||
"widget.display.thisweek_title": "This Week",
|
||||
"widget.display.thisweek_desc": "Week grid with events.",
|
||||
"widget.display.twoweeks_title": "Two Weeks",
|
||||
"widget.display.twoweeks_desc": "Two-week grid with events.",
|
||||
"widget.display.threedays_title": "Three Days",
|
||||
"widget.display.threedays_desc": "Three-day view with events.",
|
||||
"widget.display.upnext_title": "Up Next + Calendar",
|
||||
"widget.display.upnext_desc": "Next events with month overview.",
|
||||
"widget.display.calday_title": "Day & Events",
|
||||
"widget.display.calday_desc": "Date, week overview and upcoming events.",
|
||||
"widget.display.lockscreen_title": "Lock Screen",
|
||||
"widget.display.lockscreen_desc": "Date and next event on the lock screen."
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user