chore(repo): iOS-App als ios/ ins Hauptrepo uebernehmen
Calendarr-IOS wird mit vollstaendiger Historie als Unterverzeichnis ios/ eingebunden. Damit liegen Server, Web, Android und iOS in einem gemeinsamen Repo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
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)
|
||||
.padding(.top, 6)
|
||||
weekStrip(snapshot: s, primary: primary, accent: accent)
|
||||
.padding(.vertical, 3)
|
||||
Rectangle()
|
||||
.fill(Color(widgetHex: s.lineColorHex).opacity(0.4))
|
||||
.frame(height: 0.5)
|
||||
.padding(.bottom, 4)
|
||||
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: 30, weight: .bold))
|
||||
.foregroundStyle(primary)
|
||||
.frame(width: 40, 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: 4) {
|
||||
ForEach(upcomingEvents.prefix(3)) { ev in
|
||||
HStack(alignment: .center, spacing: 6) {
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
.fill(Color(widgetHex: ev.colorHex))
|
||||
.frame(width: 3, height: 22)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import AppIntents
|
||||
import WidgetKit
|
||||
|
||||
/// An individual calendar option shown in the widget configuration picker.
|
||||
struct CalendarAppEntity: AppEntity, Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let colorHex: String
|
||||
|
||||
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Kalender"
|
||||
static var defaultQuery = CalendarEntityQuery()
|
||||
|
||||
var displayRepresentation: DisplayRepresentation {
|
||||
DisplayRepresentation(title: LocalizedStringResource(stringLiteral: name))
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads available calendars from the App Group container so the widget
|
||||
/// extension can offer options without a network call.
|
||||
struct CalendarEntityQuery: EntityQuery {
|
||||
func entities(for identifiers: [String]) async throws -> [CalendarAppEntity] {
|
||||
let ids = Set(identifiers)
|
||||
return WidgetStore.readCalendars()
|
||||
.filter { ids.contains($0.id) }
|
||||
.map { CalendarAppEntity(id: $0.id, name: $0.name, colorHex: $0.colorHex) }
|
||||
}
|
||||
|
||||
func suggestedEntities() async throws -> [CalendarAppEntity] {
|
||||
WidgetStore.readCalendars()
|
||||
.map { CalendarAppEntity(id: $0.id, name: $0.name, colorHex: $0.colorHex) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget configuration intent: lets the user pick which calendars to show.
|
||||
/// No selection means "show all calendars" (the default).
|
||||
///
|
||||
/// The parameter must be optional: WidgetConfigurationIntent requires every
|
||||
/// parameter type to be optional, and the macOS/Mac Catalyst SDK enforces that
|
||||
/// where the iOS one lets a bare array through. `nil` and `[]` are treated
|
||||
/// alike downstream, so this is behaviour-preserving.
|
||||
struct CalendarSelectionIntent: WidgetConfigurationIntent {
|
||||
static var title: LocalizedStringResource = "Kalender auswählen"
|
||||
static var description = IntentDescription("Wähle welche Kalender im Widget angezeigt werden. Leer = alle Kalender.")
|
||||
|
||||
@Parameter(title: "Kalender")
|
||||
var selectedCalendars: [CalendarAppEntity]?
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import WidgetKit
|
||||
import AppIntents
|
||||
import CalendarrCore
|
||||
|
||||
struct CalendarrEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let snapshot: WidgetSnapshot?
|
||||
}
|
||||
|
||||
struct CalendarrTimelineProvider: AppIntentTimelineProvider {
|
||||
typealias Entry = CalendarrEntry
|
||||
typealias Intent = CalendarSelectionIntent
|
||||
|
||||
func placeholder(in context: Context) -> CalendarrEntry {
|
||||
CalendarrEntry(date: .now, snapshot: WidgetStore.read())
|
||||
}
|
||||
|
||||
func snapshot(for configuration: CalendarSelectionIntent, in context: Context) async -> CalendarrEntry {
|
||||
let raw = WidgetStore.read()
|
||||
return CalendarrEntry(date: .now, snapshot: filtered(raw, by: configuration))
|
||||
}
|
||||
|
||||
func timeline(for configuration: CalendarSelectionIntent, in context: Context) async -> Timeline<CalendarrEntry> {
|
||||
let raw = WidgetStore.read()
|
||||
let snap = filtered(raw, by: configuration)
|
||||
let now = Date()
|
||||
|
||||
// One entry per hour for 24 h so the widget re-renders as time 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: snap))
|
||||
}
|
||||
let refreshAt = Calendar.current.date(byAdding: .minute, value: 30, to: now) ?? now
|
||||
return Timeline(entries: entries, policy: .after(refreshAt))
|
||||
}
|
||||
|
||||
// MARK: – Filtering
|
||||
|
||||
private func filtered(_ snapshot: WidgetSnapshot?, by config: CalendarSelectionIntent) -> WidgetSnapshot? {
|
||||
// The rule lives in CalendarrCore so the watch provider cannot drift
|
||||
// from this one. Nothing selected means "show all calendars".
|
||||
snapshot?.filtered(toCalendarKeys: Set((config.selectedCalendars ?? []).map(\.id)))
|
||||
}
|
||||
}
|
||||
|
||||
// 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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?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>
|
||||
<!--
|
||||
Mac Catalyst entitlements for the widget extension. Deliberately minimal:
|
||||
the widget only ever reads the snapshot from the App Group container. It
|
||||
does no networking, touches no Contacts, and needs no keychain access.
|
||||
-->
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
|
||||
<!-- Must match CalendarrAppGroup.current on this platform, byte for byte. -->
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>PP34X97WS3.group.com.scarriffleservices.calendarr</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,243 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct CalendarrWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
TodayWidget()
|
||||
TwoDaysWidget()
|
||||
ThreeDaysWidget()
|
||||
ThisWeekWidget()
|
||||
TwoWeeksWidget()
|
||||
UpcomingWidget()
|
||||
UpNextWidget()
|
||||
CalendarDayWidget()
|
||||
TwoMonthWidget()
|
||||
NowNextEventsWidget()
|
||||
LockScreenWidget()
|
||||
LockScreenCountWidget()
|
||||
LockScreenCountdownWidget()
|
||||
}
|
||||
}
|
||||
|
||||
// Shared chrome modifier — keeps every home-screen widget on the same theme.
|
||||
private struct CalendarrWidgetChrome: ViewModifier {
|
||||
let snapshot: WidgetSnapshot?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
let lang = snapshot?.language ?? "system"
|
||||
content
|
||||
.containerBackground(for: .widget) {
|
||||
Color(widgetHex: snapshot?.backgroundColorHex ?? "#000000")
|
||||
}
|
||||
.foregroundStyle(Color(widgetHex: snapshot?.textColorHex ?? "#FFFFFF"))
|
||||
.environment(\.locale, WidgetL10n.locale(lang))
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
func calendarrChrome(_ snapshot: WidgetSnapshot?) -> some View {
|
||||
modifier(CalendarrWidgetChrome(snapshot: snapshot))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Today (small)
|
||||
|
||||
struct TodayWidget: Widget {
|
||||
let kind: String = "TodayWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, 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 {
|
||||
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, 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 {
|
||||
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, 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 {
|
||||
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, 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 {
|
||||
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, 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 {
|
||||
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, 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 {
|
||||
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
|
||||
UpNextWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||
}
|
||||
.configurationDisplayName(WidgetL10n.t("widget.display.upnext_title", "system"))
|
||||
.description(WidgetL10n.t("widget.display.upnext_desc", "system"))
|
||||
.supportedFamilies([.systemMedium])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Calendar Day: date + week strip + events (medium)
|
||||
|
||||
struct CalendarDayWidget: Widget {
|
||||
let kind: String = "CalendarDayWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
|
||||
CalendarDayWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||
}
|
||||
.configurationDisplayName(WidgetL10n.t("widget.display.calday_title", "system"))
|
||||
.description(WidgetL10n.t("widget.display.calday_desc", "system"))
|
||||
.supportedFamilies([.systemMedium])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Two Month calendar grid (medium + large)
|
||||
|
||||
struct TwoMonthWidget: Widget {
|
||||
let kind: String = "TwoMonthWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, 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 {
|
||||
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
|
||||
NowNextWidgetView(entry: entry).calendarrChrome(entry.snapshot)
|
||||
}
|
||||
.configurationDisplayName(WidgetL10n.t("widget.display.nownext_title", "system"))
|
||||
.description(WidgetL10n.t("widget.display.nownext_desc", "system"))
|
||||
.supportedFamilies([.systemMedium])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Lock Screen: date (circular, rectangular, inline)
|
||||
|
||||
struct LockScreenWidget: Widget {
|
||||
let kind: String = "LockScreenWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, provider: CalendarrTimelineProvider()) { entry in
|
||||
LockScreenWidgetView(entry: entry)
|
||||
.containerBackground(for: .widget) { Color.clear }
|
||||
.environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system"))
|
||||
}
|
||||
.configurationDisplayName(WidgetL10n.t("widget.display.lockscreen_title", "system"))
|
||||
.description(WidgetL10n.t("widget.display.lockscreen_desc", "system"))
|
||||
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Lock Screen: today event count (circular, rectangular, inline)
|
||||
|
||||
struct LockScreenCountWidget: Widget {
|
||||
let kind: String = "LockScreenCountWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, 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 {
|
||||
AppIntentConfiguration(kind: kind, intent: CalendarSelectionIntent.self, 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])
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,288 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
// MARK: – Date widget (existing)
|
||||
|
||||
struct LockScreenWidgetView: View {
|
||||
let entry: CalendarrEntry
|
||||
@Environment(\.widgetFamily) private var family
|
||||
|
||||
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||
private var lang: String { snapshot?.language ?? "system" }
|
||||
|
||||
private var cal: Calendar {
|
||||
var c = Calendar(identifier: .gregorian)
|
||||
c.locale = WidgetL10n.locale(lang)
|
||||
return c
|
||||
}
|
||||
|
||||
private var nextEvent: WidgetEvent? {
|
||||
guard let s = snapshot else { return nil }
|
||||
return WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s).first
|
||||
}
|
||||
|
||||
private var timeFmt: DateFormatter {
|
||||
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "HH:mm"; return f
|
||||
}
|
||||
|
||||
private var monthAbbrev: String {
|
||||
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "LLL"
|
||||
return f.string(from: entry.date).uppercased()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
switch family {
|
||||
case .accessoryCircular:
|
||||
circularView
|
||||
case .accessoryRectangular:
|
||||
rectangularView
|
||||
default:
|
||||
inlineView
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Circular: today's date
|
||||
|
||||
private var circularView: some View {
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
VStack(spacing: 0) {
|
||||
Text("\(cal.component(.day, from: entry.date))")
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.minimumScaleFactor(0.7)
|
||||
.widgetAccentable()
|
||||
Text(monthAbbrev)
|
||||
.font(.system(size: 8, weight: .semibold))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Rectangular: next event
|
||||
|
||||
private var rectangularView: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if let ev = nextEvent {
|
||||
Text(ev.isAllDay
|
||||
? WidgetL10n.t("widget.allday", lang)
|
||||
: timeFmt.string(from: ev.start))
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.widgetAccentable()
|
||||
Text(ev.title)
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.lineLimit(1)
|
||||
if !ev.location.isEmpty {
|
||||
Text(ev.location)
|
||||
.font(.system(size: 11))
|
||||
.lineLimit(1)
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "calendar")
|
||||
.font(.system(size: 13))
|
||||
.widgetAccentable()
|
||||
Text(WidgetL10n.t("widget.no_events", lang))
|
||||
.font(.system(size: 13))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
// MARK: – Inline: brief next event
|
||||
|
||||
private var inlineView: some View {
|
||||
let text: String = {
|
||||
guard let ev = nextEvent else {
|
||||
return WidgetL10n.t("widget.no_events", lang)
|
||||
}
|
||||
return ev.isAllDay ? ev.title : "\(timeFmt.string(from: ev.start)) \(ev.title)"
|
||||
}()
|
||||
return Label(text, systemImage: "calendar")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Today event count widget
|
||||
|
||||
struct LockScreenCountWidgetView: View {
|
||||
let entry: CalendarrEntry
|
||||
@Environment(\.widgetFamily) private var family
|
||||
|
||||
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||
private var lang: String { snapshot?.language ?? "system" }
|
||||
|
||||
private var timeFmt: DateFormatter {
|
||||
let f = DateFormatter()
|
||||
f.locale = WidgetL10n.locale(lang)
|
||||
f.dateFormat = "HH:mm"
|
||||
return f
|
||||
}
|
||||
|
||||
private var todayEvents: [WidgetEvent] {
|
||||
guard let s = snapshot else { return [] }
|
||||
return WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
switch family {
|
||||
case .accessoryCircular:
|
||||
circularView
|
||||
case .accessoryRectangular:
|
||||
rectangularView
|
||||
default:
|
||||
inlineView
|
||||
}
|
||||
}
|
||||
|
||||
private var circularView: some View {
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
VStack(spacing: 1) {
|
||||
Image(systemName: "calendar")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.widgetAccentable()
|
||||
Text("\(todayEvents.count)")
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.minimumScaleFactor(0.7)
|
||||
.widgetAccentable()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var rectangularView: some View {
|
||||
let countLabel = "\(todayEvents.count) \(WidgetL10n.t("widget.events_count", lang))"
|
||||
return VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(spacing: 4) {
|
||||
Text(WidgetL10n.t("widget.today", lang).uppercased())
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.widgetAccentable()
|
||||
Text("· \(countLabel)")
|
||||
.font(.system(size: 9))
|
||||
}
|
||||
if todayEvents.isEmpty {
|
||||
Text(WidgetL10n.t("widget.no_events", lang))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(todayEvents.prefix(2)) { ev in
|
||||
HStack(spacing: 4) {
|
||||
Text(ev.isAllDay ? "·" : timeFmt.string(from: ev.start))
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.widgetAccentable()
|
||||
.frame(width: 32, alignment: .leading)
|
||||
Text(ev.title)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
private var inlineView: some View {
|
||||
let label = "\(todayEvents.count) \(WidgetL10n.t("widget.events_count", lang))"
|
||||
return Label(label, systemImage: "calendar.badge.clock")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Countdown to next event widget
|
||||
|
||||
struct LockScreenCountdownWidgetView: View {
|
||||
let entry: CalendarrEntry
|
||||
@Environment(\.widgetFamily) private var family
|
||||
|
||||
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||
private var lang: String { snapshot?.language ?? "system" }
|
||||
|
||||
private var timeFmt: DateFormatter {
|
||||
let f = DateFormatter()
|
||||
f.locale = WidgetL10n.locale(lang)
|
||||
f.dateFormat = "HH:mm"
|
||||
return f
|
||||
}
|
||||
|
||||
private var nextEvent: WidgetEvent? {
|
||||
guard let s = snapshot else { return nil }
|
||||
return WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s).first
|
||||
}
|
||||
|
||||
private var isRunning: Bool {
|
||||
guard let ev = nextEvent, !ev.isAllDay else { return false }
|
||||
return ev.start <= entry.date && ev.end > entry.date
|
||||
}
|
||||
|
||||
private var countdownText: String {
|
||||
guard let ev = nextEvent else { return WidgetL10n.t("widget.no_events", lang) }
|
||||
if isRunning { return WidgetL10n.t("widget.running", lang) }
|
||||
if ev.isAllDay { return WidgetL10n.t("widget.allday", lang) }
|
||||
let total = Int(max(0, ev.start.timeIntervalSince(entry.date)) / 60)
|
||||
if total < 60 { return "in \(total)m" }
|
||||
let h = total / 60; let m = total % 60
|
||||
return m == 0 ? "in \(h)h" : "in \(h)h \(m)m"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
switch family {
|
||||
case .accessoryCircular:
|
||||
circularView
|
||||
case .accessoryRectangular:
|
||||
rectangularView
|
||||
default:
|
||||
inlineView
|
||||
}
|
||||
}
|
||||
|
||||
private var circularView: some View {
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
VStack(spacing: 1) {
|
||||
Text(countdownText)
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.minimumScaleFactor(0.5)
|
||||
.lineLimit(1)
|
||||
.widgetAccentable()
|
||||
if let ev = nextEvent, !ev.isAllDay {
|
||||
Text(timeFmt.string(from: ev.start))
|
||||
.font(.system(size: 8))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private var rectangularView: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if let ev = nextEvent {
|
||||
Text(countdownText)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.widgetAccentable()
|
||||
Text(ev.title)
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.lineLimit(1)
|
||||
let timeStr = ev.isAllDay
|
||||
? WidgetL10n.t("widget.allday", lang)
|
||||
: "\(timeFmt.string(from: ev.start)) – \(timeFmt.string(from: ev.end))"
|
||||
Text(timeStr)
|
||||
.font(.system(size: 11))
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
Image(systemName: "timer")
|
||||
.font(.system(size: 13))
|
||||
.widgetAccentable()
|
||||
Text(WidgetL10n.t("widget.no_events", lang))
|
||||
.font(.system(size: 13))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
|
||||
private var inlineView: some View {
|
||||
let text: String = {
|
||||
guard let ev = nextEvent else { return WidgetL10n.t("widget.no_events", lang) }
|
||||
return "\(ev.title) \(countdownText)"
|
||||
}()
|
||||
return Label(text, systemImage: "timer")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
struct NowNextWidgetView: View {
|
||||
let entry: CalendarrEntry
|
||||
|
||||
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||
private var lang: String { snapshot?.language ?? "system" }
|
||||
|
||||
private var cal: Calendar {
|
||||
var c = Calendar(identifier: .gregorian)
|
||||
c.locale = WidgetL10n.locale(lang)
|
||||
return c
|
||||
}
|
||||
|
||||
private var timeFmt: DateFormatter {
|
||||
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "HH:mm"; return f
|
||||
}
|
||||
private var dayOfWeekFmt: DateFormatter {
|
||||
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "EEEE"; return f
|
||||
}
|
||||
|
||||
// Currently running event, or next upcoming timed event, or first all-day event
|
||||
private var featuredEvent: WidgetEvent? {
|
||||
guard let s = snapshot else { return nil }
|
||||
let pool = WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s)
|
||||
if let running = pool.first(where: { !$0.isAllDay && $0.start <= entry.date }) { return running }
|
||||
if let next = pool.first(where: { !$0.isAllDay }) { return next }
|
||||
return pool.first
|
||||
}
|
||||
|
||||
// All upcoming events today except the featured one
|
||||
private var remainingEvents: [WidgetEvent] {
|
||||
guard let s = snapshot else { return [] }
|
||||
let pool = WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s)
|
||||
guard let featured = featuredEvent else { return pool }
|
||||
return pool.filter { $0.id != featured.id }
|
||||
}
|
||||
|
||||
private func timeRange(_ ev: WidgetEvent) -> String {
|
||||
ev.isAllDay
|
||||
? WidgetL10n.t("widget.allday", lang)
|
||||
: "\(timeFmt.string(from: ev.start)) – \(timeFmt.string(from: ev.end))"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let s = snapshot {
|
||||
let line = Color(widgetHex: s.lineColorHex)
|
||||
VStack(spacing: 6) {
|
||||
featuredCard(snapshot: s)
|
||||
bottomRow(line: line)
|
||||
}
|
||||
} else {
|
||||
Text(WidgetL10n.t("widget.no_data", lang))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Featured event card
|
||||
|
||||
private func featuredCard(snapshot: WidgetSnapshot) -> some View {
|
||||
let ev = featuredEvent
|
||||
let baseColor = ev.map { Color(widgetHex: $0.colorHex) } ?? Color(widgetHex: snapshot.primaryColorHex)
|
||||
|
||||
return ZStack(alignment: .leading) {
|
||||
LinearGradient(
|
||||
colors: [baseColor.opacity(0.75), baseColor],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
HStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(ev?.title ?? WidgetL10n.t("widget.no_events", lang))
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
Text(ev.map { timeRange($0) } ?? "")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
}
|
||||
.padding(.leading, 10)
|
||||
.padding(.vertical, 9)
|
||||
Spacer()
|
||||
if ev != nil {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.padding(.trailing, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
// MARK: – Bottom: date + event list
|
||||
|
||||
private func bottomRow(line: Color) -> some View {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
// Left: day name + large number
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(dayOfWeekFmt.string(from: entry.date).uppercased())
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.6)
|
||||
Text("\(cal.component(.day, from: entry.date))")
|
||||
.font(.system(size: 30, weight: .light))
|
||||
}
|
||||
.frame(width: 50, alignment: .leading)
|
||||
|
||||
// Divider
|
||||
line.opacity(0.4).frame(width: 0.5)
|
||||
.padding(.horizontal, 6)
|
||||
|
||||
// Right: event list
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
let shown = remainingEvents.prefix(2)
|
||||
if shown.isEmpty {
|
||||
Text(WidgetL10n.t("widget.no_events", lang))
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(shown) { ev in
|
||||
HStack(alignment: .firstTextBaseline, spacing: 5) {
|
||||
Circle()
|
||||
.fill(Color(widgetHex: ev.colorHex))
|
||||
.frame(width: 7, height: 7)
|
||||
.padding(.top, 1)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(ev.title)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
Text(timeRange(ev))
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
// +N badge
|
||||
if remainingEvents.count > 2 {
|
||||
Text("+\(remainingEvents.count - 2)")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 2)
|
||||
.background(.secondary.opacity(0.18), in: Capsule())
|
||||
.frame(maxHeight: .infinity, alignment: .bottom)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
struct ThisWeekWidgetView: View {
|
||||
let entry: CalendarrEntry
|
||||
|
||||
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||
private var lang: String { snapshot?.language ?? "system" }
|
||||
|
||||
private var cal: Calendar {
|
||||
var c = Calendar(identifier: .gregorian)
|
||||
c.locale = WidgetL10n.locale(lang)
|
||||
c.firstWeekday = 2
|
||||
return c
|
||||
}
|
||||
|
||||
private var weekStart: Date {
|
||||
cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: entry.date)) ?? entry.date
|
||||
}
|
||||
|
||||
private var weekDays: [Date] {
|
||||
(0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: weekStart) }
|
||||
}
|
||||
|
||||
private var monthHeader: String {
|
||||
let f = DateFormatter()
|
||||
f.locale = WidgetL10n.locale(lang)
|
||||
f.dateFormat = "LLLL yyyy"
|
||||
return f.string(from: weekStart).uppercased()
|
||||
}
|
||||
|
||||
private var weekdayHeaders: [String] {
|
||||
let f = DateFormatter(); f.locale = WidgetL10n.locale(lang)
|
||||
let symbols = f.shortWeekdaySymbols ?? cal.shortWeekdaySymbols
|
||||
let start = cal.firstWeekday - 1
|
||||
return (0..<7).map { String(symbols[(start + $0) % 7].prefix(2)).uppercased() }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let s = snapshot {
|
||||
let primary = Color(widgetHex: s.primaryColorHex)
|
||||
let accent = Color(widgetHex: s.accentColorHex)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text(monthHeader.split(separator: " ").first.map(String.init) ?? "")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(primary)
|
||||
Text(monthHeader.split(separator: " ").dropFirst().joined(separator: " "))
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
}
|
||||
// Equal-width columns via maxWidth — no GeometryReader needed
|
||||
HStack(spacing: 0) {
|
||||
ForEach(Array(weekDays.enumerated()), id: \.offset) { idx, day in
|
||||
dayColumn(day, snapshot: s, primary: primary, accent: accent)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.overlay(alignment: .trailing) {
|
||||
if idx < 6 {
|
||||
Rectangle()
|
||||
.fill(Color(widgetHex: s.lineColorHex).opacity(0.35))
|
||||
.frame(width: 0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
} else {
|
||||
Text(WidgetL10n.t("widget.no_data", lang))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private func dayColumn(_ day: Date,
|
||||
snapshot: WidgetSnapshot,
|
||||
primary: Color,
|
||||
accent: Color) -> some View {
|
||||
let isToday = cal.isDateInToday(day)
|
||||
let evs = WidgetHelpers.events(for: day, in: snapshot)
|
||||
let dayIdx = (0..<7).firstIndex { i in cal.isDate(weekDays[i], inSameDayAs: day) } ?? 0
|
||||
return VStack(alignment: .center, spacing: 1) {
|
||||
Text(weekdayHeaders[dayIdx])
|
||||
.font(.system(size: 7.5, weight: .bold))
|
||||
.foregroundStyle(isToday ? accent : .secondary)
|
||||
Text("\(cal.component(.day, from: day))")
|
||||
.font(.system(size: 10, weight: isToday ? .bold : .semibold))
|
||||
.foregroundStyle(isToday ? Color.white : Color.primary)
|
||||
.frame(width: 16, height: 16)
|
||||
.background(isToday ? primary : Color.clear)
|
||||
.clipShape(Circle())
|
||||
ForEach(evs.prefix(8)) { ev in
|
||||
eventPill(ev)
|
||||
}
|
||||
if evs.count > 8 {
|
||||
Text("+\(evs.count - 8)")
|
||||
.font(.system(size: 6.5))
|
||||
.foregroundStyle(accent)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 1)
|
||||
}
|
||||
|
||||
private func eventPill(_ ev: WidgetEvent) -> some View {
|
||||
Text(ev.title)
|
||||
.font(.system(size: 7, weight: .medium))
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 2)
|
||||
.padding(.vertical, 0.5)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(widgetHex: ev.colorHex))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 1.5))
|
||||
}
|
||||
}
|
||||
@@ -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(WidgetTime.range(ev, lang: lang))
|
||||
.font(.system(size: 8))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, 5)
|
||||
}
|
||||
.padding(.horizontal, 3)
|
||||
}
|
||||
}
|
||||
@@ -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(WidgetTime.range(ev, lang: lang))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(WidgetTime.range(ev, lang: lang))
|
||||
.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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
// Distribute weeks across the full column height instead of
|
||||
// top-packing them (which left the lower portion empty).
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
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 2 mini event-title pills (the cell has room for titles).
|
||||
ForEach(evs.prefix(2)) { ev in
|
||||
Text(ev.title)
|
||||
.font(.system(size: 6, weight: .medium))
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 1.5)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(widgetHex: ev.colorHex))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 1.5))
|
||||
}
|
||||
if evs.count > 2 {
|
||||
Text("+\(evs.count - 2)")
|
||||
.font(.system(size: 6))
|
||||
.foregroundStyle(accent)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.top, 1)
|
||||
}
|
||||
}
|
||||
@@ -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(WidgetTime.range(ev, lang: lang))
|
||||
.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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
private let rowHeight: CGFloat = 16
|
||||
private let dayHeaderHeight: CGFloat = 14
|
||||
// Show all events of a day (the total-row cap below governs how much fits);
|
||||
// a low per-day cap previously made busy days collapse to "+N" far too early.
|
||||
private let maxEventsPerDay: Int = 25
|
||||
|
||||
struct UpcomingWidgetView: View {
|
||||
let entry: CalendarrEntry
|
||||
@Environment(\.widgetFamily) private var family
|
||||
|
||||
private var snapshot: WidgetSnapshot? { entry.snapshot }
|
||||
private var lang: String { snapshot?.language ?? "system" }
|
||||
|
||||
// Fill the available height: extraLarge (iPad) fits far more rows.
|
||||
private var maxTotalRows: Int { family == .systemExtraLarge ? 40 : 22 }
|
||||
|
||||
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: 7, 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared event time formatting for all widgets: "start – end", or the
|
||||
/// localized all-day label. Keeps every widget's event row consistent.
|
||||
enum WidgetTime {
|
||||
static func range(_ ev: WidgetEvent, lang: String) -> String {
|
||||
if ev.isAllDay { return WidgetL10n.t("widget.allday", lang) }
|
||||
let f = DateFormatter()
|
||||
f.locale = WidgetL10n.locale(lang)
|
||||
f.dateFormat = "HH:mm"
|
||||
let start = f.string(from: ev.start)
|
||||
// Hide a redundant identical end time (zero-length events).
|
||||
if ev.end <= ev.start { return start }
|
||||
return "\(start) – \(f.string(from: ev.end))"
|
||||
}
|
||||
}
|
||||
|
||||
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 7 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 7 Tage",
|
||||
"widget.display.upcoming_desc": "Termine der nächsten 7 Tage.",
|
||||
"widget.display.thisweek_title": "Diese Woche",
|
||||
"widget.display.thisweek_desc": "Wochenraster mit Terminen.",
|
||||
"widget.display.twoweeks_title": "Zwei Wochen",
|
||||
"widget.display.twoweeks_desc": "Zwei-Wochen-Raster mit Terminen.",
|
||||
"widget.display.threedays_title": "Drei Tage",
|
||||
"widget.display.threedays_desc": "Drei-Tages-Ansicht mit Terminen.",
|
||||
"widget.display.upnext_title": "Up Next + Kalender",
|
||||
"widget.display.upnext_desc": "Nächste Termine mit Monatsübersicht.",
|
||||
"widget.display.calday_title": "Tag & Termine",
|
||||
"widget.display.calday_desc": "Datum, Wochenübersicht und nächste Termine.",
|
||||
"widget.display.lockscreen_title": "Datum",
|
||||
"widget.display.lockscreen_desc": "Aktuelles Datum und nächster Termin.",
|
||||
"widget.display.twomonth_title": "Zwei Monate",
|
||||
"widget.display.twomonth_desc": "Aktueller und nächster Monat auf einen Blick.",
|
||||
"widget.display.nownext_title": "Jetzt & Nächstes",
|
||||
"widget.display.nownext_desc": "Aktueller Termin und nächste Ereignisse.",
|
||||
"widget.cw": "KW",
|
||||
"widget.running": "Läuft",
|
||||
"widget.events_count": "Termine",
|
||||
"widget.display.lockscreen_count_title": "Termine heute",
|
||||
"widget.display.lockscreen_count_desc": "Anzahl und Liste heutiger Termine.",
|
||||
"widget.display.lockscreen_countdown_title": "Countdown",
|
||||
"widget.display.lockscreen_countdown_desc": "Zeit bis zum nächsten Termin."
|
||||
],
|
||||
"en": [
|
||||
"widget.today": "Today",
|
||||
"widget.tomorrow": "Tomorrow",
|
||||
"widget.no_events": "No events",
|
||||
"widget.allday": "All-day",
|
||||
"widget.more": "+%d more",
|
||||
"widget.upcoming": "Next 7 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 7 days",
|
||||
"widget.display.upcoming_desc": "Events for the next 7 days.",
|
||||
"widget.display.thisweek_title": "This Week",
|
||||
"widget.display.thisweek_desc": "Week grid with events.",
|
||||
"widget.display.twoweeks_title": "Two Weeks",
|
||||
"widget.display.twoweeks_desc": "Two-week grid with events.",
|
||||
"widget.display.threedays_title": "Three Days",
|
||||
"widget.display.threedays_desc": "Three-day view with events.",
|
||||
"widget.display.upnext_title": "Up Next + Calendar",
|
||||
"widget.display.upnext_desc": "Next events with month overview.",
|
||||
"widget.display.calday_title": "Day & Events",
|
||||
"widget.display.calday_desc": "Date, week overview and upcoming events.",
|
||||
"widget.display.lockscreen_title": "Date",
|
||||
"widget.display.lockscreen_desc": "Current date and next event.",
|
||||
"widget.display.twomonth_title": "Two Months",
|
||||
"widget.display.twomonth_desc": "Current and next month at a glance.",
|
||||
"widget.display.nownext_title": "Now & Next",
|
||||
"widget.display.nownext_desc": "Current event and upcoming events.",
|
||||
"widget.cw": "W",
|
||||
"widget.running": "Running",
|
||||
"widget.events_count": "Events",
|
||||
"widget.display.lockscreen_count_title": "Today's Events",
|
||||
"widget.display.lockscreen_count_desc": "Count and list of today's events.",
|
||||
"widget.display.lockscreen_countdown_title": "Countdown",
|
||||
"widget.display.lockscreen_countdown_desc": "Time until your next event."
|
||||
]
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user