diff --git a/Calendarr iOS/Views/Calendar/EventDetailSheet.swift b/Calendarr iOS/Views/Calendar/EventDetailSheet.swift index f73cef7..a1e7aa1 100644 --- a/Calendarr iOS/Views/Calendar/EventDetailSheet.swift +++ b/Calendarr iOS/Views/Calendar/EventDetailSheet.swift @@ -4,11 +4,16 @@ struct EventDetailSheet: View { let event: CalEvent let api: CalendarrAPI let store: CalendarStore - let onDone: (CalEvent?) async -> Void + /// Called when the sheet should close. + /// - `editEvent`: non-nil when the user wants to edit this event + /// - `forceReload`: true when server data changed (create/copy) and the + /// caller must bypass the cache to fetch fresh events + let onDone: (_ editEvent: CalEvent?, _ forceReload: Bool) async -> Void @Environment(\.dismiss) var dismiss @State private var showDeleteConfirm = false @State private var isDeleting = false + @State private var showCopySheet = false private let timeFmt: DateFormatter = { let f = DateFormatter() @@ -92,6 +97,16 @@ struct EventDetailSheet: View { } } + if !store.writableCalendars.isEmpty { + Section { + Button { + showCopySheet = true + } label: { + Label("Termin kopieren", systemImage: "doc.on.doc") + } + } + } + if canDelete { Section { Button(role: .destructive) { @@ -110,13 +125,13 @@ struct EventDetailSheet: View { .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Schliessen") { - Task { await onDone(nil) } + Task { await onDone(nil, false) } } } if canEdit { ToolbarItem(placement: .primaryAction) { Button("Bearbeiten") { - Task { await onDone(event) } + Task { await onDone(event, false) } } } } @@ -129,6 +144,18 @@ struct EventDetailSheet: View { } message: { Text("\"\(event.title)\" wird dauerhaft gelöscht.") } + .sheet(isPresented: $showCopySheet) { + EventEditorSheet( + api: api, + store: store, + initialDate: event.startDate, + editingEvent: nil, + copyFrom: event + ) { + // Copy created a new server-side event → force reload so it appears + await onDone(nil, true) + } + } } } @@ -149,7 +176,7 @@ struct EventDetailSheet: View { // Optimistically drop it from the cache so it vanishes immediately, // regardless of how long the source takes to propagate the delete. store.removeCachedEvent(id: event.id) - await onDone(nil) + await onDone(nil, false) } catch { isDeleting = false } diff --git a/Calendarr iOS/Views/Calendar/EventEditorSheet.swift b/Calendarr iOS/Views/Calendar/EventEditorSheet.swift index 1cf0f51..a967b45 100644 --- a/Calendarr iOS/Views/Calendar/EventEditorSheet.swift +++ b/Calendarr iOS/Views/Calendar/EventEditorSheet.swift @@ -5,6 +5,7 @@ struct EventEditorSheet: View { let store: CalendarStore let initialDate: Date let editingEvent: CalEvent? + let copyFrom: CalEvent? let onSaved: () async -> Void @Environment(\.dismiss) var dismiss @@ -21,6 +22,7 @@ struct EventEditorSheet: View { @State private var error = "" private var isEditing: Bool { editingEvent != nil } + private var isCopying: Bool { copyFrom != nil && editingEvent == nil } private var selectedCal: WritableCalendar? { store.writableCalendars.first { $0.id == selectedCalendarId } @@ -96,9 +98,11 @@ struct EventEditorSheet: View { } } } - .navigationTitle(isEditing - ? L10n.t("event.edit_title", appLang) - : L10n.t("event.new_title", appLang)) + .navigationTitle( + isEditing ? L10n.t("event.edit_title", appLang) : + isCopying ? L10n.t("event.copy_title", appLang) : + L10n.t("event.new_title", appLang) + ) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { @@ -116,6 +120,12 @@ struct EventEditorSheet: View { } } .onAppear { setup() } + .onChange(of: startDate) { oldStart, newStart in + guard newStart >= endDate else { return } + let duration = endDate.timeIntervalSince(oldStart) + let minDuration: TimeInterval = isAllDay ? 86400 : 3600 + endDate = newStart.addingTimeInterval(max(duration, minDuration)) + } } private func setup() { @@ -128,6 +138,15 @@ struct EventEditorSheet: View { notes = ev.notes color = ev.color ?? "" selectedCalendarId = ev.calendarId + } else if let ev = copyFrom { + title = ev.title + isAllDay = ev.isAllDay + startDate = ev.startDate + endDate = ev.endDate + location = ev.location + notes = ev.notes + color = ev.color ?? "" + selectedCalendarId = store.writableCalendars.first?.id ?? "" } else { let cal = Calendar.current startDate = cal.date(bySettingHour: cal.component(.hour, from: initialDate), diff --git a/CalendarrWidgets/CalendarDayWidgetView.swift b/CalendarrWidgets/CalendarDayWidgetView.swift new file mode 100644 index 0000000..91521dc --- /dev/null +++ b/CalendarrWidgets/CalendarDayWidgetView.swift @@ -0,0 +1,151 @@ +import SwiftUI +import WidgetKit + +struct CalendarDayWidgetView: View { + let entry: CalendarrEntry + + private var snapshot: WidgetSnapshot? { entry.snapshot } + private var lang: String { snapshot?.language ?? "system" } + + private var cal: Calendar { + var c = Calendar(identifier: .gregorian) + c.locale = WidgetL10n.locale(lang) + c.firstWeekday = 2 + return c + } + + private var weekDays: [Date] { + let start = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: entry.date)) ?? entry.date + return (0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: start) } + } + + private var upcomingEvents: [WidgetEvent] { + guard let s = snapshot else { return [] } + return WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s) + } + + private var monthFmt: DateFormatter { + let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "LLLL"; return f + } + private var weekdayFmt: DateFormatter { + let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "EEEE"; return f + } + private var timeFmt: DateFormatter { + let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "HH:mm"; return f + } + + var body: some View { + if let s = snapshot { + let primary = Color(widgetHex: s.primaryColorHex) + let accent = Color(widgetHex: s.accentColorHex) + VStack(alignment: .leading, spacing: 0) { + header(primary: primary) + weekStrip(snapshot: s, primary: primary, accent: accent) + .padding(.vertical, 5) + Rectangle() + .fill(Color(widgetHex: s.lineColorHex).opacity(0.4)) + .frame(height: 0.5) + .padding(.bottom, 6) + eventList(accent: accent) + } + } else { + Text(WidgetL10n.t("widget.no_data", lang)) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + // MARK: – Header + + private func header(primary: Color) -> some View { + HStack(alignment: .top, spacing: 6) { + Text("\(cal.component(.day, from: entry.date))") + .font(.system(size: 36, weight: .bold)) + .foregroundStyle(primary) + .frame(width: 44, alignment: .leading) + .minimumScaleFactor(0.7) + VStack(alignment: .leading, spacing: 1) { + Text(monthFmt.string(from: entry.date).uppercased()) + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(primary) + Text("\(WidgetL10n.t("widget.today", lang)), \(weekdayFmt.string(from: entry.date))") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + Spacer(minLength: 0) + } + .padding(.bottom, 2) + } + + // MARK: – Week strip + + private func weekStrip(snapshot: WidgetSnapshot, primary: Color, accent: Color) -> some View { + HStack(spacing: 0) { + ForEach(weekDays, id: \.self) { day in + let isToday = cal.isDateInToday(day) + let hasEvs = !WidgetHelpers.events(for: day, in: snapshot).isEmpty + VStack(spacing: 2) { + Text(shortDay(day)) + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(isToday ? accent : .secondary) + ZStack { + if isToday { + Circle().fill(primary) + } else if hasEvs { + Circle().fill(accent.opacity(0.18)) + } + Text("\(cal.component(.day, from: day))") + .font(.system(size: 11, weight: isToday ? .bold : .medium)) + .foregroundStyle(isToday ? .white : .primary) + } + .frame(width: 22, height: 22) + } + .frame(maxWidth: .infinity) + } + } + } + + private func shortDay(_ date: Date) -> String { + let f = DateFormatter() + f.locale = WidgetL10n.locale(lang) + f.dateFormat = "EEE" + return String(f.string(from: date).prefix(2)).uppercased() + } + + // MARK: – Event list + + @ViewBuilder + private func eventList(accent: Color) -> some View { + if upcomingEvents.isEmpty { + Text(WidgetL10n.t("widget.no_events", lang)) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + } else { + VStack(alignment: .leading, spacing: 5) { + ForEach(upcomingEvents.prefix(3)) { ev in + HStack(alignment: .center, spacing: 6) { + RoundedRectangle(cornerRadius: 1.5) + .fill(Color(widgetHex: ev.colorHex)) + .frame(width: 3, height: 26) + VStack(alignment: .leading, spacing: 1) { + Text(ev.title) + .font(.system(size: 11, weight: .semibold)) + .lineLimit(1) + Text(ev.isAllDay + ? WidgetL10n.t("widget.allday", lang) + : "\(timeFmt.string(from: ev.start)) – \(timeFmt.string(from: ev.end))") + .font(.system(size: 9)) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + } + Spacer(minLength: 0) + } + } + } +} diff --git a/CalendarrWidgets/CalendarrWidgets.swift b/CalendarrWidgets/CalendarrWidgets.swift index 9511c9e..67e59bb 100644 --- a/CalendarrWidgets/CalendarrWidgets.swift +++ b/CalendarrWidgets/CalendarrWidgets.swift @@ -11,10 +11,12 @@ struct CalendarrWidgetBundle: WidgetBundle { TwoWeeksWidget() UpcomingWidget() UpNextWidget() + CalendarDayWidget() + LockScreenWidget() } } -// Shared chrome modifier — keeps every widget on the same theme. +// Shared chrome modifier — keeps every home-screen widget on the same theme. private struct CalendarrWidgetChrome: ViewModifier { let snapshot: WidgetSnapshot? @@ -139,3 +141,35 @@ struct UpNextWidget: Widget { .supportedFamilies([.systemMedium]) } } + +// MARK: – Calendar Day: date + week strip + events (medium) + +struct CalendarDayWidget: Widget { + let kind: String = "CalendarDayWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + CalendarDayWidgetView(entry: entry).calendarrChrome(entry.snapshot) + } + .configurationDisplayName(WidgetL10n.t("widget.display.calday_title", "system")) + .description(WidgetL10n.t("widget.display.calday_desc", "system")) + .supportedFamilies([.systemMedium]) + } +} + +// MARK: – Lock Screen (circular, rectangular, inline) + +struct LockScreenWidget: Widget { + let kind: String = "LockScreenWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: CalendarrTimelineProvider()) { entry in + LockScreenWidgetView(entry: entry) + .containerBackground(for: .widget) { Color.clear } + .environment(\.locale, WidgetL10n.locale(entry.snapshot?.language ?? "system")) + } + .configurationDisplayName(WidgetL10n.t("widget.display.lockscreen_title", "system")) + .description(WidgetL10n.t("widget.display.lockscreen_desc", "system")) + .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline]) + } +} diff --git a/CalendarrWidgets/LockScreenWidgetViews.swift b/CalendarrWidgets/LockScreenWidgetViews.swift new file mode 100644 index 0000000..63b3771 --- /dev/null +++ b/CalendarrWidgets/LockScreenWidgetViews.swift @@ -0,0 +1,99 @@ +import SwiftUI +import WidgetKit + +struct LockScreenWidgetView: View { + let entry: CalendarrEntry + @Environment(\.widgetFamily) private var family + + private var snapshot: WidgetSnapshot? { entry.snapshot } + private var lang: String { snapshot?.language ?? "system" } + + private var cal: Calendar { + var c = Calendar(identifier: .gregorian) + c.locale = WidgetL10n.locale(lang) + return c + } + + private var nextEvent: WidgetEvent? { + guard let s = snapshot else { return nil } + return WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s).first + } + + private var timeFmt: DateFormatter { + let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "HH:mm"; return f + } + + private var monthAbbrev: String { + let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "LLL" + return f.string(from: entry.date).uppercased() + } + + @ViewBuilder + var body: some View { + switch family { + case .accessoryCircular: + circularView + case .accessoryRectangular: + rectangularView + default: + inlineView + } + } + + // MARK: – Circular: today's date + + private var circularView: some View { + ZStack { + AccessoryWidgetBackground() + VStack(spacing: 0) { + Text("\(cal.component(.day, from: entry.date))") + .font(.system(size: 22, weight: .bold)) + .minimumScaleFactor(0.7) + .widgetAccentable() + Text(monthAbbrev) + .font(.system(size: 8, weight: .semibold)) + } + } + } + + // MARK: – Rectangular: next event + + private var rectangularView: some View { + VStack(alignment: .leading, spacing: 2) { + if let ev = nextEvent { + Text(ev.isAllDay + ? WidgetL10n.t("widget.allday", lang) + : timeFmt.string(from: ev.start)) + .font(.system(size: 11, weight: .semibold)) + .widgetAccentable() + Text(ev.title) + .font(.system(size: 14, weight: .bold)) + .lineLimit(1) + if !ev.location.isEmpty { + Text(ev.location) + .font(.system(size: 11)) + .lineLimit(1) + } + } else { + Image(systemName: "calendar") + .font(.system(size: 13)) + .widgetAccentable() + Text(WidgetL10n.t("widget.no_events", lang)) + .font(.system(size: 13)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + // MARK: – Inline: brief next event + + private var inlineView: some View { + let text: String = { + guard let ev = nextEvent else { + return WidgetL10n.t("widget.no_events", lang) + } + return ev.isAllDay ? ev.title : "\(timeFmt.string(from: ev.start)) \(ev.title)" + }() + return Label(text, systemImage: "calendar") + } +} diff --git a/CalendarrWidgets/NowNextWidgetView.swift b/CalendarrWidgets/NowNextWidgetView.swift new file mode 100644 index 0000000..c8f724e --- /dev/null +++ b/CalendarrWidgets/NowNextWidgetView.swift @@ -0,0 +1,162 @@ +import SwiftUI +import WidgetKit + +struct NowNextWidgetView: View { + let entry: CalendarrEntry + + private var snapshot: WidgetSnapshot? { entry.snapshot } + private var lang: String { snapshot?.language ?? "system" } + + private var cal: Calendar { + var c = Calendar(identifier: .gregorian) + c.locale = WidgetL10n.locale(lang) + return c + } + + private var timeFmt: DateFormatter { + let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "HH:mm"; return f + } + private var dayOfWeekFmt: DateFormatter { + let f = DateFormatter(); f.locale = WidgetL10n.locale(lang); f.dateFormat = "EEEE"; return f + } + + // Currently running event, or next upcoming timed event, or first all-day event + private var featuredEvent: WidgetEvent? { + guard let s = snapshot else { return nil } + let pool = WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s) + if let running = pool.first(where: { !$0.isAllDay && $0.start <= entry.date }) { return running } + if let next = pool.first(where: { !$0.isAllDay }) { return next } + return pool.first + } + + // All upcoming events today except the featured one + private var remainingEvents: [WidgetEvent] { + guard let s = snapshot else { return [] } + let pool = WidgetHelpers.upcoming(from: entry.date, daysAhead: 1, in: s) + guard let featured = featuredEvent else { return pool } + return pool.filter { $0.id != featured.id } + } + + private func timeRange(_ ev: WidgetEvent) -> String { + ev.isAllDay + ? WidgetL10n.t("widget.allday", lang) + : "\(timeFmt.string(from: ev.start)) – \(timeFmt.string(from: ev.end))" + } + + var body: some View { + if let s = snapshot { + let line = Color(widgetHex: s.lineColorHex) + VStack(spacing: 6) { + featuredCard(snapshot: s) + bottomRow(line: line) + } + } else { + Text(WidgetL10n.t("widget.no_data", lang)) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + // MARK: – Featured event card + + private func featuredCard(snapshot: WidgetSnapshot) -> some View { + let ev = featuredEvent + let baseColor = ev.map { Color(widgetHex: $0.colorHex) } ?? Color.accentColor.opacity(0.5) + + return ZStack(alignment: .leading) { + LinearGradient( + colors: [baseColor.opacity(0.75), baseColor], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 2) { + Text(ev?.title ?? WidgetL10n.t("widget.no_events", lang)) + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(.white) + .lineLimit(1) + Text(ev.map { timeRange($0) } ?? "") + .font(.system(size: 10)) + .foregroundStyle(.white.opacity(0.85)) + } + .padding(.leading, 10) + .padding(.vertical, 9) + Spacer() + if ev != nil { + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white.opacity(0.7)) + .padding(.trailing, 12) + } + } + } + .frame(maxWidth: .infinity) + .fixedSize(horizontal: false, vertical: true) + } + + // MARK: – Bottom: date + event list + + private func bottomRow(line: Color) -> some View { + HStack(alignment: .top, spacing: 0) { + // Left: day name + large number + VStack(alignment: .leading, spacing: 0) { + Text(dayOfWeekFmt.string(from: entry.date).uppercased()) + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.6) + Text("\(cal.component(.day, from: entry.date))") + .font(.system(size: 30, weight: .light)) + } + .frame(width: 50, alignment: .leading) + + // Divider + line.opacity(0.4).frame(width: 0.5) + .padding(.horizontal, 6) + + // Right: event list + VStack(alignment: .leading, spacing: 4) { + let shown = remainingEvents.prefix(2) + if shown.isEmpty { + Text(WidgetL10n.t("widget.no_events", lang)) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + } else { + ForEach(shown) { ev in + HStack(alignment: .firstTextBaseline, spacing: 5) { + Circle() + .fill(Color(widgetHex: ev.colorHex)) + .frame(width: 7, height: 7) + .padding(.top, 1) + VStack(alignment: .leading, spacing: 0) { + Text(ev.title) + .font(.system(size: 11, weight: .semibold)) + .lineLimit(1) + Text(timeRange(ev)) + .font(.system(size: 9)) + .foregroundStyle(.secondary) + } + } + } + } + Spacer(minLength: 0) + } + + Spacer(minLength: 0) + + // +N badge + if remainingEvents.count > 2 { + Text("+\(remainingEvents.count - 2)") + .font(.system(size: 9, weight: .semibold)) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(.secondary.opacity(0.18), in: Capsule()) + .frame(maxHeight: .infinity, alignment: .bottom) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/CalendarrWidgets/ThisWeekWidgetView.swift b/CalendarrWidgets/ThisWeekWidgetView.swift index e547ade..0bdf1bd 100644 --- a/CalendarrWidgets/ThisWeekWidgetView.swift +++ b/CalendarrWidgets/ThisWeekWidgetView.swift @@ -10,7 +10,7 @@ struct ThisWeekWidgetView: View { private var cal: Calendar { var c = Calendar(identifier: .gregorian) c.locale = WidgetL10n.locale(lang) - c.firstWeekday = 2 // Monday + c.firstWeekday = 2 return c } @@ -40,7 +40,7 @@ struct ThisWeekWidgetView: View { if let s = snapshot { let primary = Color(widgetHex: s.primaryColorHex) let accent = Color(widgetHex: s.accentColorHex) - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 3) { HStack(alignment: .firstTextBaseline, spacing: 4) { Text(monthHeader.split(separator: " ").first.map(String.init) ?? "") .font(.system(size: 10, weight: .bold)) @@ -48,22 +48,21 @@ struct ThisWeekWidgetView: View { Text(monthHeader.split(separator: " ").dropFirst().joined(separator: " ")) .font(.system(size: 10, weight: .semibold)) } - GeometryReader { geo in - let colW = geo.size.width / 7 - HStack(spacing: 0) { - ForEach(Array(weekDays.enumerated()), id: \.offset) { idx, day in - dayColumn(day, snapshot: s, primary: primary, accent: accent) - .frame(width: colW) - .overlay(alignment: .trailing) { - if idx < 6 { - Rectangle() - .fill(Color(widgetHex: s.lineColorHex).opacity(0.35)) - .frame(width: 0.5) - } + // Equal-width columns via maxWidth — no GeometryReader needed + HStack(spacing: 0) { + ForEach(Array(weekDays.enumerated()), id: \.offset) { idx, day in + dayColumn(day, snapshot: s, primary: primary, accent: accent) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .overlay(alignment: .trailing) { + if idx < 6 { + Rectangle() + .fill(Color(widgetHex: s.lineColorHex).opacity(0.35)) + .frame(width: 0.5) } - } + } } } + .frame(maxWidth: .infinity, maxHeight: .infinity) } } else { Text(WidgetL10n.t("widget.no_data", lang)) @@ -80,22 +79,22 @@ struct ThisWeekWidgetView: View { let isToday = cal.isDateInToday(day) let evs = WidgetHelpers.events(for: day, in: snapshot) let dayIdx = (0..<7).firstIndex { i in cal.isDate(weekDays[i], inSameDayAs: day) } ?? 0 - return VStack(spacing: 1) { + return VStack(alignment: .center, spacing: 1) { Text(weekdayHeaders[dayIdx]) .font(.system(size: 7.5, weight: .bold)) .foregroundStyle(isToday ? accent : .secondary) Text("\(cal.component(.day, from: day))") .font(.system(size: 10, weight: isToday ? .bold : .semibold)) .foregroundStyle(isToday ? Color.white : Color.primary) - .frame(width: 15, height: 15) + .frame(width: 16, height: 16) .background(isToday ? primary : Color.clear) .clipShape(Circle()) - ForEach(evs.prefix(2)) { ev in + ForEach(evs.prefix(3)) { ev in eventPill(ev) } - if evs.count > 2 { - Text("+\(evs.count - 2)") - .font(.system(size: 7)) + if evs.count > 3 { + Text("+\(evs.count - 3)") + .font(.system(size: 6.5)) .foregroundStyle(accent) } Spacer(minLength: 0) diff --git a/CalendarrWidgets/TwoMonthWidgetView.swift b/CalendarrWidgets/TwoMonthWidgetView.swift new file mode 100644 index 0000000..2838ca3 --- /dev/null +++ b/CalendarrWidgets/TwoMonthWidgetView.swift @@ -0,0 +1,170 @@ +import SwiftUI +import WidgetKit + +struct TwoMonthWidgetView: View { + let entry: CalendarrEntry + @Environment(\.widgetFamily) private var family + + private var snapshot: WidgetSnapshot? { entry.snapshot } + private var lang: String { snapshot?.language ?? "system" } + + private var cal: Calendar { + var c = Calendar(identifier: .gregorian) + c.locale = WidgetL10n.locale(lang) + c.firstWeekday = 2 + return c + } + + private var thisMonth: Date { + cal.date(from: cal.dateComponents([.year, .month], from: entry.date)) ?? entry.date + } + + private var nextMonth: Date { + cal.date(byAdding: .month, value: 1, to: thisMonth) ?? thisMonth + } + + // Weekday header labels (M T W T F S S) + private var weekdayHeaders: [String] { + let f = DateFormatter(); f.locale = WidgetL10n.locale(lang) + let symbols = f.veryShortWeekdaySymbols ?? cal.veryShortWeekdaySymbols + let start = cal.firstWeekday - 1 + return (0..<7).map { String(symbols[(start + $0) % 7]).uppercased() } + } + + // Number of date rows to show (5 for medium, 6 for large) + private var rowCount: Int { family == .systemLarge ? 6 : 5 } + + var body: some View { + if let s = snapshot { + let primary = Color(widgetHex: s.primaryColorHex) + let accent = Color(widgetHex: s.accentColorHex) + let line = Color(widgetHex: s.lineColorHex) + HStack(alignment: .top, spacing: 0) { + monthColumn(monthDate: thisMonth, snapshot: s, + primary: primary, accent: accent, line: line) + .frame(maxWidth: .infinity) + line.opacity(0.35).frame(width: 0.5) + .padding(.horizontal, 3) + monthColumn(monthDate: nextMonth, snapshot: s, + primary: primary, accent: accent, line: line) + .frame(maxWidth: .infinity) + } + } else { + Text(WidgetL10n.t("widget.no_data", lang)) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + // MARK: – One month column + + private func monthColumn(monthDate: Date, + snapshot: WidgetSnapshot, + primary: Color, + accent: Color, + line: Color) -> some View { + let monthFmt = DateFormatter() + monthFmt.locale = WidgetL10n.locale(lang) + monthFmt.dateFormat = "LLLL" + let name = monthFmt.string(from: monthDate).uppercased() + let start = gridStart(for: monthDate) + let wn = WidgetL10n.t("widget.cw", lang) + + return VStack(alignment: .leading, spacing: 1) { + // Month name + Text(name) + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(primary) + + // Column headers: KW + 7 weekdays + HStack(spacing: 0) { + Text(wn) + .font(.system(size: 6, weight: .bold)) + .foregroundStyle(.secondary) + .frame(width: 14, alignment: .center) + ForEach(weekdayHeaders, id: \.self) { h in + Text(h) + .font(.system(size: 6.5, weight: .bold)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + } + } + + // Date rows + ForEach(0.. 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 + } +} diff --git a/CalendarrWidgets/WidgetSupport.swift b/CalendarrWidgets/WidgetSupport.swift index 56c1aa4..f080405 100644 --- a/CalendarrWidgets/WidgetSupport.swift +++ b/CalendarrWidgets/WidgetSupport.swift @@ -48,20 +48,24 @@ enum WidgetL10n { "widget.more": "+%d weitere", "widget.upcoming": "Nächste 5 Tage", "widget.no_data": "Keine Daten – App einmal öffnen", - "widget.display.today_title": "Heute", - "widget.display.today_desc": "Heutige Termine auf einen Blick.", - "widget.display.days_title": "Heute & Morgen", - "widget.display.days_desc": "Termine der nächsten zwei Tage.", - "widget.display.upcoming_title": "Nächste 5 Tage", - "widget.display.upcoming_desc": "Termine der nächsten 5 Tage.", - "widget.display.thisweek_title": "Diese Woche", - "widget.display.thisweek_desc": "Wochenraster mit Terminen.", - "widget.display.twoweeks_title": "Zwei Wochen", - "widget.display.twoweeks_desc": "Zwei-Wochen-Raster mit Terminen.", - "widget.display.threedays_title": "Drei Tage", - "widget.display.threedays_desc": "Drei-Tages-Ansicht mit Terminen.", - "widget.display.upnext_title": "Up Next + Kalender", - "widget.display.upnext_desc": "Nächste Termine mit Monatsübersicht." + "widget.display.today_title": "Heute", + "widget.display.today_desc": "Heutige Termine auf einen Blick.", + "widget.display.days_title": "Heute & Morgen", + "widget.display.days_desc": "Termine der nächsten zwei Tage.", + "widget.display.upcoming_title": "Nächste 5 Tage", + "widget.display.upcoming_desc": "Termine der nächsten 5 Tage.", + "widget.display.thisweek_title": "Diese Woche", + "widget.display.thisweek_desc": "Wochenraster mit Terminen.", + "widget.display.twoweeks_title": "Zwei Wochen", + "widget.display.twoweeks_desc": "Zwei-Wochen-Raster mit Terminen.", + "widget.display.threedays_title": "Drei Tage", + "widget.display.threedays_desc": "Drei-Tages-Ansicht mit Terminen.", + "widget.display.upnext_title": "Up Next + Kalender", + "widget.display.upnext_desc": "Nächste Termine mit Monatsübersicht.", + "widget.display.calday_title": "Tag & Termine", + "widget.display.calday_desc": "Datum, Wochenübersicht und nächste Termine.", + "widget.display.lockscreen_title": "Sperrbildschirm", + "widget.display.lockscreen_desc": "Datum und nächster Termin auf dem Sperrbildschirm." ], "en": [ "widget.today": "Today", @@ -71,20 +75,24 @@ enum WidgetL10n { "widget.more": "+%d more", "widget.upcoming": "Next 5 days", "widget.no_data": "No data – open the app once", - "widget.display.today_title": "Today", - "widget.display.today_desc": "Today's events at a glance.", - "widget.display.days_title": "Today & tomorrow", - "widget.display.days_desc": "Events for the next two days.", - "widget.display.upcoming_title": "Next 5 days", - "widget.display.upcoming_desc": "Events for the next 5 days.", - "widget.display.thisweek_title": "This Week", - "widget.display.thisweek_desc": "Week grid with events.", - "widget.display.twoweeks_title": "Two Weeks", - "widget.display.twoweeks_desc": "Two-week grid with events.", - "widget.display.threedays_title": "Three Days", - "widget.display.threedays_desc": "Three-day view with events.", - "widget.display.upnext_title": "Up Next + Calendar", - "widget.display.upnext_desc": "Next events with month overview." + "widget.display.today_title": "Today", + "widget.display.today_desc": "Today's events at a glance.", + "widget.display.days_title": "Today & tomorrow", + "widget.display.days_desc": "Events for the next two days.", + "widget.display.upcoming_title": "Next 5 days", + "widget.display.upcoming_desc": "Events for the next 5 days.", + "widget.display.thisweek_title": "This Week", + "widget.display.thisweek_desc": "Week grid with events.", + "widget.display.twoweeks_title": "Two Weeks", + "widget.display.twoweeks_desc": "Two-week grid with events.", + "widget.display.threedays_title": "Three Days", + "widget.display.threedays_desc": "Three-day view with events.", + "widget.display.upnext_title": "Up Next + Calendar", + "widget.display.upnext_desc": "Next events with month overview.", + "widget.display.calday_title": "Day & Events", + "widget.display.calday_desc": "Date, week overview and upcoming events.", + "widget.display.lockscreen_title": "Lock Screen", + "widget.display.lockscreen_desc": "Date and next event on the lock screen." ] ] }