Widget anpassung vorbereitung

This commit is contained in:
Scarriffle
2026-05-25 11:53:02 +02:00
parent d1004a9111
commit 6c506770ba
21 changed files with 1838 additions and 3 deletions

View File

@@ -0,0 +1,53 @@
import WidgetKit
struct CalendarrEntry: TimelineEntry {
let date: Date
let snapshot: WidgetSnapshot?
}
struct CalendarrTimelineProvider: TimelineProvider {
func placeholder(in context: Context) -> CalendarrEntry {
CalendarrEntry(date: .now, snapshot: WidgetStore.read())
}
func getSnapshot(in context: Context, completion: @escaping (CalendarrEntry) -> Void) {
completion(CalendarrEntry(date: .now, snapshot: WidgetStore.read()))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<CalendarrEntry>) -> Void) {
let snapshot = WidgetStore.read()
let now = Date()
// Provide one entry per hour for the next 24h so the widget keeps
// re-rendering as time progresses (past events drop off, "now" advances).
var entries: [CalendarrEntry] = []
for h in 0..<24 {
let date = Calendar.current.date(byAdding: .hour, value: h, to: now) ?? now
entries.append(CalendarrEntry(date: date, snapshot: snapshot))
}
// Ask iOS to refresh in 30 min to pick up any new data the app wrote.
let refreshAt = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now
completion(Timeline(entries: entries, policy: .after(refreshAt)))
}
}
// MARK: Shared helpers used by all widget views
enum WidgetHelpers {
static func events(for day: Date, in snapshot: WidgetSnapshot) -> [WidgetEvent] {
let cal = Calendar.current
let dayStart = cal.startOfDay(for: day)
let dayEnd = cal.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart
return snapshot.events
.filter { $0.start < dayEnd && $0.end > dayStart }
.sorted { $0.start < $1.start }
}
static func upcoming(from now: Date, daysAhead: Int, in snapshot: WidgetSnapshot) -> [WidgetEvent] {
let cal = Calendar.current
let end = cal.date(byAdding: .day, value: daysAhead, to: cal.startOfDay(for: now)) ?? now
return snapshot.events
.filter { $0.end > now && $0.start < end }
.sorted { $0.start < $1.start }
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.scarriffleservices.calendarr</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,141 @@
import WidgetKit
import SwiftUI
@main
struct CalendarrWidgetBundle: WidgetBundle {
var body: some Widget {
TodayWidget()
TwoDaysWidget()
ThreeDaysWidget()
ThisWeekWidget()
TwoWeeksWidget()
UpcomingWidget()
UpNextWidget()
}
}
// Shared chrome modifier keeps every widget on the same theme.
private struct CalendarrWidgetChrome: ViewModifier {
let snapshot: WidgetSnapshot?
func body(content: Content) -> some View {
let lang = snapshot?.language ?? "system"
content
.containerBackground(for: .widget) {
Color(widgetHex: snapshot?.backgroundColorHex ?? "#000000")
}
.foregroundStyle(Color(widgetHex: snapshot?.textColorHex ?? "#FFFFFF"))
.environment(\.locale, WidgetL10n.locale(lang))
}
}
private extension View {
func calendarrChrome(_ snapshot: WidgetSnapshot?) -> some View {
modifier(CalendarrWidgetChrome(snapshot: snapshot))
}
}
// MARK: Today (small)
struct TodayWidget: Widget {
let kind: String = "TodayWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
TodayWidgetView(entry: entry).calendarrChrome(entry.snapshot)
}
.configurationDisplayName(WidgetL10n.t("widget.display.today_title", "system"))
.description(WidgetL10n.t("widget.display.today_desc", "system"))
.supportedFamilies([.systemSmall])
}
}
// MARK: Today & Tomorrow (medium)
struct TwoDaysWidget: Widget {
let kind: String = "TwoDaysWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
TwoDaysWidgetView(entry: entry).calendarrChrome(entry.snapshot)
}
.configurationDisplayName(WidgetL10n.t("widget.display.days_title", "system"))
.description(WidgetL10n.t("widget.display.days_desc", "system"))
.supportedFamilies([.systemMedium])
}
}
// MARK: Three Days (medium)
struct ThreeDaysWidget: Widget {
let kind: String = "ThreeDaysWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
ThreeDaysWidgetView(entry: entry).calendarrChrome(entry.snapshot)
}
.configurationDisplayName(WidgetL10n.t("widget.display.threedays_title", "system"))
.description(WidgetL10n.t("widget.display.threedays_desc", "system"))
.supportedFamilies([.systemMedium])
}
}
// MARK: This Week (medium)
struct ThisWeekWidget: Widget {
let kind: String = "ThisWeekWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
ThisWeekWidgetView(entry: entry).calendarrChrome(entry.snapshot)
}
.configurationDisplayName(WidgetL10n.t("widget.display.thisweek_title", "system"))
.description(WidgetL10n.t("widget.display.thisweek_desc", "system"))
.supportedFamilies([.systemMedium])
}
}
// MARK: Two Weeks (medium)
struct TwoWeeksWidget: Widget {
let kind: String = "TwoWeeksWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
TwoWeeksWidgetView(entry: entry).calendarrChrome(entry.snapshot)
}
.configurationDisplayName(WidgetL10n.t("widget.display.twoweeks_title", "system"))
.description(WidgetL10n.t("widget.display.twoweeks_desc", "system"))
.supportedFamilies([.systemMedium])
}
}
// MARK: Upcoming (large + extra large on iPad)
struct UpcomingWidget: Widget {
let kind: String = "UpcomingWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
UpcomingWidgetView(entry: entry).calendarrChrome(entry.snapshot)
}
.configurationDisplayName(WidgetL10n.t("widget.display.upcoming_title", "system"))
.description(WidgetL10n.t("widget.display.upcoming_desc", "system"))
.supportedFamilies([.systemLarge, .systemExtraLarge])
}
}
// MARK: Up Next + Calendar (medium)
struct UpNextWidget: Widget {
let kind: String = "UpNextWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in
UpNextWidgetView(entry: entry).calendarrChrome(entry.snapshot)
}
.configurationDisplayName(WidgetL10n.t("widget.display.upnext_title", "system"))
.description(WidgetL10n.t("widget.display.upnext_desc", "system"))
.supportedFamilies([.systemMedium])
}
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Calendarr Widgets</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,117 @@
import SwiftUI
import WidgetKit
struct ThisWeekWidgetView: View {
let entry: CalendarrEntry
private var snapshot: WidgetSnapshot? { entry.snapshot }
private var lang: String { snapshot?.language ?? "system" }
private var cal: Calendar {
var c = Calendar(identifier: .gregorian)
c.locale = WidgetL10n.locale(lang)
c.firstWeekday = 2 // Monday
return c
}
private var weekStart: Date {
cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: entry.date)) ?? entry.date
}
private var weekDays: [Date] {
(0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: weekStart) }
}
private var monthHeader: String {
let f = DateFormatter()
f.locale = WidgetL10n.locale(lang)
f.dateFormat = "LLLL yyyy"
return f.string(from: weekStart).uppercased()
}
private var weekdayHeaders: [String] {
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang)
let symbols = f.shortWeekdaySymbols ?? cal.shortWeekdaySymbols
let start = cal.firstWeekday - 1
return (0..<7).map { String(symbols[(start + $0) % 7].prefix(2)).uppercased() }
}
var body: some View {
if let s = snapshot {
let primary = Color(widgetHex: s.primaryColorHex)
let accent = Color(widgetHex: s.accentColorHex)
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(monthHeader.split(separator: " ").first.map(String.init) ?? "")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(primary)
Text(monthHeader.split(separator: " ").dropFirst().joined(separator: " "))
.font(.system(size: 10, weight: .semibold))
}
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)
}
}
}
}
}
}
} else {
Text(WidgetL10n.t("widget.no_data", lang))
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private func dayColumn(_ day: Date,
snapshot: WidgetSnapshot,
primary: Color,
accent: Color) -> some View {
let isToday = cal.isDateInToday(day)
let evs = WidgetHelpers.events(for: day, in: snapshot)
let dayIdx = (0..<7).firstIndex { i in cal.isDate(weekDays[i], inSameDayAs: day) } ?? 0
return VStack(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)
.background(isToday ? primary : Color.clear)
.clipShape(Circle())
ForEach(evs.prefix(2)) { ev in
eventPill(ev)
}
if evs.count > 2 {
Text("+\(evs.count - 2)")
.font(.system(size: 7))
.foregroundStyle(accent)
}
Spacer(minLength: 0)
}
.padding(.horizontal, 1)
}
private func eventPill(_ ev: WidgetEvent) -> some View {
Text(ev.title)
.font(.system(size: 7, weight: .medium))
.lineLimit(1)
.foregroundStyle(.white)
.padding(.horizontal, 2)
.padding(.vertical, 0.5)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(widgetHex: ev.colorHex))
.clipShape(RoundedRectangle(cornerRadius: 1.5))
}
}

View File

@@ -0,0 +1,131 @@
import SwiftUI
import WidgetKit
struct ThreeDaysWidgetView: View {
let entry: CalendarrEntry
private var snapshot: WidgetSnapshot? { entry.snapshot }
private var lang: String { snapshot?.language ?? "system" }
private var cal: Calendar {
var c = Calendar(identifier: .gregorian)
c.locale = WidgetL10n.locale(lang)
return c
}
private var days: [Date] {
let today = cal.startOfDay(for: entry.date)
return (0..<3).compactMap { cal.date(byAdding: .day, value: $0, to: today) }
}
private var monthHeader: String {
let f = DateFormatter()
f.locale = WidgetL10n.locale(lang)
f.dateFormat = "LLLL yyyy"
return f.string(from: entry.date).uppercased()
}
private var weekdayFmt: DateFormatter {
let f = DateFormatter()
f.locale = WidgetL10n.locale(lang)
f.dateFormat = "EEE"
return f
}
private var timeFmt: DateFormatter {
let f = DateFormatter()
f.locale = WidgetL10n.locale(lang)
f.dateFormat = "HH:mm"
return f
}
var body: some View {
if let s = snapshot {
let primary = Color(widgetHex: s.primaryColorHex)
let accent = Color(widgetHex: s.accentColorHex)
VStack(alignment: .leading, spacing: 3) {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(monthHeader.split(separator: " ").first.map(String.init) ?? "")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(primary)
Text(monthHeader.split(separator: " ").dropFirst().joined(separator: " "))
.font(.system(size: 11, weight: .semibold))
}
HStack(spacing: 0) {
ForEach(Array(days.enumerated()), id: \.offset) { idx, day in
column(for: day, snapshot: s, primary: primary, accent: accent)
.frame(maxWidth: .infinity, alignment: .topLeading)
.overlay(alignment: .trailing) {
if idx < 2 {
Rectangle()
.fill(Color(widgetHex: s.lineColorHex).opacity(0.4))
.frame(width: 0.5)
}
}
}
}
}
} else {
Text(WidgetL10n.t("widget.no_data", lang))
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private func column(for day: Date, snapshot: WidgetSnapshot, primary: Color, accent: Color) -> some View {
let isToday = cal.isDateInToday(day)
let evs = WidgetHelpers.events(for: day, in: snapshot)
return VStack(alignment: .leading, spacing: 2) {
HStack {
Text(weekdayFmt.string(from: day).uppercased() + ".")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(isToday ? accent : .secondary)
Spacer()
Text("\(cal.component(.day, from: day))")
.font(.system(size: 11, weight: isToday ? .bold : .semibold))
.foregroundStyle(isToday ? Color.white : Color.primary)
.frame(width: 17, height: 17)
.background(isToday ? primary : Color.clear)
.clipShape(Circle())
}
.padding(.horizontal, 3)
if evs.isEmpty {
Text(WidgetL10n.t("widget.no_events", lang))
.font(.system(size: 9))
.foregroundStyle(.tertiary)
.padding(.horizontal, 3)
} else {
ForEach(evs.prefix(4)) { ev in
eventRow(ev)
}
if evs.count > 4 {
Text("+\(evs.count - 4)")
.font(.system(size: 8))
.foregroundStyle(accent)
.padding(.leading, 3)
}
}
Spacer(minLength: 0)
}
}
private func eventRow(_ ev: WidgetEvent) -> some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 3) {
RoundedRectangle(cornerRadius: 1)
.fill(Color(widgetHex: ev.colorHex))
.frame(width: 2)
Text(ev.title)
.font(.system(size: 9, weight: .medium))
.lineLimit(1)
Spacer(minLength: 0)
}
Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start))
.font(.system(size: 8))
.foregroundStyle(.secondary)
.padding(.leading, 5)
}
.padding(.horizontal, 3)
}
}

View File

@@ -0,0 +1,84 @@
import SwiftUI
import WidgetKit
struct TodayWidgetView: View {
let entry: CalendarrEntry
private var snapshot: WidgetSnapshot? { entry.snapshot }
private var todayEvents: [WidgetEvent] {
guard let s = snapshot else { return [] }
return WidgetHelpers.events(for: entry.date, in: s)
}
private var lang: String { snapshot?.language ?? "system" }
private var timeFmt: DateFormatter {
let f = DateFormatter()
f.locale = WidgetL10n.locale(lang)
f.dateFormat = "HH:mm"
return f
}
var body: some View {
let primary = Color(widgetHex: snapshot?.primaryColorHex ?? "#4285f4")
let accent = Color(widgetHex: snapshot?.accentColorHex ?? "#ea4335")
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(WidgetL10n.t("widget.today", lang))
.font(.caption.weight(.bold))
.foregroundStyle(primary)
Spacer()
Text(headerDate)
.font(.caption2)
.foregroundStyle(.secondary)
}
if snapshot == nil {
Text(WidgetL10n.t("widget.no_data", lang))
.font(.caption)
.foregroundStyle(.secondary)
} else if todayEvents.isEmpty {
Spacer()
Text(WidgetL10n.t("widget.no_events", lang))
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
} else {
ForEach(todayEvents.prefix(3)) { ev in
eventRow(ev)
}
if todayEvents.count > 3 {
Text(String(format: WidgetL10n.t("widget.more", lang), todayEvents.count - 3))
.font(.caption2)
.foregroundStyle(accent)
}
Spacer(minLength: 0)
}
}
}
private var headerDate: String {
let f = DateFormatter()
f.locale = WidgetL10n.locale(lang)
f.dateFormat = "d. MMM"
return f.string(from: entry.date)
}
private func eventRow(_ ev: WidgetEvent) -> some View {
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 2)
.fill(Color(widgetHex: ev.colorHex))
.frame(width: 3)
VStack(alignment: .leading, spacing: 1) {
Text(ev.title)
.font(.caption.weight(.medium))
.lineLimit(1)
Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start))
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
}
}

View File

@@ -0,0 +1,109 @@
import SwiftUI
import WidgetKit
struct TwoDaysWidgetView: View {
let entry: CalendarrEntry
private var snapshot: WidgetSnapshot? { entry.snapshot }
private var lang: String { snapshot?.language ?? "system" }
private var today: Date { Calendar.current.startOfDay(for: entry.date) }
private var tomorrow: Date {
Calendar.current.date(byAdding: .day, value: 1, to: today) ?? today
}
private var timeFmt: DateFormatter {
let f = DateFormatter()
f.locale = WidgetL10n.locale(lang)
f.dateFormat = "HH:mm"
return f
}
var body: some View {
if let s = snapshot {
let primary = Color(widgetHex: s.primaryColorHex)
let accent = Color(widgetHex: s.accentColorHex)
HStack(spacing: 8) {
column(for: today,
title: WidgetL10n.t("widget.today", lang),
isToday: true,
events: WidgetHelpers.events(for: today, in: s),
primary: primary, accent: accent,
lineColor: Color(widgetHex: s.lineColorHex))
Rectangle()
.fill(Color(widgetHex: s.lineColorHex).opacity(0.4))
.frame(width: 0.5)
column(for: tomorrow,
title: WidgetL10n.t("widget.tomorrow", lang),
isToday: false,
events: WidgetHelpers.events(for: tomorrow, in: s),
primary: primary, accent: accent,
lineColor: Color(widgetHex: s.lineColorHex))
}
} else {
Text(WidgetL10n.t("widget.no_data", lang))
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private func column(for day: Date,
title: String,
isToday: Bool,
events: [WidgetEvent],
primary: Color,
accent: Color,
lineColor: Color) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(title)
.font(.caption.weight(.bold))
.foregroundStyle(isToday ? primary : accent)
Text(shortDate(day))
.font(.caption2)
.foregroundStyle(.tertiary)
}
if events.isEmpty {
Text(WidgetL10n.t("widget.no_events", lang))
.font(.caption2)
.foregroundStyle(.secondary)
} else {
ForEach(events.prefix(4)) { ev in
eventRow(ev)
}
if events.count > 4 {
Text(String(format: WidgetL10n.t("widget.more", lang), events.count - 4))
.font(.system(size: 9))
.foregroundStyle(accent)
}
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .topLeading)
}
private func eventRow(_ ev: WidgetEvent) -> some View {
HStack(spacing: 4) {
RoundedRectangle(cornerRadius: 1.5)
.fill(Color(widgetHex: ev.colorHex))
.frame(width: 2)
VStack(alignment: .leading, spacing: 0) {
Text(ev.title)
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start))
.font(.system(size: 9))
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
}
private func shortDate(_ d: Date) -> String {
let f = DateFormatter()
f.locale = WidgetL10n.locale(lang)
f.dateFormat = "d. MMM"
return f.string(from: d)
}
}

View File

@@ -0,0 +1,132 @@
import SwiftUI
import WidgetKit
struct TwoWeeksWidgetView: View {
let entry: CalendarrEntry
private var snapshot: WidgetSnapshot? { entry.snapshot }
private var lang: String { snapshot?.language ?? "system" }
private var cal: Calendar {
var c = Calendar(identifier: .gregorian)
c.locale = WidgetL10n.locale(lang)
c.firstWeekday = 2
return c
}
private var weekStart: Date {
cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: entry.date)) ?? entry.date
}
private var fortnight: [Date] {
(0..<14).compactMap { cal.date(byAdding: .day, value: $0, to: weekStart) }
}
private var monthHeader: String {
let f = DateFormatter()
f.locale = WidgetL10n.locale(lang)
f.dateFormat = "LLLL yyyy"
return f.string(from: weekStart).uppercased()
}
private var weekdayHeaders: [String] {
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang)
let symbols = f.veryShortWeekdaySymbols ?? cal.veryShortWeekdaySymbols
let start = cal.firstWeekday - 1
return (0..<7).map { symbols[(start + $0) % 7] }
}
var body: some View {
if let s = snapshot {
let primary = Color(widgetHex: s.primaryColorHex)
let accent = Color(widgetHex: s.accentColorHex)
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(monthHeader.split(separator: " ").first.map(String.init) ?? "")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(primary)
Text(monthHeader.split(separator: " ").dropFirst().joined(separator: " "))
.font(.system(size: 9, weight: .semibold))
}
weekdayRow(accent: accent)
GeometryReader { geo in
let colW = geo.size.width / 7
let rowH = geo.size.height / 2
VStack(spacing: 0) {
ForEach(0..<2, id: \.self) { row in
HStack(spacing: 0) {
ForEach(0..<7, id: \.self) { col in
let day = fortnight[row * 7 + col]
dayCell(day, snapshot: s, primary: primary, accent: accent)
.frame(width: colW, height: rowH)
.overlay(alignment: .trailing) {
if col < 6 {
Rectangle()
.fill(Color(widgetHex: s.lineColorHex).opacity(0.35))
.frame(width: 0.5)
}
}
.overlay(alignment: .top) {
if row == 1 {
Rectangle()
.fill(Color(widgetHex: s.lineColorHex).opacity(0.35))
.frame(height: 0.5)
}
}
}
}
}
}
}
}
} else {
Text(WidgetL10n.t("widget.no_data", lang))
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private func weekdayRow(accent: Color) -> some View {
HStack(spacing: 0) {
ForEach(weekdayHeaders, id: \.self) { h in
Text(h)
.font(.system(size: 7, weight: .bold))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
}
}
}
private func dayCell(_ day: Date,
snapshot: WidgetSnapshot,
primary: Color,
accent: Color) -> some View {
let isToday = cal.isDateInToday(day)
let evs = WidgetHelpers.events(for: day, in: snapshot)
return VStack(alignment: .center, spacing: 0.5) {
Text("\(cal.component(.day, from: day))")
.font(.system(size: 8.5, weight: isToday ? .bold : .semibold))
.foregroundStyle(isToday ? Color.white : Color.primary)
.frame(width: 12, height: 12)
.background(isToday ? primary : Color.clear)
.clipShape(Circle())
// Up to 3 colored dots
HStack(spacing: 1) {
ForEach(evs.prefix(3).indices, id: \.self) { i in
Circle()
.fill(Color(widgetHex: evs[i].colorHex))
.frame(width: 3, height: 3)
}
}
.frame(height: 3)
if evs.count > 3 {
Text("+\(evs.count - 3)")
.font(.system(size: 6))
.foregroundStyle(accent)
}
Spacer(minLength: 0)
}
.padding(.top, 1)
}
}

View File

@@ -0,0 +1,173 @@
import SwiftUI
import WidgetKit
struct UpNextWidgetView: View {
let entry: CalendarrEntry
private var snapshot: WidgetSnapshot? { entry.snapshot }
private var lang: String { snapshot?.language ?? "system" }
private var cal: Calendar {
var c = Calendar(identifier: .gregorian)
c.locale = WidgetL10n.locale(lang)
c.firstWeekday = 2
return c
}
private var todayEvents: [WidgetEvent] {
guard let s = snapshot else { return [] }
return WidgetHelpers.events(for: entry.date, in: s)
}
/// Mini-month grid: 6 rows × 7 cols starting from the first weekday of the
/// month, padded with neighbouring days where necessary.
private var monthGrid: [Date] {
let firstOfMonth = cal.date(from: cal.dateComponents([.year, .month], from: entry.date)) ?? entry.date
let weekday = cal.component(.weekday, from: firstOfMonth)
let offset = ((weekday - cal.firstWeekday) + 7) % 7
let gridStart = cal.date(byAdding: .day, value: -offset, to: firstOfMonth) ?? firstOfMonth
return (0..<42).compactMap { cal.date(byAdding: .day, value: $0, to: gridStart) }
}
private var weekdayHeaders: [String] {
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang)
let symbols = f.veryShortWeekdaySymbols ?? cal.veryShortWeekdaySymbols
let start = cal.firstWeekday - 1
return (0..<7).map { symbols[(start + $0) % 7] }
}
private var weekdayFmt: DateFormatter {
let f = DateFormatter()
f.locale = WidgetL10n.locale(lang)
f.dateFormat = "EEE"
return f
}
private var monthNameFmt: DateFormatter {
let f = DateFormatter()
f.locale = WidgetL10n.locale(lang)
f.dateFormat = "LLL"
return f
}
private var timeFmt: DateFormatter {
let f = DateFormatter()
f.locale = WidgetL10n.locale(lang)
f.dateFormat = "HH:mm"
return f
}
var body: some View {
if let s = snapshot {
let primary = Color(widgetHex: s.primaryColorHex)
let accent = Color(widgetHex: s.accentColorHex)
HStack(spacing: 8) {
leftPanel(snapshot: s, primary: primary, accent: accent)
.frame(maxWidth: .infinity, alignment: .topLeading)
miniMonth(snapshot: s, primary: primary, accent: accent)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
} else {
Text(WidgetL10n.t("widget.no_data", lang))
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private func leftPanel(snapshot: WidgetSnapshot, primary: Color, accent: Color) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text("\(cal.component(.day, from: entry.date))")
.font(.system(size: 17, weight: .bold))
.foregroundStyle(Color.white)
.frame(width: 26, height: 26)
.background(primary)
.clipShape(RoundedRectangle(cornerRadius: 5))
VStack(alignment: .leading, spacing: 0) {
Text(weekdayFmt.string(from: entry.date).uppercased() + ".")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(accent)
Text(monthNameFmt.string(from: entry.date))
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
}
}
if todayEvents.isEmpty {
Text(WidgetL10n.t("widget.no_events", lang))
.font(.system(size: 10))
.foregroundStyle(.secondary)
} else {
ForEach(todayEvents.prefix(3)) { ev in
HStack(alignment: .top, spacing: 4) {
Circle()
.fill(Color(widgetHex: ev.colorHex))
.frame(width: 5, height: 5)
.padding(.top, 4)
VStack(alignment: .leading, spacing: 0) {
Text(ev.title)
.font(.system(size: 10, weight: .semibold))
.lineLimit(1)
Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start))
.font(.system(size: 9))
.foregroundStyle(.secondary)
}
}
}
}
Spacer(minLength: 0)
}
}
private func miniMonth(snapshot: WidgetSnapshot, primary: Color, accent: Color) -> some View {
VStack(spacing: 1) {
HStack(spacing: 0) {
ForEach(weekdayHeaders, id: \.self) { h in
Text(h)
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
}
}
GeometryReader { geo in
let cellW = geo.size.width / 7
let cellH = geo.size.height / 6
VStack(spacing: 0) {
ForEach(0..<6, id: \.self) { row in
HStack(spacing: 0) {
ForEach(0..<7, id: \.self) { col in
miniDay(monthGrid[row * 7 + col],
snapshot: snapshot,
primary: primary,
accent: accent)
.frame(width: cellW, height: cellH)
}
}
}
}
}
}
}
private func miniDay(_ day: Date, snapshot: WidgetSnapshot, primary: Color, accent: Color) -> some View {
let isToday = cal.isDateInToday(day)
let inMonth = cal.isDate(day, equalTo: entry.date, toGranularity: .month)
let hasEvents = !WidgetHelpers.events(for: day, in: snapshot).isEmpty
return ZStack {
if isToday {
RoundedRectangle(cornerRadius: 3)
.fill(primary)
} else if hasEvents && inMonth {
RoundedRectangle(cornerRadius: 3)
.fill(accent.opacity(0.20))
}
Text("\(cal.component(.day, from: day))")
.font(.system(size: 9, weight: isToday ? .bold : .medium))
.foregroundStyle(
isToday ? Color.white :
inMonth ? Color.primary : Color.secondary.opacity(0.4)
)
}
.padding(0.5)
}
}

View File

@@ -0,0 +1,130 @@
import SwiftUI
import WidgetKit
private let rowHeight: CGFloat = 16
private let dayHeaderHeight: CGFloat = 14
private let maxEventsPerDay: Int = 3
private let maxTotalRows: Int = 15
struct UpcomingWidgetView: View {
let entry: CalendarrEntry
private var snapshot: WidgetSnapshot? { entry.snapshot }
private var lang: String { snapshot?.language ?? "system" }
private var groupedWithLimits: [(Date, [WidgetEvent], Int)] {
guard let s = snapshot else { return [] }
let cal = Calendar.current
let now = entry.date
let events = WidgetHelpers.upcoming(from: now, daysAhead: 5, in: s)
var buckets: [Date: [WidgetEvent]] = [:]
for ev in events {
let key = cal.startOfDay(for: ev.start)
buckets[key, default: []].append(ev)
}
var result: [(Date, [WidgetEvent], Int)] = []
var totalRows = 0
for date in buckets.keys.sorted() {
let allEventsForDay = buckets[date] ?? []
let eventsToShow = Array(allEventsForDay.prefix(maxEventsPerDay))
let hiddenCount = allEventsForDay.count - eventsToShow.count
// Account for day header + event rows + potential "more" row
let rowsForThisDay = 1 + eventsToShow.count + (hiddenCount > 0 ? 1 : 0)
if totalRows + rowsForThisDay <= maxTotalRows {
result.append((date, eventsToShow, hiddenCount))
totalRows += rowsForThisDay
} else {
break
}
}
return result
}
private var timeFmt: DateFormatter {
let f = DateFormatter()
f.locale = WidgetL10n.locale(lang)
f.dateFormat = "HH:mm"
return f
}
private var dayFmt: DateFormatter {
let f = DateFormatter()
f.locale = WidgetL10n.locale(lang)
f.dateFormat = "EEE d. MMM"
return f
}
var body: some View {
let primary = Color(widgetHex: snapshot?.primaryColorHex ?? "#4285f4")
let accent = Color(widgetHex: snapshot?.accentColorHex ?? "#ea4335")
VStack(alignment: .leading, spacing: 2) {
Text(WidgetL10n.t("widget.upcoming", lang))
.font(.caption.weight(.bold))
.foregroundStyle(primary)
.padding(.bottom, 2)
if snapshot == nil {
Text(WidgetL10n.t("widget.no_data", lang))
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if groupedWithLimits.isEmpty {
Text(WidgetL10n.t("widget.no_events", lang))
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
VStack(alignment: .leading, spacing: 2) {
ForEach(groupedWithLimits, id: \.0) { day, evs, hiddenCount in
dayHeader(d: day, accent: accent)
ForEach(evs) { ev in
eventRow(ev)
}
if hiddenCount > 0 {
moreRow(count: hiddenCount, accent: accent)
}
}
}
Spacer(minLength: 0)
}
}
}
private func dayHeader(d: Date, accent: Color) -> some View {
let cal = Calendar.current
let isToday = cal.isDateInToday(d)
return Text(dayFmt.string(from: d))
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(isToday ? accent : .secondary)
.frame(height: dayHeaderHeight, alignment: .bottomLeading)
.padding(.top, 1)
}
private func eventRow(_ ev: WidgetEvent) -> some View {
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 1.5)
.fill(Color(widgetHex: ev.colorHex))
.frame(width: 2.5)
Text(ev.isAllDay ? WidgetL10n.t("widget.allday", lang) : timeFmt.string(from: ev.start))
.font(.system(size: 10))
.foregroundStyle(.secondary)
.frame(width: 38, alignment: .leading)
Text(ev.title)
.font(.system(size: 10, weight: .medium))
.lineLimit(1)
Spacer(minLength: 0)
}
.frame(height: rowHeight)
}
private func moreRow(count: Int, accent: Color) -> some View {
Text(String(format: WidgetL10n.t("widget.more", lang), count))
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(accent)
.frame(height: rowHeight)
}
}

View File

@@ -0,0 +1,90 @@
import SwiftUI
// Local copy of the Color(hex:) initializer, since the widget extension
// is a separate target and cannot import the main app's Color extension.
extension Color {
init(widgetHex hex: String) {
let cleaned = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
var int: UInt64 = 0
Scanner(string: cleaned).scanHexInt64(&int)
let r, g, b: UInt64
switch cleaned.count {
case 6:
(r, g, b) = ((int >> 16) & 0xFF, (int >> 8) & 0xFF, int & 0xFF)
default:
(r, g, b) = (0, 0, 0)
}
self.init(red: Double(r) / 255, green: Double(g) / 255, blue: Double(b) / 255)
}
}
enum WidgetL10n {
static func t(_ key: String, _ stored: String) -> String {
let lang: String
if stored == "de" || stored == "en" { lang = stored }
else {
let pref = Locale.preferredLanguages.first ?? "en"
lang = pref.lowercased().hasPrefix("de") ? "de" : "en"
}
return strings[lang]?[key] ?? strings["en"]?[key] ?? key
}
static func locale(_ stored: String) -> Locale {
let lang: String
if stored == "de" || stored == "en" { lang = stored }
else {
let pref = Locale.preferredLanguages.first ?? "en"
lang = pref.lowercased().hasPrefix("de") ? "de" : "en"
}
return Locale(identifier: lang)
}
private static let strings: [String: [String: String]] = [
"de": [
"widget.today": "Heute",
"widget.tomorrow": "Morgen",
"widget.no_events": "Keine Termine",
"widget.allday": "Ganztägig",
"widget.more": "+%d weitere",
"widget.upcoming": "Nächste 5 Tage",
"widget.no_data": "Keine Daten App einmal öffnen",
"widget.display.today_title": "Heute",
"widget.display.today_desc": "Heutige Termine auf einen Blick.",
"widget.display.days_title": "Heute & Morgen",
"widget.display.days_desc": "Termine der nächsten zwei Tage.",
"widget.display.upcoming_title": "Nächste 5 Tage",
"widget.display.upcoming_desc": "Termine der nächsten 5 Tage.",
"widget.display.thisweek_title": "Diese Woche",
"widget.display.thisweek_desc": "Wochenraster mit Terminen.",
"widget.display.twoweeks_title": "Zwei Wochen",
"widget.display.twoweeks_desc": "Zwei-Wochen-Raster mit Terminen.",
"widget.display.threedays_title": "Drei Tage",
"widget.display.threedays_desc": "Drei-Tages-Ansicht mit Terminen.",
"widget.display.upnext_title": "Up Next + Kalender",
"widget.display.upnext_desc": "Nächste Termine mit Monatsübersicht."
],
"en": [
"widget.today": "Today",
"widget.tomorrow": "Tomorrow",
"widget.no_events": "No events",
"widget.allday": "All-day",
"widget.more": "+%d more",
"widget.upcoming": "Next 5 days",
"widget.no_data": "No data open the app once",
"widget.display.today_title": "Today",
"widget.display.today_desc": "Today's events at a glance.",
"widget.display.days_title": "Today & tomorrow",
"widget.display.days_desc": "Events for the next two days.",
"widget.display.upcoming_title": "Next 5 days",
"widget.display.upcoming_desc": "Events for the next 5 days.",
"widget.display.thisweek_title": "This Week",
"widget.display.thisweek_desc": "Week grid with events.",
"widget.display.twoweeks_title": "Two Weeks",
"widget.display.twoweeks_desc": "Two-week grid with events.",
"widget.display.threedays_title": "Three Days",
"widget.display.threedays_desc": "Three-day view with events.",
"widget.display.upnext_title": "Up Next + Calendar",
"widget.display.upnext_desc": "Next events with month overview."
]
]
}