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,111 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AgendaView: View {
|
||||
let store: CalendarStore
|
||||
let onEventTap: (CalEvent) -> Void
|
||||
@AppStorage("appLanguage") private var appLang = "system"
|
||||
|
||||
private var cal: Calendar { store.userCalendar }
|
||||
|
||||
private var grouped: [(Date, [CalEvent])] {
|
||||
let start = cal.startOfDay(for: .now)
|
||||
let end = cal.date(byAdding: .day, value: 90, to: start)!
|
||||
var dict: [Date: [CalEvent]] = [:]
|
||||
for ev in store.events(in: start, end: end) {
|
||||
let key = cal.startOfDay(for: ev.startDate)
|
||||
dict[key, default: []].append(ev)
|
||||
}
|
||||
return dict.keys.sorted().map { ($0, dict[$0]!.sorted { $0.startDate < $1.startDate }) }
|
||||
}
|
||||
|
||||
private var dayFmt: DateFormatter {
|
||||
let f = DateFormatter()
|
||||
f.locale = L10n.locale(appLang)
|
||||
f.dateFormat = "EEEE, d. MMMM yyyy"
|
||||
return f
|
||||
}
|
||||
|
||||
private var timeFmt: DateFormatter {
|
||||
let f = DateFormatter()
|
||||
f.locale = L10n.locale(appLang)
|
||||
f.timeStyle = .short
|
||||
f.dateStyle = .none
|
||||
return f
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if grouped.isEmpty {
|
||||
ContentUnavailableView(
|
||||
L10n.t("cal.no_events_title", appLang),
|
||||
systemImage: "calendar",
|
||||
description: Text(L10n.t("cal.no_events_body", appLang))
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
ForEach(grouped, id: \.0) { day, evs in
|
||||
Section {
|
||||
ForEach(evs) { ev in
|
||||
Button { onEventTap(ev) } label: {
|
||||
AgendaEventRow(event: ev, timeFmt: timeFmt, allDayLabel: L10n.t("cal.allday", appLang))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
} header: {
|
||||
Text(dayFmt.string(from: day))
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(cal.isDateInToday(day) ? Color.accentColor : .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AgendaEventRow: View {
|
||||
let event: CalEvent
|
||||
let timeFmt: DateFormatter
|
||||
let allDayLabel: String
|
||||
|
||||
var timeString: String {
|
||||
if event.isAllDay { return allDayLabel }
|
||||
return timeFmt.string(from: event.startDate)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color(hex: event.effectiveColor))
|
||||
.frame(width: 4, height: 40)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
EventLabel(event: event)
|
||||
.font(.body.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
HStack(spacing: 6) {
|
||||
Text(timeString)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if !event.location.isEmpty {
|
||||
Text("·")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(event.location)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Text(event.calendarName)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Minimal "new birthday" mask for the single birthday calendar: enter a name
|
||||
/// and a date (optionally "year unknown"). Saves an all-day, yearly-recurring
|
||||
/// local event; the server adds the age suffix and cake icon on read. If no
|
||||
/// birthday calendar exists yet, offers to activate one.
|
||||
struct BirthdayEditorSheet: View {
|
||||
let api: CalendarrAPI
|
||||
var onDone: () async -> Void
|
||||
|
||||
@AppStorage("appLanguage") private var appLang = "system"
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var calendar: LocalCalendar? = nil
|
||||
@State private var name = ""
|
||||
@State private var date = Date()
|
||||
@State private var yearUnknown = false
|
||||
@State private var loading = true
|
||||
@State private var saving = false
|
||||
@State private var activating = false
|
||||
|
||||
private var canSave: Bool {
|
||||
!saving && calendar != nil
|
||||
&& !name.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
if loading {
|
||||
HStack { Spacer(); ProgressView(); Spacer() }
|
||||
} else if calendar == nil {
|
||||
Section {
|
||||
Text(L10n.t("birthday.activate_hint", appLang))
|
||||
.foregroundStyle(.secondary)
|
||||
Button(L10n.t("birthday.activate", appLang)) { Task { await activate() } }
|
||||
.disabled(activating)
|
||||
}
|
||||
} else {
|
||||
Section(L10n.t("birthday.person", appLang)) {
|
||||
TextField(L10n.t("birthday.person_placeholder", appLang), text: $name)
|
||||
}
|
||||
Section(L10n.t("birthday.date", appLang)) {
|
||||
DatePicker(L10n.t("birthday.date", appLang), selection: $date,
|
||||
displayedComponents: [.date])
|
||||
Toggle(L10n.t("birthday.year_unknown", appLang), isOn: $yearUnknown)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(L10n.t("birthday.new", appLang))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(L10n.t("common.cancel", appLang)) { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(L10n.t("event.save", appLang)) { Task { await save() } }
|
||||
.disabled(!canSave)
|
||||
}
|
||||
}
|
||||
.task { await load() }
|
||||
}
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
calendar = await BirthdaysImporter.birthdayCalendar(api: api)
|
||||
loading = false
|
||||
}
|
||||
|
||||
private func activate() async {
|
||||
activating = true
|
||||
calendar = await BirthdaysImporter.ensureBirthdayCalendar(api: api)
|
||||
activating = false
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
guard let cal = calendar else { return }
|
||||
saving = true
|
||||
let calc = Calendar.current
|
||||
let comps = calc.dateComponents([.year, .month, .day], from: date)
|
||||
let month = comps.month ?? 1
|
||||
let day = comps.day ?? 1
|
||||
let year = yearUnknown ? nil : comps.year
|
||||
// All-day anchor at local noon; anchor year = birth year (else 1970) so
|
||||
// the yearly rule expands across any queried range.
|
||||
var anchor = DateComponents()
|
||||
anchor.year = year ?? 1970
|
||||
anchor.month = month
|
||||
anchor.day = day
|
||||
anchor.hour = 12
|
||||
let start = calc.date(from: anchor) ?? date
|
||||
let end = calc.date(byAdding: .day, value: 1, to: start) ?? start
|
||||
_ = try? await api.createLocalEvent(
|
||||
calendarId: cal.id, title: name.trimmingCharacters(in: .whitespaces),
|
||||
start: start, end: end, isAllDay: true, location: "", description: "",
|
||||
color: nil, rrule: "FREQ=YEARLY", birthYear: year
|
||||
)
|
||||
await onDone()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The left side drawer: calendar visibility + group/view switching, plus a
|
||||
/// single entry into the full menu (Settings/Accounts/Profile/…/Sync/Logout).
|
||||
struct CalendarDrawer: View {
|
||||
let api: CalendarrAPI
|
||||
let store: CalendarStore
|
||||
let groups: [CalGroup]
|
||||
let onSwitchGroup: (CalGroup?) -> Void
|
||||
let onSelectView: (CalViewType) -> Void
|
||||
let onOpenMenu: () -> Void
|
||||
let onSync: () -> Void
|
||||
let onClose: () -> Void
|
||||
|
||||
@Environment(AppState.self) private var appState
|
||||
@AppStorage("appLanguage") private var appLang = "system"
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
viewSwitcher
|
||||
if !groups.isEmpty {
|
||||
Divider()
|
||||
groupSwitcher
|
||||
}
|
||||
Divider()
|
||||
CalendarFilterContent(api: api, store: store)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.background(Color(.systemBackground))
|
||||
}
|
||||
|
||||
// MARK: – Header
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 14) {
|
||||
Circle()
|
||||
.fill(Color.accentColor)
|
||||
.frame(width: 48, height: 48)
|
||||
.overlay {
|
||||
Text(appState.username.prefix(1).uppercased())
|
||||
.font(.title3.bold()).foregroundStyle(.white)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(appState.username).font(.title3.weight(.semibold)).lineLimit(1)
|
||||
Text(appState.serverURL
|
||||
.replacingOccurrences(of: "https://", with: "")
|
||||
.replacingOccurrences(of: "http://", with: ""))
|
||||
.font(.footnote).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 20) {
|
||||
Button { onSync() } label: {
|
||||
Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 19, weight: .medium))
|
||||
}
|
||||
.buttonStyle(.plain).foregroundStyle(Color.accentColor)
|
||||
.accessibilityLabel(L10n.t("menu.sync", appLang))
|
||||
Button { onOpenMenu() } label: {
|
||||
Image(systemName: "gearshape").font(.system(size: 19, weight: .medium))
|
||||
}
|
||||
.buttonStyle(.plain).foregroundStyle(Color.accentColor)
|
||||
.accessibilityLabel(L10n.t("menu.section.settings", appLang))
|
||||
Button { onClose() } label: {
|
||||
Image(systemName: "xmark").font(.system(size: 17, weight: .semibold))
|
||||
}
|
||||
.buttonStyle(.plain).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 18).padding(.top, 20).padding(.bottom, 16)
|
||||
}
|
||||
|
||||
// MARK: – View switcher
|
||||
|
||||
private var viewSwitcher: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(CalViewType.allCases, id: \.self) { vt in
|
||||
let selected = store.viewType == vt
|
||||
Button { onSelectView(vt) } label: {
|
||||
Label(vt.label(appLang), systemImage: vt.systemImage)
|
||||
.font(.caption.weight(.medium))
|
||||
.padding(.horizontal, 12).padding(.vertical, 7)
|
||||
.background(selected ? Color.accentColor : Color(.secondarySystemBackground))
|
||||
.foregroundStyle(selected ? .white : .primary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16).padding(.vertical, 13)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Group switcher
|
||||
|
||||
private var groupSwitcher: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
chip(label: L10n.t("groups.personal", appLang),
|
||||
systemImage: "person",
|
||||
selected: store.activeGroup == nil) { onSwitchGroup(nil) }
|
||||
ForEach(groups) { g in
|
||||
chip(label: g.name,
|
||||
systemImage: GroupIcons.symbol(g.icon),
|
||||
selected: store.activeGroup?.id == g.id) { onSwitchGroup(g) }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16).padding(.vertical, 13)
|
||||
}
|
||||
}
|
||||
|
||||
private func chip(label: String, systemImage: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Label(label, systemImage: systemImage)
|
||||
.font(.caption.weight(.medium))
|
||||
.padding(.horizontal, 12).padding(.vertical, 7)
|
||||
.background(selected ? Color.accentColor : Color(.secondarySystemBackground))
|
||||
.foregroundStyle(selected ? .white : .primary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
import SwiftUI
|
||||
|
||||
private enum CalEditorContext: Identifiable {
|
||||
case create(Date)
|
||||
case edit(CalEvent)
|
||||
var id: String {
|
||||
switch self {
|
||||
case .create(let d): return "new-\(d.timeIntervalSince1970)"
|
||||
case .edit(let ev): return "edit-\(ev.id)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CalendarHostView: View {
|
||||
let api: CalendarrAPI
|
||||
@Binding var showMenu: Bool
|
||||
|
||||
@AppStorage("liquidGlass") private var liquidGlass = false
|
||||
@AppStorage("cacheMonths") private var cacheMonths = 3
|
||||
@AppStorage("appLanguage") private var appLang = "system"
|
||||
@AppStorage("backgroundColor") private var bgHex = "#000000"
|
||||
@AppStorage("weekStartDay") private var weekStartDay = "monday"
|
||||
@AppStorage("defaultView") private var defaultView = "month"
|
||||
// Opt-in: empty keeps the translucent `.bar` material; a hex tints the top bar.
|
||||
@AppStorage("surfaceColor") private var surfaceHex = ""
|
||||
// Device-local: hide the hamburger (drawer then opens only via edge-swipe).
|
||||
@AppStorage("hideMenuButton") private var hideMenuButton = false
|
||||
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
@State private var store = CalendarStore()
|
||||
@State private var editorContext: CalEditorContext? = nil
|
||||
@State private var selectedEvent: CalEvent? = nil
|
||||
@State private var showFilter = false
|
||||
@State private var didApplyDefaultView = false
|
||||
@State private var groups: [CalGroup] = []
|
||||
@State private var showNewBirthday = false
|
||||
@State private var showDrawer = false
|
||||
|
||||
private var titleString: String {
|
||||
if store.viewType == .month {
|
||||
let f = DateFormatter()
|
||||
f.locale = L10n.locale(appLang)
|
||||
f.dateFormat = "LLLL yyyy"
|
||||
return f.string(from: store.visibleMonth).capitalized(with: L10n.locale(appLang))
|
||||
}
|
||||
return store.titleForCurrentView(language: appLang)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .leading) {
|
||||
Group {
|
||||
if liquidGlass { glassVariant } else { flatVariant }
|
||||
}
|
||||
// Dim scrim behind the open drawer.
|
||||
if showDrawer {
|
||||
Color.black.opacity(0.35)
|
||||
.ignoresSafeArea()
|
||||
.transition(.opacity)
|
||||
.onTapGesture { closeDrawer() }
|
||||
}
|
||||
// Off-canvas left drawer.
|
||||
CalendarDrawer(
|
||||
api: api, store: store, groups: groups,
|
||||
onSwitchGroup: { g in closeDrawer(); switchGroup(g) },
|
||||
onSelectView: { vt in store.viewType = vt; closeDrawer() },
|
||||
onOpenMenu: { closeDrawer(); showMenu = true },
|
||||
onSync: { closeDrawer(); Task { await syncFromServer(force: true) } },
|
||||
onClose: { closeDrawer() }
|
||||
)
|
||||
.frame(width: drawerWidth)
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
.shadow(color: .black.opacity(showDrawer ? 0.25 : 0), radius: 12, x: 4)
|
||||
.offset(x: showDrawer ? 0 : -(drawerWidth + 60))
|
||||
.animation(.easeInOut(duration: 0.25), value: showDrawer)
|
||||
// Swipe the drawer toward the left edge to close it.
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 20)
|
||||
.onEnded { v in
|
||||
if showDrawer, v.translation.width < -45,
|
||||
abs(v.translation.width) > abs(v.translation.height) {
|
||||
closeDrawer()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
// A narrow leading strip opens the drawer via edge-swipe (kept off the
|
||||
// content area so month paging / week swipe stay free).
|
||||
.overlay(alignment: .leading) {
|
||||
if !showDrawer {
|
||||
Color.clear
|
||||
.frame(width: 18)
|
||||
.frame(maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 12, coordinateSpace: .local)
|
||||
.onEnded { v in
|
||||
if v.translation.width > 45,
|
||||
abs(v.translation.width) > abs(v.translation.height) {
|
||||
withAnimation(.easeInOut(duration: 0.25)) { showDrawer = true }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var drawerWidth: CGFloat { min(UIScreen.main.bounds.width - 40, 360) }
|
||||
|
||||
private func closeDrawer() {
|
||||
withAnimation(.easeInOut(duration: 0.25)) { showDrawer = false }
|
||||
}
|
||||
|
||||
// MARK: – Loading indicator
|
||||
|
||||
@ViewBuilder
|
||||
private var loadingIndicator: some View {
|
||||
if store.isLoading || store.isCachingBackground {
|
||||
ProgressView()
|
||||
.padding(14)
|
||||
.background(.regularMaterial, in: Circle())
|
||||
.shadow(color: .black.opacity(0.12), radius: 6, y: 2)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.85)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Flat variant
|
||||
|
||||
private var flatVariant: some View {
|
||||
VStack(spacing: 0) {
|
||||
topBar
|
||||
groupBanner
|
||||
Divider()
|
||||
errorBanner
|
||||
calendarContent
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(hex: bgHex))
|
||||
.overlay(alignment: .top) {
|
||||
loadingIndicator.padding(.top, 12)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: store.isLoading || store.isCachingBackground)
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) { solidFAB }
|
||||
.modifier(calendarSheets)
|
||||
.task { await startup() }
|
||||
.onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } }
|
||||
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
|
||||
.onChange(of: cacheMonths) { _, _ in Task { await recache() } }
|
||||
.onChange(of: store.visibleMonth) { _, new in Task { await ensureLoaded(around: new) } }
|
||||
.onChange(of: scenePhase) { _, phase in if phase == .active { Task { await syncFromServer() } } }
|
||||
.onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in
|
||||
store.syncBanishedFromDefaults()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .settingsDidChange)) { _ in
|
||||
applyServerDrivenSettings(initial: false)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in
|
||||
Task { await forceReload() }
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .rescheduleReminders)) { _ in
|
||||
store.rescheduleNotifications()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Liquid Glass variant
|
||||
|
||||
private var glassVariant: some View {
|
||||
// Real iOS-26 Liquid Glass: the system NavigationStack toolbar renders the
|
||||
// glass bar (buttons). The month TITLE is NOT placed in the toolbar — the
|
||||
// system title silently fails to refresh on month change on iOS 26 — but
|
||||
// as a normal inline Text in a top safe-area inset just below the glass
|
||||
// bar, where it updates reliably (same mechanism as the flat variant).
|
||||
NavigationStack {
|
||||
calendarContent
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(hex: bgHex))
|
||||
.overlay(alignment: .top) {
|
||||
loadingIndicator.padding(.top, 12)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: store.isLoading || store.isCachingBackground)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
HStack(spacing: 2) {
|
||||
if !hideMenuButton { menuButton }
|
||||
Button { store.navigatePrev() } label: { Image(systemName: "chevron.left") }
|
||||
Button { store.navigateNext() } label: { Image(systemName: "chevron.right") }
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }.font(.callout)
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
VStack(spacing: 0) {
|
||||
Text(titleString)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
.background(.bar)
|
||||
groupBanner
|
||||
errorBanner
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) { glassFAB }
|
||||
.modifier(calendarSheets)
|
||||
.task { await startup() }
|
||||
.onChange(of: store.currentDate) { _, _ in Task { await onNavigate() } }
|
||||
.onChange(of: store.viewType) { _, _ in Task { await onNavigate() } }
|
||||
.onChange(of: cacheMonths) { _, _ in Task { await recache() } }
|
||||
.onChange(of: store.visibleMonth) { _, new in Task { await ensureLoaded(around: new) } }
|
||||
.onChange(of: scenePhase) { _, phase in if phase == .active { Task { await syncFromServer() } } }
|
||||
.onReceive(NotificationCenter.default.publisher(for: .banishedCalendarsChanged)) { _ in
|
||||
store.syncBanishedFromDefaults()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .settingsDidChange)) { _ in
|
||||
applyServerDrivenSettings(initial: false)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .manualSyncRequested)) { _ in
|
||||
Task { await forceReload() }
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .rescheduleReminders)) { _ in
|
||||
store.rescheduleNotifications()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Top bar (flat mode)
|
||||
|
||||
/// Shared bar contents (chevrons / today / title / group / view / filter / menu).
|
||||
/// Used by both the flat and the glass top bar so the inline title — which
|
||||
/// updates reliably on month change — is identical in both modes.
|
||||
@ViewBuilder private var barContents: some View {
|
||||
HStack(spacing: 0) {
|
||||
// Menu (hamburger) on the left — the drawer opens from the left.
|
||||
if !hideMenuButton { menuButton.padding(.leading, 2) }
|
||||
HStack(spacing: 2) {
|
||||
Button { store.navigatePrev() } label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
Button { store.navigateNext() } label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
}
|
||||
.padding(.leading, hideMenuButton ? 6 : 0)
|
||||
Spacer(minLength: 6)
|
||||
Text(titleString)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
.layoutPriority(1)
|
||||
Spacer(minLength: 6)
|
||||
Button(L10n.t("nav.today", appLang)) { store.moveToToday() }
|
||||
.font(.callout).padding(.horizontal, 6)
|
||||
.lineLimit(1).fixedSize()
|
||||
.padding(.trailing, 4)
|
||||
}
|
||||
.frame(height: 48)
|
||||
}
|
||||
|
||||
private var topBar: some View {
|
||||
barContents.background(
|
||||
surfaceHex.isEmpty ? AnyShapeStyle(Material.bar) : AnyShapeStyle(Color(hex: surfaceHex))
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder private var groupBanner: some View {
|
||||
if let g = store.activeGroup {
|
||||
HStack(spacing: 6) {
|
||||
GroupIconView(icon: g.icon).font(.subheadline)
|
||||
Text("\(L10n.t("groups.view_label", appLang)): \(g.name)")
|
||||
.font(.subheadline).lineLimit(1)
|
||||
Spacer()
|
||||
Button(L10n.t("groups.exit", appLang)) { switchGroup(nil) }
|
||||
.font(.callout)
|
||||
}
|
||||
.padding(.horizontal, 12).padding(.vertical, 7)
|
||||
.background(Color.accentColor.opacity(0.18))
|
||||
}
|
||||
}
|
||||
|
||||
private func switchGroup(_ g: CalGroup?) {
|
||||
store.activeGroup = g
|
||||
store.hiddenGroupKeys = [] // member visibility is per-group; start fresh
|
||||
// The cache holds the previous mode's events — drop it and reload the
|
||||
// visible range + prefetch a wide window so the whole grid is covered.
|
||||
Task { await forceReload() }
|
||||
}
|
||||
|
||||
/// Opens the side drawer (calendars + groups + navigation). Tinted when a
|
||||
/// filter/group is active so the user sees state at a glance.
|
||||
private var menuButton: some View {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.25)) { showDrawer = true }
|
||||
} label: {
|
||||
Image(systemName: "line.3.horizontal")
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundStyle(store.activeGroup == nil && store.hiddenCalendarKeys.isEmpty ? .primary : Color.accentColor)
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
.accessibilityLabel(L10n.t("nav.menu", appLang))
|
||||
}
|
||||
|
||||
// MARK: – Error banners
|
||||
|
||||
// `lastError` (whole fetch failed) and `syncErrors` (fetch succeeded, but
|
||||
// individual calendars didn't sync) are independent conditions and can
|
||||
// both be shown at once.
|
||||
@ViewBuilder private var errorBanner: some View {
|
||||
if let err = store.lastError { errorBannerView(err) }
|
||||
if !store.syncErrors.isEmpty { syncErrorBannerView(store.syncErrors) }
|
||||
}
|
||||
|
||||
private func errorBannerView(_ err: String) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.yellow)
|
||||
Text(err).font(.caption).foregroundStyle(.white).lineLimit(2)
|
||||
Spacer()
|
||||
Button { Task { await onNavigate() } } label: {
|
||||
Image(systemName: "arrow.clockwise").foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12).padding(.vertical, 8)
|
||||
.background(Color.red.opacity(0.85))
|
||||
}
|
||||
|
||||
/// One or more calendars didn't sync on the last fetch (e.g. expired
|
||||
/// credentials) even though they're still enabled. Same visual language
|
||||
/// as `errorBannerView`, joined into a single compact banner.
|
||||
private func syncErrorBannerView(_ errors: [SyncError]) -> some View {
|
||||
let text = errors.map { "\($0.source) (\($0.name)): \($0.message)" }.joined(separator: "\n")
|
||||
return HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.yellow)
|
||||
Text(text).font(.caption).foregroundStyle(.white).lineLimit(errors.count + 1)
|
||||
Spacer()
|
||||
Button { Task { await onNavigate() } } label: {
|
||||
Image(systemName: "arrow.clockwise").foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12).padding(.vertical, 8)
|
||||
.background(Color.red.opacity(0.85))
|
||||
}
|
||||
|
||||
// MARK: – Calendar content (with swipe)
|
||||
|
||||
@ViewBuilder
|
||||
private var calendarContent: some View {
|
||||
let swipe = DragGesture(minimumDistance: 14, coordinateSpace: .local)
|
||||
.onEnded { val in
|
||||
let h = val.translation.width
|
||||
let v = val.translation.height
|
||||
guard abs(h) > abs(v) * 1.2, abs(h) > 28 else { return }
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
if h < 0 { store.navigateNext() } else { store.navigatePrev() }
|
||||
}
|
||||
}
|
||||
switch store.viewType {
|
||||
case .month:
|
||||
// Month view uses vertical scroll – no horizontal swipe.
|
||||
MonthView(store: store,
|
||||
onDayTap: { store.currentDate = $0 },
|
||||
onEventTap: { selectedEvent = $0 },
|
||||
onCreateEvent: { day in editorContext = .create(day) },
|
||||
onShowWeek: { day in
|
||||
store.currentDate = day
|
||||
store.viewType = .week
|
||||
},
|
||||
onShowDay: { day in
|
||||
store.currentDate = day
|
||||
store.viewType = .day
|
||||
})
|
||||
case .week:
|
||||
WeekView(store: store,
|
||||
onEventTap: { selectedEvent = $0 },
|
||||
onCreateEvent: { date in editorContext = .create(date) },
|
||||
onShowMonth: { date in
|
||||
store.currentDate = date
|
||||
store.viewType = .month
|
||||
},
|
||||
onShowDay: { date in
|
||||
store.currentDate = date
|
||||
store.viewType = .day
|
||||
})
|
||||
.simultaneousGesture(swipe)
|
||||
case .day:
|
||||
DayView(store: store,
|
||||
onEventTap: { selectedEvent = $0 },
|
||||
onCreateEvent: { date in editorContext = .create(date) })
|
||||
.simultaneousGesture(swipe)
|
||||
case .quarter:
|
||||
QuarterView(store: store, onEventTap: { selectedEvent = $0 })
|
||||
.simultaneousGesture(swipe)
|
||||
case .agenda:
|
||||
AgendaView(store: store, onEventTap: { selectedEvent = $0 })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – FAB buttons
|
||||
|
||||
/// Long-press menu on the create button: a plain new event, or a new birthday
|
||||
/// (which opens a minimal name + date mask that only targets birthday calendars).
|
||||
@ViewBuilder private var fabMenu: some View {
|
||||
Button {
|
||||
editorContext = .create(.now)
|
||||
} label: {
|
||||
Label(L10n.t("event.new_title", appLang), systemImage: "plus")
|
||||
}
|
||||
Button {
|
||||
showNewBirthday = true
|
||||
} label: {
|
||||
Label(L10n.t("birthday.new", appLang), systemImage: "birthday.cake.fill")
|
||||
}
|
||||
}
|
||||
|
||||
/// Standard solid FAB (flat mode)
|
||||
private var solidFAB: some View {
|
||||
Button {
|
||||
editorContext = .create(.now)
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 56, height: 56)
|
||||
.background(Color.accentColor)
|
||||
.clipShape(Circle())
|
||||
.shadow(radius: 4, y: 2)
|
||||
}
|
||||
.contextMenu { fabMenu }
|
||||
.padding(.trailing, 20).padding(.bottom, 20)
|
||||
}
|
||||
|
||||
/// Liquid Glass FAB (iOS 26) with glass effect; falls back to solid on older OS
|
||||
@ViewBuilder
|
||||
private var glassFAB: some View {
|
||||
if #available(iOS 26, *) {
|
||||
Button {
|
||||
editorContext = .create(.now)
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.frame(width: 56, height: 56)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.glassEffect(in: Circle())
|
||||
.contextMenu { fabMenu }
|
||||
.padding(.trailing, 20).padding(.bottom, 20)
|
||||
} else {
|
||||
solidFAB
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Sheets modifier
|
||||
|
||||
private var calendarSheets: CalendarSheets {
|
||||
CalendarSheets(store: store, editorContext: $editorContext,
|
||||
selectedEvent: $selectedEvent, showFilter: $showFilter,
|
||||
showNewBirthday: $showNewBirthday,
|
||||
api: api,
|
||||
reload: { await onNavigate() },
|
||||
reloadForce: { await reloadVisible(force: true) })
|
||||
}
|
||||
|
||||
// MARK: – Loading logic
|
||||
|
||||
private func startup() async {
|
||||
// Ask for notification permission early so reminders can be scheduled.
|
||||
NotificationScheduler.requestAuthorizationIfNeeded()
|
||||
// 0. Pull settings first so week-start / default-view are correct
|
||||
// before we compute the initial range and load events.
|
||||
await SettingsSync.pull(api: api)
|
||||
applyServerDrivenSettings(initial: true)
|
||||
|
||||
await store.loadWritableCalendars(api: api)
|
||||
// Reconcile per-calendar visibility with the server BEFORE the first
|
||||
// load so a calendar hidden/shown on the web is honoured immediately
|
||||
// (banished set correct before events are filtered).
|
||||
_ = await store.reconcileCalendarVisibility(api: api)
|
||||
groups = (try? await api.getGroups()) ?? []
|
||||
// 1. Load current view immediately (visible)
|
||||
let (s, e) = store.rangeForCurrentView()
|
||||
await store.loadEvents(api: api, start: s, end: e)
|
||||
// 2. Background prefetch for the configured range (non-blocking)
|
||||
Task(priority: .background) {
|
||||
await store.prefetchBackground(api: api, months: cacheMonths)
|
||||
}
|
||||
// 2b. Mirror Contacts birthdays into the bound birthday calendar, if the
|
||||
// user enabled it, then refresh so new birthdays appear immediately.
|
||||
if BirthdaysImporter.isEnabled {
|
||||
Task(priority: .background) {
|
||||
await BirthdaysImporter.sync(api: api)
|
||||
await forceReload()
|
||||
}
|
||||
}
|
||||
// 3. Periodic settings + visibility pull (tied to this .task's lifetime).
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(600))
|
||||
if Task.isCancelled { break }
|
||||
await syncFromServer()
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the server-driven "always sync" settings to the live store.
|
||||
/// `weekStartsOnMonday` is applied every time; the default view is applied
|
||||
/// only once at startup so it never overrides the user's manual switches.
|
||||
private func applyServerDrivenSettings(initial: Bool) {
|
||||
store.weekStartsOnMonday = (weekStartDay != "sunday")
|
||||
if initial, !didApplyDefaultView {
|
||||
didApplyDefaultView = true
|
||||
if let vt = CalViewType(rawValue: defaultView) {
|
||||
store.viewType = vt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Called on every navigation – instant if within cache, fetches otherwise.
|
||||
private func onNavigate() async {
|
||||
let (s, e) = store.rangeForCurrentView()
|
||||
await store.loadEvents(api: api, start: s, end: e)
|
||||
}
|
||||
|
||||
/// Re-fetch the visible range. With `force` it bypasses the cache so a just
|
||||
/// created/edited event shows up immediately (the server is authoritative).
|
||||
private func reloadVisible(force: Bool) async {
|
||||
let (s, e) = store.rangeForCurrentView()
|
||||
await store.loadEvents(api: api, start: s, end: e, force: force)
|
||||
}
|
||||
|
||||
/// Called when cacheMonths setting changes – clear cache and re-prefetch.
|
||||
private func recache() async {
|
||||
store.invalidateCache()
|
||||
await startup()
|
||||
}
|
||||
|
||||
/// Manual sync from the menu: drop the event cache and re-fetch from the
|
||||
/// server (the periodic loop in `startup()` is untouched, so we don't spawn
|
||||
/// a second one). Settings were already pulled by the menu action.
|
||||
private func forceReload() async {
|
||||
store.invalidateCache()
|
||||
let (s, e) = store.rangeForCurrentView()
|
||||
await store.loadEvents(api: api, start: s, end: e)
|
||||
Task(priority: .background) {
|
||||
await store.prefetchBackground(api: api, months: cacheMonths)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull server-driven settings AND reconcile per-calendar visibility in one
|
||||
/// step (used on launch, resume and the periodic loop). If the server
|
||||
/// changed a calendar's `sidebar_hidden` — e.g. hidden/shown on the web or
|
||||
/// another device — or `force` is set (manual sync), refetch so the change
|
||||
/// shows up without the user opening the filter sheet.
|
||||
private func syncFromServer(force: Bool = false) async {
|
||||
await SettingsSync.pull(api: api)
|
||||
// Mirror Contacts birthdays on every server sync (manual, resume,
|
||||
// periodic) — the user expects "sync with server" to include birthdays.
|
||||
if BirthdaysImporter.isEnabled { await BirthdaysImporter.sync(api: api) }
|
||||
let changed = await store.reconcileCalendarVisibility(api: api)
|
||||
if changed || force { await forceReload() }
|
||||
}
|
||||
|
||||
/// Called when the user scrolls into a new month – refreshes the visible range
|
||||
/// immediately from cache, then fetches on demand if needed.
|
||||
private func ensureLoaded(around month: Date) async {
|
||||
let cal = store.userCalendar
|
||||
let monthStart = cal.date(from: cal.dateComponents([.year, .month], from: month)) ?? month
|
||||
let s = cal.date(byAdding: .month, value: -1, to: monthStart) ?? monthStart
|
||||
let e = cal.date(byAdding: .month, value: 2, to: monthStart) ?? monthStart
|
||||
|
||||
// Only narrow the visible event set if the requested window is already cached.
|
||||
// Otherwise keep the current events visible until the network fetch finishes,
|
||||
// so previous/current/next month events don't disappear temporarily.
|
||||
if store.isCached(start: s, end: e) {
|
||||
store.refreshFromCache(start: s, end: e)
|
||||
}
|
||||
|
||||
await store.loadEvents(api: api, start: s, end: e)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Shared sheet modifier
|
||||
|
||||
private struct CalendarSheets: ViewModifier {
|
||||
let store: CalendarStore
|
||||
@Binding var editorContext: CalEditorContext?
|
||||
@Binding var selectedEvent: CalEvent?
|
||||
@Binding var showFilter: Bool
|
||||
@Binding var showNewBirthday: Bool
|
||||
let api: CalendarrAPI
|
||||
let reload: () async -> Void
|
||||
let reloadForce: () async -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.sheet(isPresented: $showNewBirthday) {
|
||||
BirthdayEditorSheet(api: api) { await reloadForce() }
|
||||
}
|
||||
// Use sheet(item:) so the editing event is captured atomically –
|
||||
// avoiding the race where sheet(isPresented:) evaluates its content
|
||||
// before the editingEvent state update propagates.
|
||||
.sheet(item: $editorContext) { ctx in
|
||||
let editingEv: CalEvent? = { if case .edit(let ev) = ctx { return ev }; return nil }()
|
||||
let date: Date = { if case .create(let d) = ctx { return d }; return .now }()
|
||||
EventEditorSheet(api: api, store: store,
|
||||
initialDate: date, editingEvent: editingEv) {
|
||||
editorContext = nil
|
||||
await reloadForce()
|
||||
}
|
||||
}
|
||||
.sheet(item: $selectedEvent) { ev in
|
||||
EventDetailSheet(event: ev, api: api, store: store) { updated, needsForce in
|
||||
selectedEvent = nil
|
||||
if let u = updated { editorContext = .edit(u) }
|
||||
if needsForce { await reloadForce() } else { await reload() }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showFilter) {
|
||||
CalendarFilterSheet(api: api, store: store)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DayView: View {
|
||||
let store: CalendarStore
|
||||
let onEventTap: (CalEvent) -> Void
|
||||
let onCreateEvent: (Date) -> Void
|
||||
|
||||
@AppStorage("appLanguage") private var appLang = "system"
|
||||
@AppStorage("todayColor") private var todayHex = "#4285f4"
|
||||
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
||||
@AppStorage("lineColor") private var lineHex = "#3A3A52"
|
||||
@AppStorage("textContrast") private var textContrast = 3
|
||||
@AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout
|
||||
|
||||
private var cal: Calendar { store.userCalendar }
|
||||
private var allDayEvents: [CalEvent] {
|
||||
store.events(on: store.currentDate).filter { $0.isAllDay || eventSpansMultipleDays($0) }
|
||||
}
|
||||
private var timedEvents: [CalEvent] {
|
||||
store.events(on: store.currentDate).filter { !$0.isAllDay && !eventSpansMultipleDays($0) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if !allDayEvents.isEmpty { allDayStrip }
|
||||
|
||||
GeometryReader { geo in
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
ZStack(alignment: .topLeading) {
|
||||
// Background grid with per-hour context menus
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
timeLabels
|
||||
VStack(spacing: 0) {
|
||||
ForEach(hours, id: \.self) { hour in
|
||||
DayHourSlot(day: store.currentDate, hour: hour,
|
||||
hourHeight: hourHeight,
|
||||
language: appLang,
|
||||
onCreateEvent: onCreateEvent)
|
||||
}
|
||||
}
|
||||
.frame(width: geo.size.width - timeColumnWidth)
|
||||
}
|
||||
|
||||
// Events
|
||||
let evWidth = geo.size.width - timeColumnWidth - 2
|
||||
ForEach(timedEvents) { ev in
|
||||
Button(action: { onEventTap(ev) }) {
|
||||
EventBlock(event: ev)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: evWidth, height: max(eventHeight(ev), 18))
|
||||
.offset(x: timeColumnWidth + 1, y: eventTop(ev))
|
||||
}
|
||||
|
||||
// Current time
|
||||
if cal.isDateInToday(store.currentDate) {
|
||||
let lineY = nowLineY()
|
||||
let nowColor = Color(hex: todayHex)
|
||||
HStack(spacing: 0) {
|
||||
Spacer().frame(width: timeColumnWidth - 4)
|
||||
Circle().fill(nowColor).frame(width: 8, height: 8)
|
||||
Rectangle().fill(nowColor)
|
||||
.frame(width: geo.size.width - timeColumnWidth - 4, height: 1.5)
|
||||
}
|
||||
.offset(y: lineY - 0.75)
|
||||
}
|
||||
}
|
||||
.frame(width: geo.size.width, height: hourHeight * 24 + 80)
|
||||
.id("grid")
|
||||
}
|
||||
.onAppear { scrollToCurrentHour(proxy) }
|
||||
.onChange(of: store.currentDate) { _, _ in scrollToCurrentHour(proxy) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var allDayStrip: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(allDayEvents) { ev in
|
||||
Button(action: { onEventTap(ev) }) {
|
||||
EventLabel(event: ev)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 8).padding(.vertical, 4)
|
||||
.background(Color(hex: ev.effectiveColor))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12).padding(.vertical, 6)
|
||||
}
|
||||
.overlay(alignment: .bottom) { Divider() }
|
||||
}
|
||||
|
||||
private var timeLabels: some View {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(hours, id: \.self) { h in
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Color.clear.frame(height: hourHeight)
|
||||
Text(String(format: "%02d:00", h))
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
|
||||
.offset(y: -6)
|
||||
}
|
||||
}
|
||||
Color.clear.frame(height: 80)
|
||||
}
|
||||
.frame(width: timeColumnWidth)
|
||||
}
|
||||
|
||||
private func nowLineY() -> CGFloat {
|
||||
let cal = Calendar.current
|
||||
let h = CGFloat(cal.component(.hour, from: Date()))
|
||||
let m = CGFloat(cal.component(.minute, from: Date()))
|
||||
return h * hourHeight + m * hourHeight / 60
|
||||
}
|
||||
|
||||
private func scrollToCurrentHour(_ proxy: ScrollViewProxy) {
|
||||
let h = Calendar.current.component(.hour, from: .now)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
||||
withAnimation(.easeOut(duration: 0.3)) {
|
||||
proxy.scrollTo("grid", anchor: UnitPoint(x: 0, y: CGFloat(max(h - 1, 0)) / 24.0))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One-hour slot for the single-column day view.
|
||||
private struct DayHourSlot: View {
|
||||
let day: Date
|
||||
let hour: Int
|
||||
let hourHeight: CGFloat
|
||||
let language: String
|
||||
let onCreateEvent: (Date) -> Void
|
||||
|
||||
@AppStorage("lineColor") private var lineHex = "#3A3A52"
|
||||
@AppStorage("lineContrast") private var lineContrast = 3
|
||||
|
||||
private var date: Date {
|
||||
Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(height: 0.5)
|
||||
Color.clear.frame(height: hourHeight - 0.5)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.contextMenu {
|
||||
Button { onCreateEvent(date) } label: {
|
||||
Label(L10n.t("cal.new_event", language), systemImage: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import SwiftUI
|
||||
|
||||
struct EventDetailSheet: View {
|
||||
let event: CalEvent
|
||||
let api: CalendarrAPI
|
||||
let store: CalendarStore
|
||||
/// 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
|
||||
@AppStorage("appLanguage") private var appLang = "system"
|
||||
@State private var showDeleteConfirm = false
|
||||
@State private var isDeleting = false
|
||||
@State private var showCopySheet = false
|
||||
|
||||
private let timeFmt: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateStyle = .medium
|
||||
f.timeStyle = .short
|
||||
return f
|
||||
}()
|
||||
|
||||
private let dateFmt: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateStyle = .medium
|
||||
f.timeStyle = .none
|
||||
return f
|
||||
}()
|
||||
|
||||
private var timeString: String {
|
||||
if event.isAllDay {
|
||||
if Calendar.current.isDate(event.startDate, inSameDayAs: event.endDate) ||
|
||||
event.endDate == event.startDate {
|
||||
return "\(L10n.t("event.allday", appLang)) · \(dateFmt.string(from: event.startDate))"
|
||||
}
|
||||
let end = Calendar.current.date(byAdding: .day, value: -1, to: event.endDate) ?? event.endDate
|
||||
return "\(L10n.t("event.allday", appLang)) · \(dateFmt.string(from: event.startDate)) – \(dateFmt.string(from: end))"
|
||||
}
|
||||
return "\(timeFmt.string(from: event.startDate)) – \(timeFmt.string(from: event.endDate))"
|
||||
}
|
||||
|
||||
private var canEdit: Bool {
|
||||
event.source == "local" || event.source == "caldav" || event.source == "homeassistant"
|
||||
}
|
||||
|
||||
private var canDelete: Bool { canEdit }
|
||||
|
||||
private var currentUserId: Int? {
|
||||
let id = UserDefaults.standard.integer(forKey: "userId")
|
||||
return id == 0 ? nil : id
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color(hex: event.effectiveColor))
|
||||
.frame(width: 6, height: 44)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(event.renderTitle)
|
||||
.font(.title3.bold())
|
||||
Text(event.calendarName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
Section {
|
||||
Label(timeString, systemImage: "clock")
|
||||
|
||||
if !event.location.isEmpty {
|
||||
Label(event.location, systemImage: "mappin.and.ellipse")
|
||||
}
|
||||
|
||||
if !event.notes.isEmpty {
|
||||
Label(event.notes, systemImage: "text.alignleft")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Label(L10n.t("event.calendar_section", appLang), systemImage: "calendar")
|
||||
Spacer()
|
||||
Text(event.calendarName)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
HStack {
|
||||
Label(L10n.t("detail.source", appLang), systemImage: "server.rack")
|
||||
Spacer()
|
||||
Text(event.source.capitalized)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let creator = event.creator, creator.id != currentUserId {
|
||||
HStack {
|
||||
Label(L10n.t("detail.created_by", appLang), systemImage: "person")
|
||||
Spacer()
|
||||
Text(creator.displayName)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if event.isPrivate {
|
||||
Label(L10n.t("event.private", appLang), systemImage: "lock")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if !store.writableCalendars.isEmpty {
|
||||
Section {
|
||||
Button {
|
||||
showCopySheet = true
|
||||
} label: {
|
||||
Label(L10n.t("event.copy_title", appLang), systemImage: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if canDelete {
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
showDeleteConfirm = true
|
||||
} label: {
|
||||
Label(L10n.t("detail.delete", appLang), systemImage: "trash")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
.disabled(isDeleting)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.navigationTitle(L10n.t("detail.title", appLang))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(L10n.t("common.close", appLang)) {
|
||||
Task { await onDone(nil, false) }
|
||||
}
|
||||
}
|
||||
if canEdit {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(L10n.t("detail.edit", appLang)) {
|
||||
Task { await onDone(event, false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert(L10n.t("detail.delete_confirm_title", appLang), isPresented: $showDeleteConfirm) {
|
||||
Button(L10n.t("common.delete", appLang), role: .destructive) {
|
||||
Task { await deleteEvent() }
|
||||
}
|
||||
Button(L10n.t("common.cancel", appLang), role: .cancel) {}
|
||||
} message: {
|
||||
Text("\"\(event.renderTitle)\" \(L10n.t("detail.delete_msg_suffix", appLang))")
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteEvent() async {
|
||||
isDeleting = true
|
||||
do {
|
||||
switch event.source {
|
||||
case "local":
|
||||
try await api.deleteLocalEvent(uid: event.id)
|
||||
case "homeassistant":
|
||||
// calendarId looks like "homeassistant-42" → numeric DB id 42
|
||||
let calId = Int(event.calendarId.replacingOccurrences(of: "homeassistant-", with: "")) ?? 0
|
||||
try await api.deleteHAEvent(calendarId: calId, uid: event.id)
|
||||
default:
|
||||
let calId = Int(event.calendarId)
|
||||
try await api.deleteCalDAVEvent(uid: event.id, url: event.url, calendarId: calId)
|
||||
}
|
||||
// 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, false)
|
||||
} catch {
|
||||
isDeleting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import SwiftUI
|
||||
|
||||
struct EventEditorSheet: View {
|
||||
let api: CalendarrAPI
|
||||
let store: CalendarStore
|
||||
let initialDate: Date
|
||||
let editingEvent: CalEvent?
|
||||
var copyFrom: CalEvent? = nil
|
||||
let onSaved: () async -> Void
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@AppStorage("appLanguage") private var appLang = "system"
|
||||
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
|
||||
@AppStorage("defaultEventDurationMinutes") private var defaultEventDurationMinutes = 60
|
||||
@State private var title = ""
|
||||
@State private var isAllDay = false
|
||||
@State private var startDate = Date()
|
||||
@State private var endDate = Date().addingTimeInterval(3600)
|
||||
@State private var location = ""
|
||||
@State private var notes = ""
|
||||
@State private var selectedCalendarId: String = ""
|
||||
@State private var color = ""
|
||||
@State private var isPrivate = false
|
||||
@State private var reminders: [Int] = []
|
||||
@State private var isSaving = false
|
||||
@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 }
|
||||
}
|
||||
|
||||
/// True when the selected calendar has its reminders muted: we keep any
|
||||
/// existing reminders on the event (never delete them) but grey out the
|
||||
/// controls and explain that they won't fire.
|
||||
private var remindersDisabled: Bool {
|
||||
guard let cal = selectedCal else { return false }
|
||||
let key = CalendarStore.calendarKey(source: cal.source, calendarId: String(cal.numericId))
|
||||
return store.reminderDisabledKeys.contains(key)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
TextField(L10n.t("event.title_placeholder", appLang), text: $title)
|
||||
.font(.body.weight(.medium))
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle(L10n.t("event.allday", appLang), isOn: $isAllDay.animation())
|
||||
.tint(Color.accentColor)
|
||||
|
||||
if isAllDay {
|
||||
DatePicker(L10n.t("event.start", appLang), selection: $startDate, displayedComponents: .date)
|
||||
DatePicker(L10n.t("event.end", appLang), selection: $endDate, displayedComponents: .date)
|
||||
} else {
|
||||
DatePicker(L10n.t("event.start", appLang), selection: $startDate)
|
||||
DatePicker(L10n.t("event.end", appLang), selection: $endDate)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField(L10n.t("event.location", appLang), text: $location)
|
||||
TextField(L10n.t("event.description", appLang), text: $notes, axis: .vertical)
|
||||
.lineLimit(3...6)
|
||||
}
|
||||
|
||||
Section(L10n.t("event.calendar_section", appLang)) {
|
||||
if store.writableCalendars.isEmpty {
|
||||
Text(L10n.t("event.no_writable", appLang))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.callout)
|
||||
} else {
|
||||
Picker(L10n.t("event.calendar_picker", appLang), selection: $selectedCalendarId) {
|
||||
ForEach(store.writableCalendars) { cal in
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(Color(hex: cal.color))
|
||||
.frame(width: 10, height: 10)
|
||||
Text(cal.name)
|
||||
}
|
||||
.tag(cal.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if selectedCal?.source == "local" {
|
||||
Section {
|
||||
Toggle(L10n.t("event.private", appLang), isOn: $isPrivate)
|
||||
.tint(Color.accentColor)
|
||||
}
|
||||
|
||||
Section {
|
||||
ForEach(reminders.indices, id: \.self) { idx in
|
||||
ReminderEditRow(minutes: $reminders[idx], appLang: appLang)
|
||||
}
|
||||
.onDelete { reminders.remove(atOffsets: $0) }
|
||||
Button {
|
||||
let next = ReminderOptions.presets.first { !reminders.contains($0) }
|
||||
?? ReminderOptions.customDefault
|
||||
reminders.append(next)
|
||||
} label: {
|
||||
Label(ReminderOptions.addLabel(appLang), systemImage: "bell.badge.plus")
|
||||
}
|
||||
} header: {
|
||||
Text(ReminderOptions.sectionTitle(appLang))
|
||||
} footer: {
|
||||
if remindersDisabled {
|
||||
Text(ReminderOptions.disabledNote(appLang)).foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
.disabled(remindersDisabled)
|
||||
}
|
||||
|
||||
Section(L10n.t("event.color_section", appLang)) {
|
||||
HStack {
|
||||
Text(L10n.t("event.color", appLang))
|
||||
Spacer()
|
||||
ColorPicker("", selection: Binding(
|
||||
get: { Color(hex: color.isEmpty ? (selectedCal?.color ?? "#4285f4") : color) },
|
||||
set: { color = $0.toHex() }
|
||||
), supportsOpacity: false)
|
||||
.labelsHidden()
|
||||
if !color.isEmpty {
|
||||
Button(L10n.t("event.reset_color", appLang)) { color = "" }
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !error.isEmpty {
|
||||
Section {
|
||||
Text(error).foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.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) {
|
||||
Button(L10n.t("common.cancel", appLang)) { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(isEditing
|
||||
? L10n.t("event.save", appLang)
|
||||
: L10n.t("event.add", appLang)) {
|
||||
Task { await save() }
|
||||
}
|
||||
.bold()
|
||||
.disabled(title.isEmpty || selectedCalendarId.isEmpty || isSaving)
|
||||
}
|
||||
}
|
||||
}
|
||||
.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() {
|
||||
if let ev = editingEvent {
|
||||
title = ev.title
|
||||
isAllDay = ev.isAllDay
|
||||
startDate = ev.startDate
|
||||
// All-day end dates are stored as exclusive (day after last); subtract 1 for the picker.
|
||||
endDate = ev.isAllDay
|
||||
? Calendar.current.date(byAdding: .day, value: -1, to: ev.endDate) ?? ev.endDate
|
||||
: ev.endDate
|
||||
location = ev.location
|
||||
notes = ev.notes
|
||||
color = ev.color ?? ""
|
||||
isPrivate = ev.isPrivate
|
||||
reminders = ev.reminders
|
||||
// HA events use "homeassistant-42" in CalEvent but "ha-42" in WritableCalendar
|
||||
if ev.source == "homeassistant" {
|
||||
let num = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
|
||||
selectedCalendarId = "ha-\(num)"
|
||||
} else {
|
||||
selectedCalendarId = ev.calendarId
|
||||
}
|
||||
} else if let ev = copyFrom {
|
||||
title = ev.title
|
||||
isAllDay = ev.isAllDay
|
||||
startDate = ev.startDate
|
||||
endDate = ev.isAllDay
|
||||
? Calendar.current.date(byAdding: .day, value: -1, to: ev.endDate) ?? ev.endDate
|
||||
: ev.endDate
|
||||
location = ev.location
|
||||
notes = ev.notes
|
||||
color = ev.color ?? ""
|
||||
isPrivate = ev.isPrivate
|
||||
reminders = ev.reminders
|
||||
selectedCalendarId = store.writableCalendars.first?.id ?? ""
|
||||
} else {
|
||||
let cal = Calendar.current
|
||||
startDate = cal.date(bySettingHour: cal.component(.hour, from: initialDate),
|
||||
minute: 0, second: 0, of: initialDate) ?? initialDate
|
||||
let durMin = defaultEventDurationMinutes > 0 ? defaultEventDurationMinutes : 60
|
||||
endDate = startDate.addingTimeInterval(Double(durMin) * 60)
|
||||
selectedCalendarId = store.writableCalendars.first?.id ?? ""
|
||||
// New events inherit the user's default reminder (editable).
|
||||
if defaultReminderMinutes >= 0 { reminders = [defaultReminderMinutes] }
|
||||
}
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
guard let cal = selectedCal else { return }
|
||||
isSaving = true
|
||||
error = ""
|
||||
defer { isSaving = false }
|
||||
|
||||
let colorVal: String? = color.isEmpty ? nil : color
|
||||
let start = isAllDay ? Calendar.current.startOfDay(for: startDate) : startDate
|
||||
let end = isAllDay ? Calendar.current.startOfDay(for: Calendar.current.date(byAdding: .day, value: 1, to: endDate)!) : endDate
|
||||
|
||||
do {
|
||||
if let ev = editingEvent {
|
||||
switch ev.source {
|
||||
case "local":
|
||||
try await api.updateLocalEvent(uid: ev.id, title: title, start: start, end: end,
|
||||
isAllDay: isAllDay, location: location, description: notes, color: colorVal,
|
||||
isPrivate: isPrivate, reminders: reminders)
|
||||
case "homeassistant":
|
||||
// No update API exists – delete the old event and recreate with new data.
|
||||
let rawId = ev.calendarId.replacingOccurrences(of: "homeassistant-", with: "")
|
||||
let haCalId = Int(rawId) ?? 0
|
||||
try await api.deleteHAEvent(calendarId: haCalId, uid: ev.id)
|
||||
try await api.createHAEvent(calendarId: haCalId, title: title,
|
||||
start: start, end: end, isAllDay: isAllDay,
|
||||
location: location, description: notes)
|
||||
default: // caldav
|
||||
let calId = Int(ev.calendarId)
|
||||
try await api.updateCalDAVEvent(uid: ev.id, url: ev.url, calendarId: calId,
|
||||
title: title, start: start, end: end, isAllDay: isAllDay,
|
||||
location: location, description: notes, color: colorVal)
|
||||
}
|
||||
} else {
|
||||
switch cal.source {
|
||||
case "local":
|
||||
_ = try await api.createLocalEvent(calendarId: cal.numericId, title: title,
|
||||
start: start, end: end, isAllDay: isAllDay,
|
||||
location: location, description: notes, color: colorVal,
|
||||
isPrivate: isPrivate, reminders: reminders)
|
||||
case "google":
|
||||
try await api.createGoogleEvent(calendarDbId: cal.numericId, title: title,
|
||||
start: start, end: end, isAllDay: isAllDay,
|
||||
location: location, description: notes)
|
||||
case "homeassistant":
|
||||
try await api.createHAEvent(calendarId: cal.numericId, title: title,
|
||||
start: start, end: end, isAllDay: isAllDay,
|
||||
location: location, description: notes)
|
||||
default: // caldav
|
||||
try await api.createCalDAVEvent(calendarId: cal.numericId, title: title,
|
||||
start: start, end: end, isAllDay: isAllDay,
|
||||
location: location, description: notes, color: colorVal)
|
||||
}
|
||||
}
|
||||
await onSaved()
|
||||
dismiss()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One reminder row: a preset picker plus, when "Custom" is chosen, a number
|
||||
/// stepper and a unit picker. The value is always stored as minutes-before-start.
|
||||
private struct ReminderEditRow: View {
|
||||
@Binding var minutes: Int
|
||||
let appLang: String
|
||||
private let customTag = -1
|
||||
|
||||
private var isPreset: Bool { ReminderOptions.presets.contains(minutes) }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Picker("", selection: Binding(
|
||||
get: { isPreset ? minutes : customTag },
|
||||
set: { newVal in
|
||||
if newVal == customTag {
|
||||
if ReminderOptions.presets.contains(minutes) { minutes = ReminderOptions.customDefault }
|
||||
} else {
|
||||
minutes = newVal
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ForEach(ReminderOptions.presets, id: \.self) { Text(ReminderOptions.label($0, appLang)).tag($0) }
|
||||
Text(ReminderOptions.customLabel(appLang)).tag(customTag)
|
||||
}
|
||||
.labelsHidden()
|
||||
|
||||
if !isPreset {
|
||||
HStack {
|
||||
Stepper(value: Binding(
|
||||
get: { ReminderOptions.split(minutes).value },
|
||||
set: { minutes = max(1, $0) * ReminderOptions.split(minutes).unit.mult }
|
||||
), in: 1...999) {
|
||||
Text("\(ReminderOptions.split(minutes).value)")
|
||||
.monospacedDigit()
|
||||
}
|
||||
Picker("", selection: Binding(
|
||||
get: { ReminderOptions.split(minutes).unit },
|
||||
set: { newUnit in minutes = ReminderOptions.split(minutes).value * newUnit.mult }
|
||||
)) {
|
||||
ForEach(ReminderOptions.Unit.allCases) { u in
|
||||
Text(ReminderOptions.unitLabel(u, appLang)).tag(u)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
Text(ReminderOptions.beforeLabel(appLang)).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,619 @@
|
||||
import SwiftUI
|
||||
|
||||
// Cover ~10 years back and ~77 years ahead, so the scrollable range goes well
|
||||
// past 2100 — enough room for any vacation that's actually getting planned.
|
||||
private let weeksBack = 520
|
||||
private let weeksAhead = 4000
|
||||
// Paged mode uses one page per month over a bounded range (keeps the TabView light).
|
||||
private let monthsBack = 120
|
||||
private let monthsAhead = 600
|
||||
private let weekdayHeaderHeight: CGFloat = 28
|
||||
private let dayNumberRowHeight: CGFloat = 22
|
||||
private let laneHeight: CGFloat = 16
|
||||
private let laneSpacing: CGFloat = 2
|
||||
private let maxLanesPerWeek = 5
|
||||
|
||||
private enum DividerEdge { case none, topHighlight, bottomHighlight }
|
||||
|
||||
struct MonthView: View {
|
||||
let store: CalendarStore
|
||||
let onDayTap: (Date) -> Void
|
||||
let onEventTap: (CalEvent) -> Void
|
||||
let onCreateEvent: (Date) -> Void
|
||||
let onShowWeek: (Date) -> Void
|
||||
let onShowDay: (Date) -> Void
|
||||
|
||||
@AppStorage("appLanguage") private var appLang = "system"
|
||||
@AppStorage("monthDividerColor") private var dividerHex = "#7090c0"
|
||||
@AppStorage("monthLabelColor") private var labelHex = "#7090c0"
|
||||
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
||||
@AppStorage("lineColor") private var lineHex = "#3A3A52"
|
||||
@AppStorage("textContrast") private var textContrast = 3
|
||||
@AppStorage("monthViewPaged") private var monthPaged = false
|
||||
|
||||
@State private var scrolledWeek: Date? = nil
|
||||
@State private var didInitialScroll = false
|
||||
@State private var pagedMonth: Date = Calendar.current.date(
|
||||
from: Calendar.current.dateComponents([.year, .month], from: .now)) ?? .now
|
||||
|
||||
private var cal: Calendar { store.userCalendar }
|
||||
|
||||
/// First day of the month containing `date`.
|
||||
private func monthStart(for date: Date) -> Date {
|
||||
cal.date(from: cal.dateComponents([.year, .month], from: date)) ?? date
|
||||
}
|
||||
|
||||
/// One entry per month across the bounded paged range.
|
||||
private var monthStarts: [Date] {
|
||||
let base = monthStart(for: .now)
|
||||
return (-monthsBack...monthsAhead).compactMap {
|
||||
cal.date(byAdding: .month, value: $0, to: base)
|
||||
}
|
||||
}
|
||||
|
||||
private var weekStarts: [Date] {
|
||||
let today = cal.startOfDay(for: .now)
|
||||
let thisWeek = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: today))!
|
||||
return (-weeksBack...weeksAhead).compactMap {
|
||||
cal.date(byAdding: .weekOfYear, value: $0, to: thisWeek)
|
||||
}
|
||||
}
|
||||
|
||||
private var weekdayHeaders: [String] {
|
||||
let fmt = DateFormatter(); fmt.locale = L10n.locale(appLang)
|
||||
let symbols = fmt.shortWeekdaySymbols ?? cal.shortWeekdaySymbols
|
||||
let start = cal.firstWeekday - 1
|
||||
return (0..<7).map { i in String(symbols[(start + i) % 7].prefix(2)) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
headerRow
|
||||
Divider()
|
||||
if monthPaged { pagedBody } else { scrollBody }
|
||||
}
|
||||
}
|
||||
|
||||
// Continuous vertical scroll feed (default).
|
||||
private var scrollBody: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(weekStarts, id: \.self) { ws in
|
||||
weekRow(for: ws, fillHeight: false)
|
||||
.id(ws)
|
||||
}
|
||||
}
|
||||
.scrollTargetLayout()
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
.scrollPosition(id: $scrolledWeek, anchor: .top)
|
||||
.onAppear {
|
||||
if !didInitialScroll {
|
||||
didInitialScroll = true
|
||||
scrolledWeek = weekStart(for: store.currentDate)
|
||||
publishVisibleMonth(from: scrolledWeek)
|
||||
}
|
||||
}
|
||||
.onChange(of: store.currentDate) { _, newDate in
|
||||
let target = weekStart(for: newDate)
|
||||
if scrolledWeek != target {
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
scrolledWeek = target
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: scrolledWeek) { _, newWeek in
|
||||
publishVisibleMonth(from: newWeek)
|
||||
}
|
||||
}
|
||||
|
||||
// One month per page, swipe left/right to change month.
|
||||
private var pagedBody: some View {
|
||||
TabView(selection: $pagedMonth) {
|
||||
ForEach(monthStarts, id: \.self) { m in
|
||||
monthGrid(for: m).tag(m)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.onAppear {
|
||||
pagedMonth = monthStart(for: store.currentDate)
|
||||
if store.visibleMonth != pagedMonth { store.visibleMonth = pagedMonth }
|
||||
}
|
||||
.onChange(of: pagedMonth) { _, m in
|
||||
if store.visibleMonth != m { store.visibleMonth = m }
|
||||
// Drive the shared date so events for the new month load; guard the
|
||||
// month so we don't fight the reverse onChange below.
|
||||
if monthStart(for: store.currentDate) != m { store.currentDate = m }
|
||||
}
|
||||
.onChange(of: store.currentDate) { _, d in
|
||||
let m = monthStart(for: d)
|
||||
if pagedMonth != m { withAnimation(.easeInOut(duration: 0.25)) { pagedMonth = m } }
|
||||
}
|
||||
}
|
||||
|
||||
/// A single month page: six height-filling week rows.
|
||||
private func monthGrid(for month: Date) -> some View {
|
||||
let firstWeek = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: month)) ?? month
|
||||
let weeks = (0..<6).compactMap { cal.date(byAdding: .weekOfYear, value: $0, to: firstWeek) }
|
||||
return VStack(spacing: 0) {
|
||||
ForEach(weeks, id: \.self) { ws in
|
||||
weekRow(for: ws, fillHeight: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func weekRow(for ws: Date, fillHeight: Bool) -> some View {
|
||||
WeekRow(weekStart: ws,
|
||||
fillHeight: fillHeight,
|
||||
store: store,
|
||||
dividerColor: Color(hex: dividerHex),
|
||||
labelColor: Color(hex: labelHex),
|
||||
textColor: Color(hex: textHex),
|
||||
lineColor: Color(hex: lineHex),
|
||||
language: appLang,
|
||||
onDayTap: onDayTap,
|
||||
onEventTap: onEventTap,
|
||||
onCreateEvent: onCreateEvent,
|
||||
onShowWeek: onShowWeek,
|
||||
onShowDay: onShowDay)
|
||||
}
|
||||
|
||||
private var headerRow: some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(weekdayHeaders, id: \.self) { d in
|
||||
Text(d)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
|
||||
.frame(maxWidth: .infinity, minHeight: weekdayHeaderHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func weekStart(for date: Date) -> Date {
|
||||
cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: date))!
|
||||
}
|
||||
|
||||
/// Determine the header month from the currently-scrolled week.
|
||||
/// Rule: take the month of the topmost visible week's start day. This
|
||||
/// means as long as the "1." of the next month is still visible in the
|
||||
/// top row, the header keeps showing the previous month – and only flips
|
||||
/// to the new month once its "1." has scrolled out of view above.
|
||||
private func publishVisibleMonth(from week: Date?) {
|
||||
guard let w = week else { return }
|
||||
let month = cal.date(from: cal.dateComponents([.year, .month], from: w)) ?? w
|
||||
if store.visibleMonth != month {
|
||||
store.visibleMonth = month
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Week Row
|
||||
|
||||
private struct WeekRow: View {
|
||||
let weekStart: Date
|
||||
// Scroll mode uses a fixed row height; paged mode fills the page evenly.
|
||||
var fillHeight: Bool = false
|
||||
let store: CalendarStore
|
||||
let dividerColor: Color
|
||||
let labelColor: Color
|
||||
let textColor: Color
|
||||
let lineColor: Color
|
||||
let language: String
|
||||
let onDayTap: (Date) -> Void
|
||||
let onEventTap: (CalEvent) -> Void
|
||||
let onCreateEvent: (Date) -> Void
|
||||
let onShowWeek: (Date) -> Void
|
||||
let onShowDay: (Date) -> Void
|
||||
|
||||
private var cal: Calendar { store.userCalendar }
|
||||
|
||||
private var days: [Date] {
|
||||
(0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: weekStart) }
|
||||
}
|
||||
|
||||
private var weekNumber: Int { cal.component(.weekOfYear, from: weekStart) }
|
||||
|
||||
private func columnRange(for ev: CalEvent) -> (startCol: Int, span: Int) {
|
||||
let weekEnd = cal.date(byAdding: .day, value: 7, to: weekStart)!
|
||||
let evStart = max(cal.startOfDay(for: ev.startDate), weekStart)
|
||||
// All-day end is already exclusive; timed end-of-day-on-same-day shouldn't add a column.
|
||||
let rawEnd: Date
|
||||
if ev.isAllDay {
|
||||
rawEnd = ev.endDate
|
||||
} else {
|
||||
// Treat timed events as occupying days from start up to and including the day of end.
|
||||
rawEnd = cal.date(byAdding: .day, value: 1, to: cal.startOfDay(for: ev.endDate))!
|
||||
}
|
||||
let evEnd = min(rawEnd, weekEnd)
|
||||
let sc = max(0, cal.dateComponents([.day], from: weekStart, to: evStart).day ?? 0)
|
||||
let lastIncl = (cal.dateComponents([.day], from: weekStart, to: evEnd).day ?? 0) - 1
|
||||
let ec = min(6, lastIncl)
|
||||
return (sc, max(1, ec - sc + 1))
|
||||
}
|
||||
|
||||
/// Greedy lane packing for events overlapping this week.
|
||||
private func packEvents() -> (placed: [(event: CalEvent, lane: Int, startCol: Int, span: Int)],
|
||||
extraPerCol: [Int]) {
|
||||
let weekEndExclusive = cal.date(byAdding: .day, value: 7, to: weekStart)!
|
||||
let evs = store.events(in: weekStart, end: weekEndExclusive)
|
||||
.sorted { a, b in
|
||||
if a.startDate != b.startDate { return a.startDate < b.startDate }
|
||||
return a.endDate > b.endDate
|
||||
}
|
||||
var laneLastEnd: [Int] = []
|
||||
var placed: [(CalEvent, Int, Int, Int)] = []
|
||||
var overflowPerCol = [Int](repeating: 0, count: 7)
|
||||
|
||||
for ev in evs {
|
||||
let (sc, sp) = columnRange(for: ev)
|
||||
var assigned: Int? = nil
|
||||
for laneIdx in 0..<laneLastEnd.count {
|
||||
if laneLastEnd[laneIdx] < sc {
|
||||
laneLastEnd[laneIdx] = sc + sp - 1
|
||||
assigned = laneIdx
|
||||
break
|
||||
}
|
||||
}
|
||||
if assigned == nil {
|
||||
if laneLastEnd.count < maxLanesPerWeek {
|
||||
laneLastEnd.append(sc + sp - 1)
|
||||
assigned = laneLastEnd.count - 1
|
||||
}
|
||||
}
|
||||
if let lane = assigned {
|
||||
placed.append((ev, lane, sc, sp))
|
||||
} else {
|
||||
for c in sc...min(6, sc + sp - 1) {
|
||||
overflowPerCol[c] += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return (placed.map { (event: $0.0, lane: $0.1, startCol: $0.2, span: $0.3) },
|
||||
overflowPerCol)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let weekEndExclusive = cal.date(byAdding: .day, value: 7, to: weekStart)!
|
||||
let (placed, extras) = packEvents()
|
||||
let allWeekEvents = store.events(in: weekStart, end: weekEndExclusive)
|
||||
let rowHeight = dayNumberRowHeight + CGFloat(maxLanesPerWeek) * (laneHeight + laneSpacing) + 16
|
||||
let mondayIdx = days.firstIndex(where: { cal.component(.weekday, from: $0) == 2 }) ?? 0
|
||||
|
||||
// Where in this row does a new month start? (col 1...6 = mid-row step; nil = no step)
|
||||
let midRowBoundaryCol: Int? = {
|
||||
for idx in 1..<7 where cal.component(.day, from: days[idx]) == 1 { return idx }
|
||||
return nil
|
||||
}()
|
||||
let rowStartsNewMonth = cal.component(.day, from: days[0]) == 1
|
||||
|
||||
GeometryReader { geo in
|
||||
let cellW = geo.size.width / 7
|
||||
|
||||
ZStack(alignment: .topLeading) {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(Array(days.enumerated()), id: \.offset) { idx, day in
|
||||
let edge: DividerEdge = {
|
||||
if let b = midRowBoundaryCol {
|
||||
return idx < b ? .bottomHighlight : .topHighlight
|
||||
}
|
||||
return rowStartsNewMonth ? .topHighlight : .none
|
||||
}()
|
||||
let dayStart = cal.startOfDay(for: day)
|
||||
let dayEnd = cal.date(byAdding: .day, value: 1, to: dayStart)!
|
||||
let eventsForDay = allWeekEvents.filter {
|
||||
$0.startDate < dayEnd && $0.endDate > dayStart
|
||||
}
|
||||
DayCell(date: day,
|
||||
isToday: cal.isDateInToday(day),
|
||||
monthLabelColor: labelColor,
|
||||
dividerColor: dividerColor,
|
||||
textColor: textColor,
|
||||
lineColor: lineColor,
|
||||
language: language,
|
||||
extraCount: extras[idx],
|
||||
weekNumber: idx == mondayIdx ? weekNumber : nil,
|
||||
cwLabel: L10n.t("cal.cw", language),
|
||||
edge: edge,
|
||||
dayEvents: eventsForDay,
|
||||
onTap: { onDayTap(day) },
|
||||
onCreateEvent: { onCreateEvent(day) },
|
||||
onShowWeek: { onShowWeek(day) },
|
||||
onShowDay: { onShowDay(day) })
|
||||
.frame(width: cellW, height: geo.size.height)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(Array(placed.enumerated()), id: \.offset) { _, p in
|
||||
Button { onEventTap(p.event) } label: {
|
||||
EventBar(event: p.event)
|
||||
.frame(width: cellW * CGFloat(p.span) - 2, height: laneHeight)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.offset(x: CGFloat(p.startCol) * cellW + 1,
|
||||
y: dayNumberRowHeight + CGFloat(p.lane) * (laneHeight + laneSpacing))
|
||||
}
|
||||
|
||||
// Vertical connector at the month-boundary column – ties the bottom-line
|
||||
// of old-month cells to the top-line of new-month cells into a step.
|
||||
if let b = midRowBoundaryCol {
|
||||
Rectangle()
|
||||
.fill(dividerColor)
|
||||
.frame(width: 1.5, height: geo.size.height)
|
||||
.offset(x: CGFloat(b) * cellW - 0.75, y: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: fillHeight ? nil : rowHeight)
|
||||
.frame(maxHeight: fillHeight ? .infinity : nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Day Cell
|
||||
|
||||
private struct DayCell: View {
|
||||
let date: Date
|
||||
let isToday: Bool
|
||||
let monthLabelColor: Color
|
||||
let dividerColor: Color
|
||||
let textColor: Color
|
||||
let lineColor: Color
|
||||
let language: String
|
||||
let extraCount: Int
|
||||
let weekNumber: Int?
|
||||
let cwLabel: String
|
||||
let edge: DividerEdge
|
||||
let dayEvents: [CalEvent]
|
||||
let onTap: () -> Void
|
||||
let onCreateEvent: () -> Void
|
||||
let onShowWeek: () -> Void
|
||||
let onShowDay: () -> Void
|
||||
|
||||
@AppStorage("textContrast") private var textContrast = 3
|
||||
@AppStorage("lineContrast") private var lineContrast = 3
|
||||
|
||||
private var cal: Calendar { Calendar.current }
|
||||
private var dayNum: Int { cal.component(.day, from: date) }
|
||||
private var isFirstOfMonth: Bool { dayNum == 1 }
|
||||
|
||||
private var monthAbbrev: String {
|
||||
let f = DateFormatter()
|
||||
f.locale = L10n.locale(language)
|
||||
f.dateFormat = "LLL"
|
||||
return f.string(from: date).uppercased()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: 4) {
|
||||
Text("\(dayNum)")
|
||||
.font(.system(size: 13, weight: isToday ? .bold : .regular))
|
||||
.foregroundStyle(isToday ? Color.white : textColor)
|
||||
.frame(width: 22, height: 22)
|
||||
.background(isToday ? Color.accentColor : Color.clear)
|
||||
.clipShape(Circle())
|
||||
if isFirstOfMonth {
|
||||
Text(monthAbbrev)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(monthLabelColor)
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.leading, 4)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer(minLength: 0)
|
||||
HStack(spacing: 0) {
|
||||
if extraCount > 0 {
|
||||
Text("+\(extraCount)")
|
||||
.font(.system(size: 9, weight: .medium))
|
||||
.foregroundStyle(textColor.opacity(secondaryTextOpacity(textContrast)))
|
||||
.padding(.leading, 4)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if let wn = weekNumber {
|
||||
Text("\(cwLabel) \(wn)")
|
||||
.font(.system(size: 9, weight: .medium))
|
||||
.foregroundStyle(textColor.opacity(secondaryTextOpacity(textContrast)))
|
||||
.padding(.trailing, 4)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.overlay(alignment: .trailing) {
|
||||
Rectangle().fill(lineColor.opacity(gridLineOpacity(lineContrast))).frame(width: 0.5)
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle()
|
||||
.fill(edge == .topHighlight ? dividerColor : lineColor.opacity(gridLineOpacity(lineContrast)))
|
||||
.frame(height: edge == .topHighlight ? 1.5 : 0.5)
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
if edge == .bottomHighlight {
|
||||
Rectangle().fill(dividerColor).frame(height: 1.5)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.contextMenu {
|
||||
Button { onCreateEvent() } label: {
|
||||
Label(L10n.t("cal.new_event", language), systemImage: "plus")
|
||||
}
|
||||
Button { onShowWeek() } label: {
|
||||
Label(L10n.t("cal.show_in_week_view", language),
|
||||
systemImage: "calendar.day.timeline.leading")
|
||||
}
|
||||
Button { onShowDay() } label: {
|
||||
Label(L10n.t("cal.show_in_day_view", language), systemImage: "sun.max")
|
||||
}
|
||||
} preview: {
|
||||
DayContextPreviewView(date: date, events: dayEvents, language: language)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Day Context Menu Preview
|
||||
|
||||
private struct DayContextPreviewView: View {
|
||||
let date: Date
|
||||
let events: [CalEvent]
|
||||
let language: String
|
||||
|
||||
private var cal: Calendar { .current }
|
||||
|
||||
private var sortedEvents: [CalEvent] {
|
||||
events.sorted { a, b in
|
||||
if a.isAllDay != b.isAllDay { return a.isAllDay }
|
||||
return a.startDate < b.startDate
|
||||
}
|
||||
}
|
||||
|
||||
private var weekdayAbbr: String {
|
||||
let fmt = DateFormatter()
|
||||
fmt.locale = L10n.locale(language)
|
||||
fmt.dateFormat = "EEE"
|
||||
return fmt.string(from: date).uppercased()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Text(weekdayAbbr)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(cal.component(.day, from: date))")
|
||||
.font(.title2.weight(.bold))
|
||||
}
|
||||
if !sortedEvents.isEmpty {
|
||||
Divider()
|
||||
ForEach(sortedEvents) { ev in
|
||||
if ev.isAllDay {
|
||||
DayPreviewAllDayBar(event: ev, date: date)
|
||||
} else {
|
||||
DayPreviewTimedRow(event: ev)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("–")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(minWidth: 220, maxWidth: 300)
|
||||
}
|
||||
}
|
||||
|
||||
private struct DayPreviewAllDayBar: View {
|
||||
let event: CalEvent
|
||||
let date: Date
|
||||
|
||||
private var cal: Calendar { .current }
|
||||
|
||||
var body: some View {
|
||||
let color = Color(hex: event.effectiveColor)
|
||||
let dayStart = cal.startOfDay(for: date)
|
||||
let dayEnd = cal.date(byAdding: .day, value: 1, to: dayStart)!
|
||||
let cLeft = event.startDate < dayStart
|
||||
let cRight = event.endDate > dayEnd // endDate is exclusive for allDay
|
||||
EventLabel(event: event)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 3)
|
||||
.padding(.leading, cLeft ? 14 : 8)
|
||||
.padding(.trailing, cRight ? 14 : 8)
|
||||
.background(color)
|
||||
.clipShape(ChevronBarShape(continuesLeft: cLeft, continuesRight: cRight))
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChevronBarShape: Shape {
|
||||
var continuesLeft: Bool
|
||||
var continuesRight: Bool
|
||||
private let tip: CGFloat = 8
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
guard continuesLeft || continuesRight else {
|
||||
return RoundedRectangle(cornerRadius: 4).path(in: rect)
|
||||
}
|
||||
var p = Path()
|
||||
let t = min(tip, rect.width / 2)
|
||||
if continuesLeft && continuesRight {
|
||||
p.move(to: CGPoint(x: t, y: 0))
|
||||
p.addLine(to: CGPoint(x: rect.maxX - t, y: 0))
|
||||
p.addLine(to: CGPoint(x: rect.maxX, y: rect.midY))
|
||||
p.addLine(to: CGPoint(x: rect.maxX - t, y: rect.maxY))
|
||||
p.addLine(to: CGPoint(x: t, y: rect.maxY))
|
||||
p.addLine(to: CGPoint(x: 0, y: rect.midY))
|
||||
} else if continuesLeft {
|
||||
p.move(to: CGPoint(x: t, y: 0))
|
||||
p.addLine(to: CGPoint(x: rect.maxX, y: 0))
|
||||
p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
|
||||
p.addLine(to: CGPoint(x: t, y: rect.maxY))
|
||||
p.addLine(to: CGPoint(x: 0, y: rect.midY))
|
||||
} else {
|
||||
p.move(to: CGPoint(x: 0, y: 0))
|
||||
p.addLine(to: CGPoint(x: rect.maxX - t, y: 0))
|
||||
p.addLine(to: CGPoint(x: rect.maxX, y: rect.midY))
|
||||
p.addLine(to: CGPoint(x: rect.maxX - t, y: rect.maxY))
|
||||
p.addLine(to: CGPoint(x: 0, y: rect.maxY))
|
||||
}
|
||||
p.closeSubpath()
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
private struct DayPreviewTimedRow: View {
|
||||
let event: CalEvent
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(Color(hex: event.effectiveColor))
|
||||
.frame(width: 7, height: 7)
|
||||
Text(timeString)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 44, alignment: .leading)
|
||||
EventLabel(event: event)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private var timeString: String {
|
||||
let fmt = DateFormatter()
|
||||
fmt.dateFormat = "HH:mm"
|
||||
return fmt.string(from: event.startDate)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Event Bar
|
||||
|
||||
private struct EventBar: View {
|
||||
let event: CalEvent
|
||||
@AppStorage("dimPastEvents") private var dimPast = false
|
||||
|
||||
private var isPast: Bool { event.endDate < .now }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 3) {
|
||||
EventLabel(event: event)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.leading, 4)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(hex: event.effectiveColor))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 3))
|
||||
.opacity(dimPast && isPast ? 0.5 : 1.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import SwiftUI
|
||||
|
||||
struct QuarterView: View {
|
||||
let store: CalendarStore
|
||||
let onEventTap: (CalEvent) -> Void
|
||||
|
||||
private var cal: Calendar { store.userCalendar }
|
||||
|
||||
private var months: [Date] {
|
||||
let start = cal.date(from: cal.dateComponents([.year, .month], from: store.currentDate))!
|
||||
return (0..<3).compactMap { cal.date(byAdding: .month, value: $0, to: start) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(months, id: \.self) { month in
|
||||
MiniMonthBlock(month: month, store: store, onEventTap: onEventTap)
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MiniMonthBlock: View {
|
||||
let month: Date
|
||||
let store: CalendarStore
|
||||
let onEventTap: (CalEvent) -> Void
|
||||
|
||||
private var cal: Calendar { store.userCalendar }
|
||||
|
||||
private let monthFmt: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "MMMM yyyy"; return f
|
||||
}()
|
||||
|
||||
private var gridDays: [Date] {
|
||||
let firstWeekday = cal.firstWeekday
|
||||
let weekday = cal.component(.weekday, from: month)
|
||||
let offset = ((weekday - firstWeekday) + 7) % 7
|
||||
let gridStart = cal.date(byAdding: .day, value: -offset, to: month)!
|
||||
let rows = 6
|
||||
return (0..<(rows * 7)).compactMap { cal.date(byAdding: .day, value: $0, to: gridStart) }
|
||||
}
|
||||
|
||||
private var weekdayHeaders: [String] {
|
||||
let symbols = cal.veryShortWeekdaySymbols
|
||||
let start = cal.firstWeekday - 1
|
||||
return (0..<7).map { symbols[(start + $0) % 7] }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(monthFmt.string(from: month))
|
||||
.font(.headline.weight(.semibold))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
|
||||
HStack(spacing: 0) {
|
||||
ForEach(weekdayHeaders, id: \.self) { d in
|
||||
Text(d)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 0), count: 7), spacing: 2) {
|
||||
ForEach(gridDays, id: \.self) { day in
|
||||
MiniDayCell(
|
||||
date: day,
|
||||
isCurrentMonth: cal.isDate(day, equalTo: month, toGranularity: .month),
|
||||
isToday: cal.isDateInToday(day),
|
||||
events: store.events(on: day),
|
||||
onEventTap: onEventTap
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MiniDayCell: View {
|
||||
let date: Date
|
||||
let isCurrentMonth: Bool
|
||||
let isToday: Bool
|
||||
let events: [CalEvent]
|
||||
let onEventTap: (CalEvent) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 1) {
|
||||
Text("\(Calendar.current.component(.day, from: date))")
|
||||
.font(.system(size: 12, weight: isToday ? .bold : .regular))
|
||||
.foregroundStyle(
|
||||
isToday ? Color.white :
|
||||
isCurrentMonth ? Color.primary : Color.secondary.opacity(0.3)
|
||||
)
|
||||
.frame(width: 22, height: 22)
|
||||
.background(isToday ? Color.accentColor : Color.clear)
|
||||
.clipShape(Circle())
|
||||
|
||||
// Up to 3 event dots
|
||||
HStack(spacing: 2) {
|
||||
ForEach(events.prefix(3)) { ev in
|
||||
Circle()
|
||||
.fill(Color(hex: ev.effectiveColor))
|
||||
.frame(width: 4, height: 4)
|
||||
.onTapGesture { onEventTap(ev) }
|
||||
}
|
||||
}
|
||||
.frame(height: 6)
|
||||
}
|
||||
.frame(minHeight: 36)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import SwiftUI
|
||||
|
||||
// Shared constants used by WeekView, DayView, EventEditorSheet
|
||||
let timeColumnWidth: CGFloat = 44
|
||||
let hours = Array(0..<24)
|
||||
|
||||
/// Live hour-row height, driven by the synced `hourHeight` setting.
|
||||
/// Falls back to 60 when unset (fresh install / value 0). Views that lay out
|
||||
/// against this also observe `@AppStorage("hourHeight")` so their body
|
||||
/// re-renders when it changes.
|
||||
var hourHeight: CGFloat {
|
||||
let v = UserDefaults.standard.integer(forKey: "hourHeight")
|
||||
return v > 0 ? CGFloat(v) : 60
|
||||
}
|
||||
|
||||
/// Opacity for secondary text (weekday headers, time labels, "+N"/"KW"),
|
||||
/// mapped from the 1–4 `textContrast` level. Level 3 ≈ the previous hard-coded
|
||||
/// look so existing installs are visually unchanged.
|
||||
func secondaryTextOpacity(_ level: Int) -> Double {
|
||||
switch level {
|
||||
case 1: return 0.4
|
||||
case 2: return 0.55
|
||||
case 4: return 1.0
|
||||
default: return 0.75
|
||||
}
|
||||
}
|
||||
|
||||
/// Opacity for grid lines / separators, mapped from the 1–4 `lineContrast`
|
||||
/// level. Level 3 ≈ the previous hard-coded ~0.4 look.
|
||||
func gridLineOpacity(_ level: Int) -> Double {
|
||||
switch level {
|
||||
case 1: return 0.15
|
||||
case 2: return 0.3
|
||||
case 4: return 0.8
|
||||
default: return 0.5
|
||||
}
|
||||
}
|
||||
|
||||
/// A timed (non-all-day) event that crosses a day boundary. Such events must
|
||||
/// NOT be placed in the hourly grid — their height would be `duration ×
|
||||
/// hourHeight`, i.e. taller than the whole day, rendering as a giant block
|
||||
/// (and, sharing one id across days, only drawing on the first day). They are
|
||||
/// shown in the all-day strip instead, like all-day events.
|
||||
func eventSpansMultipleDays(_ ev: CalEvent) -> Bool {
|
||||
guard !ev.isAllDay, ev.endDate > ev.startDate else { return false }
|
||||
let cal = Calendar.current
|
||||
// End is exclusive: an event ending exactly at midnight is still single-day.
|
||||
let lastInstant = ev.endDate.addingTimeInterval(-1)
|
||||
return !cal.isDate(ev.startDate, inSameDayAs: lastInstant)
|
||||
}
|
||||
|
||||
// Position helpers
|
||||
func eventTop(_ ev: CalEvent) -> CGFloat {
|
||||
let cal = Calendar.current
|
||||
let h = CGFloat(cal.component(.hour, from: ev.startDate))
|
||||
let m = CGFloat(cal.component(.minute, from: ev.startDate))
|
||||
return h * hourHeight + m * hourHeight / 60
|
||||
}
|
||||
|
||||
func eventHeight(_ ev: CalEvent) -> CGFloat {
|
||||
let dur = ev.endDate.timeIntervalSince(ev.startDate)
|
||||
return max(CGFloat(dur / 3600) * hourHeight, 20)
|
||||
}
|
||||
|
||||
// Shared event block used in WeekView and DayView
|
||||
struct EventBlock: View {
|
||||
let event: CalEvent
|
||||
@AppStorage("dimPastEvents") private var dimPast = false
|
||||
|
||||
private var isPast: Bool { event.endDate < .now }
|
||||
|
||||
var body: some View {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color(hex: event.effectiveColor).opacity(0.85))
|
||||
.overlay(alignment: .topLeading) {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
EventLabel(event: event)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(2)
|
||||
if !event.location.isEmpty {
|
||||
Text(event.location)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
.padding(.horizontal, 1)
|
||||
.opacity(dimPast && isPast ? 0.5 : 1.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WeekView: View {
|
||||
let store: CalendarStore
|
||||
let onEventTap: (CalEvent) -> Void
|
||||
let onCreateEvent: (Date) -> Void
|
||||
let onShowMonth: (Date) -> Void
|
||||
let onShowDay: (Date) -> Void
|
||||
|
||||
@AppStorage("appLanguage") private var appLang = "system"
|
||||
@AppStorage("todayColor") private var todayHex = "#4285f4"
|
||||
@AppStorage("textColor") private var textHex = "#FFFFFF"
|
||||
@AppStorage("lineColor") private var lineHex = "#3A3A52"
|
||||
@AppStorage("textContrast") private var textContrast = 3
|
||||
@AppStorage("lineContrast") private var lineContrast = 3
|
||||
@AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout
|
||||
|
||||
private var cal: Calendar { store.userCalendar }
|
||||
|
||||
private var weekDays: [Date] {
|
||||
let start = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: store.currentDate))!
|
||||
return (0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: start) }
|
||||
}
|
||||
|
||||
private var timedEvents: [(Int, CalEvent)] {
|
||||
weekDays.enumerated().flatMap { idx, day in
|
||||
store.events(on: day)
|
||||
.filter { !$0.isAllDay && !eventSpansMultipleDays($0) }
|
||||
.map { (idx, $0) }
|
||||
}
|
||||
}
|
||||
|
||||
private var allDayEvents: [CalEvent] {
|
||||
let s = weekDays.first ?? .now
|
||||
let e = cal.date(byAdding: .day, value: 1, to: weekDays.last ?? .now)!
|
||||
return store.events(in: s, end: e).filter { $0.isAllDay || eventSpansMultipleDays($0) }
|
||||
}
|
||||
|
||||
private var todayIndex: Int? {
|
||||
weekDays.firstIndex(where: { cal.isDateInToday($0) })
|
||||
}
|
||||
|
||||
private let headerFmt: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "EEE d"; return f
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
columnHeaders
|
||||
Divider()
|
||||
if !allDayEvents.isEmpty { allDayRow }
|
||||
timeGrid
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Column headers (fixed height — no Color.clear tricks)
|
||||
|
||||
private var columnHeaders: some View {
|
||||
HStack(spacing: 0) {
|
||||
Spacer().frame(width: timeColumnWidth, height: 36) // fixed height!
|
||||
ForEach(weekDays, id: \.self) { day in
|
||||
Text(headerFmt.string(from: day).uppercased())
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(cal.isDateInToday(day) ? Color(hex: todayHex) : Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
|
||||
.frame(maxWidth: .infinity, minHeight: 36)
|
||||
.overlay(alignment: .trailing) {
|
||||
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(width: 0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – All-day strip
|
||||
|
||||
private let allDayLaneHeight: CGFloat = 16
|
||||
|
||||
/// All-day / multi-day events laid out as continuous bars: each event spans
|
||||
/// its start→end columns (clamped to this week) and is packed into lanes so
|
||||
/// overlapping bars stack — mirrors the month view instead of repeating the
|
||||
/// event once per day.
|
||||
private func allDayBars() -> [(ev: CalEvent, start: Int, end: Int, lane: Int)] {
|
||||
var spans: [(ev: CalEvent, start: Int, end: Int)] = []
|
||||
for ev in allDayEvents {
|
||||
var start: Int? = nil
|
||||
var end = 0
|
||||
for (i, day) in weekDays.enumerated() {
|
||||
let ds = cal.startOfDay(for: day)
|
||||
let de = cal.date(byAdding: .day, value: 1, to: ds)!
|
||||
if ev.startDate < de && ev.endDate > ds {
|
||||
if start == nil { start = i }
|
||||
end = i
|
||||
}
|
||||
}
|
||||
if let s = start { spans.append((ev, s, end)) }
|
||||
}
|
||||
// Longer / earlier bars first, then greedily pack into lanes.
|
||||
spans.sort { a, b in a.start != b.start ? a.start < b.start : (a.end - a.start) > (b.end - b.start) }
|
||||
var laneEnd: [Int] = [] // last occupied column index per lane
|
||||
var out: [(ev: CalEvent, start: Int, end: Int, lane: Int)] = []
|
||||
for s in spans {
|
||||
var lane = 0
|
||||
while lane < laneEnd.count && laneEnd[lane] >= s.start { lane += 1 }
|
||||
if lane == laneEnd.count { laneEnd.append(s.end) } else { laneEnd[lane] = s.end }
|
||||
out.append((s.ev, s.start, s.end, lane))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private var allDayRow: some View {
|
||||
let bars = allDayBars()
|
||||
let laneCount = max(1, (bars.map { $0.lane }.max() ?? -1) + 1)
|
||||
return GeometryReader { geo in
|
||||
let colW = (geo.size.width - timeColumnWidth) / 7
|
||||
ZStack(alignment: .topLeading) {
|
||||
ForEach(bars, id: \.ev.id) { bar in
|
||||
Button { onEventTap(bar.ev) } label: {
|
||||
EventLabel(event: bar.ev)
|
||||
.font(.system(size: 9, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 3)
|
||||
.frame(height: allDayLaneHeight - 2)
|
||||
.background(Color(hex: bar.ev.effectiveColor))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 2))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: colW * CGFloat(bar.end - bar.start + 1) - 3)
|
||||
.offset(x: timeColumnWidth + colW * CGFloat(bar.start) + 1.5,
|
||||
y: CGFloat(bar.lane) * allDayLaneHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: CGFloat(laneCount) * allDayLaneHeight)
|
||||
.padding(.vertical, 4)
|
||||
.overlay(alignment: .bottom) { Divider() }
|
||||
}
|
||||
|
||||
// MARK: – Time grid (GeometryReader OUTSIDE ScrollView → clean layout)
|
||||
|
||||
private var timeGrid: some View {
|
||||
GeometryReader { geo in
|
||||
let colW = (geo.size.width - timeColumnWidth) / 7
|
||||
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
ZStack(alignment: .topLeading) {
|
||||
// Background: time labels + per-hour cells per day
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
timeLabels
|
||||
ForEach(Array(weekDays.enumerated()), id: \.offset) { _, day in
|
||||
VStack(spacing: 0) {
|
||||
ForEach(hours, id: \.self) { hour in
|
||||
HourSlot(day: day, hour: hour,
|
||||
hourHeight: hourHeight,
|
||||
language: appLang,
|
||||
onCreateEvent: onCreateEvent,
|
||||
onShowMonth: onShowMonth,
|
||||
onShowDay: onShowDay)
|
||||
}
|
||||
}
|
||||
.frame(width: colW)
|
||||
.overlay(alignment: .trailing) {
|
||||
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(width: 0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Events – positioned using known column widths (no GeometryReader inside)
|
||||
ForEach(timedEvents, id: \.1.id) { dayIdx, ev in
|
||||
Button(action: { onEventTap(ev) }) {
|
||||
EventBlock(event: ev)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: colW - 2, height: max(eventHeight(ev), 18))
|
||||
.offset(x: timeColumnWidth + CGFloat(dayIdx) * colW + 1,
|
||||
y: eventTop(ev))
|
||||
}
|
||||
|
||||
// Current time line
|
||||
if let ti = todayIndex {
|
||||
let lineY = eventTop(Date.now)
|
||||
let nowColor = Color(hex: todayHex)
|
||||
HStack(spacing: 0) {
|
||||
Spacer().frame(width: timeColumnWidth + CGFloat(ti) * colW - 4)
|
||||
Circle().fill(nowColor).frame(width: 8, height: 8)
|
||||
Rectangle().fill(nowColor).frame(width: colW - 4, height: 1.5)
|
||||
}
|
||||
.offset(y: lineY - 0.75)
|
||||
}
|
||||
}
|
||||
.frame(width: geo.size.width, height: hourHeight * 24 + 80)
|
||||
.id("grid")
|
||||
}
|
||||
.onAppear { scrollToCurrentHour(proxy) }
|
||||
.onChange(of: store.currentDate) { _, _ in scrollToCurrentHour(proxy) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var timeLabels: some View {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(hours, id: \.self) { h in
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Color.clear.frame(height: hourHeight)
|
||||
Text(String(format: "%02d:00", h))
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
|
||||
.offset(y: -6)
|
||||
}
|
||||
}
|
||||
Color.clear.frame(height: 80) // FAB space
|
||||
}
|
||||
.frame(width: timeColumnWidth)
|
||||
}
|
||||
|
||||
private func scrollToCurrentHour(_ proxy: ScrollViewProxy) {
|
||||
let h = Calendar.current.component(.hour, from: .now)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
||||
withAnimation(.easeOut(duration: 0.3)) {
|
||||
proxy.scrollTo("grid", anchor: UnitPoint(x: 0, y: CGFloat(max(h - 1, 0)) / 24.0))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trick to compute eventTop from a Date instead of CalEvent
|
||||
private func eventTop(_ date: Date) -> CGFloat {
|
||||
let cal = Calendar.current
|
||||
let h = CGFloat(cal.component(.hour, from: date))
|
||||
let m = CGFloat(cal.component(.minute, from: date))
|
||||
return h * hourHeight + m * hourHeight / 60
|
||||
}
|
||||
|
||||
// One-hour slot with native long-press context menu.
|
||||
struct HourSlot: View {
|
||||
let day: Date
|
||||
let hour: Int
|
||||
let hourHeight: CGFloat
|
||||
let language: String
|
||||
let onCreateEvent: (Date) -> Void
|
||||
let onShowMonth: (Date) -> Void
|
||||
let onShowDay: (Date) -> Void
|
||||
|
||||
@AppStorage("lineColor") private var lineHex = "#3A3A52"
|
||||
@AppStorage("lineContrast") private var lineContrast = 3
|
||||
|
||||
private var date: Date {
|
||||
Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(height: 0.5)
|
||||
Color.clear.frame(height: hourHeight - 0.5)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.contextMenu {
|
||||
Button { onCreateEvent(date) } label: {
|
||||
Label(L10n.t("cal.new_event", language), systemImage: "plus")
|
||||
}
|
||||
Button { onShowMonth(date) } label: {
|
||||
Label(L10n.t("cal.show_in_month_view", language), systemImage: "calendar")
|
||||
}
|
||||
Button { onShowDay(date) } label: {
|
||||
Label(L10n.t("cal.show_in_day_view", language), systemImage: "sun.max")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user