import QtQuick import "Util.js" as Util import "caldav.js" as Ical Column { id: root required property Theme theme readonly property Flickable scrollView: agendaFlick required property Time timeService required property CalDav caldavService property var panelState: root.timeService ? Util.parseJson(root.timeService.stateJson(), {}) : ({}) property date localNow: new Date() property var calendar: root.timeService ? Util.parseJson(root.timeService.calendar(localNow.getFullYear(), localNow.getMonth() + 1, localNow.getDate()), { "weeks": [] }) : ({ "weeks": [] }) property var days: calendar.weeks ? [].concat.apply([], calendar.weeks) : [] readonly property bool setupMode: root.caldavService && root.caldavService.status === "needs-setup" // grid sizing: big square day cells, grid centered in the popout readonly property int gridSpacing: 6 readonly property int cellW: 48 readonly property int cellH: 48 readonly property real pitchX: cellW + gridSpacing readonly property real pitchY: cellH + gridSpacing readonly property real gridW: cellW * 7 + gridSpacing * 6 // iso "YYYY-MM-DD" -> array of single-day markers {color, allDay}. // Multi-day events (coveredDates>1) are drawn as bands, not markers. readonly property var caldavEvents: root.caldavService ? root.caldavService.events : [] readonly property var eventsByDate: { const map = {}; const evs = caldavEvents || []; for (let i = 0; i < evs.length; i++) { const ev = evs[i]; const dates = Ical.coveredDates(ev); if (dates.length !== 1) continue; const iso = dates[0]; (map[iso] || (map[iso] = [])).push({ "color": ev.color, "allDay": ev.allDay || ev.kind === "task" }); } // all-day pills before timed dots within each day for (const k in map) map[k].sort(function (a, b) { return (b.allDay ? 1 : 0) - (a.allDay ? 1 : 0); }); return map; } // multi-day events as week-split band segments (Ical.buildSegments) readonly property var bandLayout: { const gs = root.caldavService ? root.caldavService.gridStartDate() : null; if (!gs) return { "segments": [], "laneCount": 0 }; return Ical.buildSegments(caldavEvents || [], gs); } readonly property string todayIso: { const d = root.localNow; const month = d.getMonth() + 1; const day = d.getDate(); return d.getFullYear() + "-" + (month < 10 ? "0" : "") + month + "-" + (day < 10 ? "0" : "") + day; } // today's events + tasks, sorted: all-day/untimed first, then by start time readonly property var todayItems: { const evs = caldavEvents || []; const out = []; for (let i = 0; i < evs.length; i++) { if (Ical.coveredDates(evs[i]).indexOf(todayIso) >= 0) out.push(evs[i]); } out.sort(function (a, b) { const ta = (a.allDay || a.startMs == null) ? -1 : a.startMs; const tb = (b.allDay || b.startMs == null) ? -1 : b.startMs; return ta - tb; }); return out; } spacing: 8 Component.onCompleted: if (root.caldavService) root.caldavService.refresh() NuguText { width: parent ? parent.width : 0 horizontalAlignment: Text.AlignHCenter theme: root.theme variant: "title" text: root.panelState.clock ? root.panelState.clock.statusDisplay : "--:--:--" } NuguText { width: parent ? parent.width : 0 horizontalAlignment: Text.AlignHCenter theme: root.theme variant: "meta" text: (root.panelState.date ? root.panelState.date.line : "calendar") + (root.caldavService && root.caldavService.stale ? " ·" : "") } // centered calendar block: weekday header + month grid + bands Item { visible: !root.setupMode width: parent ? parent.width : 0 height: calStack.height Column { id: calStack anchors.horizontalCenter: parent.horizontalCenter spacing: root.gridSpacing // single-letter weekday header, Monday-first; weekend dimmed Row { spacing: root.gridSpacing Repeater { model: ["M", "D", "M", "D", "F", "S", "S"] Item { width: root.cellW height: hdr.height NuguText { id: hdr anchors.horizontalCenter: parent.horizontalCenter theme: root.theme variant: "meta" text: modelData opacity: index >= 5 ? 0.4 : 0.7 } } } } Item { width: monthGrid.width height: monthGrid.height Grid { id: monthGrid objectName: "calendar-grid" columns: 7 spacing: root.gridSpacing Repeater { model: root.days Item { id: cell readonly property var markers: root.eventsByDate[modelData.iso] || [] width: root.cellW height: root.cellH // today: filled accent circle behind the number Rectangle { visible: !!modelData.today anchors.horizontalCenter: parent.horizontalCenter anchors.verticalCenter: parent.verticalCenter anchors.verticalCenterOffset: -5 width: 34 height: 34 radius: 17 color: root.theme ? root.theme.ui.accent : "transparent" } NuguText { anchors.horizontalCenter: parent.horizontalCenter anchors.verticalCenter: parent.verticalCenter anchors.verticalCenterOffset: -5 theme: root.theme variant: "body" text: modelData.day ? String(modelData.day) : "" color: modelData.today && root.theme ? root.theme.content.backdrop : (root.theme ? root.theme.content.normal : "white") opacity: modelData.inMonth ? 1 : 0.3 } // marker strip: all-day pills + timed dots, max 3 then "+" Row { anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom anchors.bottomMargin: 7 spacing: 3 Repeater { model: Math.min(cell.markers.length, 3) Rectangle { readonly property var m: cell.markers[index] readonly property bool overflow: index === 2 && cell.markers.length > 3 width: overflow ? 5 : (m.allDay ? 8 : 4) height: 3 radius: 1.5 color: overflow ? (root.theme ? root.theme.content.minor : "gray") : m.color } } } } } } // multi-day bands sit behind day numbers and dots. // pitch derives from the live cell size; bands sit near the // cell bottom, stacking upward per lane. Item { anchors.fill: monthGrid z: -1 Repeater { model: root.bandLayout.segments Rectangle { readonly property var seg: modelData x: seg.colStart * root.pitchX y: seg.row * root.pitchY + root.cellH - 17 - seg.lane * 9 width: (seg.colEnd - seg.colStart + 1) * root.pitchX - root.gridSpacing height: 6 color: Qt.alpha(seg.color, 0.9) topLeftRadius: seg.roundLeft ? 3 : 0 bottomLeftRadius: seg.roundLeft ? 3 : 0 topRightRadius: seg.roundRight ? 3 : 0 bottomRightRadius: seg.roundRight ? 3 : 0 } } } } } } // today's agenda: events + tasks, ○ for open tasks, ✓ for done; centered Item { id: agendaFrame visible: !root.setupMode width: parent ? parent.width : 0 height: Math.max(70, parent ? parent.height - y : agendaCol.height) clip: true Flickable { id: agendaFlick anchors.fill: parent contentWidth: root.gridW contentHeight: agendaCol.height interactive: contentHeight > height boundsBehavior: Flickable.StopAtBounds Column { id: agendaCol x: Math.round((agendaFrame.width - width) / 2) width: root.gridW spacing: 4 Repeater { model: root.todayItems Row { spacing: 6 Rectangle { anchors.verticalCenter: parent.verticalCenter width: 6 height: 6 radius: 3 color: modelData.color } NuguText { theme: root.theme variant: "body" text: { const ev = modelData; const mark = ev.kind === "task" ? (ev.done ? "✓ " : "○ ") : ""; let t = ""; if (!ev.allDay && ev.startMs != null) { const d = new Date(ev.startMs); const p = function (n) { return (n < 10 ? "0" : "") + n; }; t = p(d.getHours()) + ":" + p(d.getMinutes()) + " "; } return t + mark + (ev.summary || "(ohne Titel)"); } } } } NuguText { visible: root.todayItems.length === 0 theme: root.theme variant: "meta" text: "keine termine" } } } Rectangle { visible: agendaFlick.interactive anchors.right: parent.right anchors.rightMargin: 2 y: agendaFlick.contentY / Math.max(1, agendaFlick.contentHeight) * parent.height width: 3 height: Math.max(18, parent.height * Math.min(1, parent.height / Math.max(parent.height, agendaFlick.contentHeight))) radius: 2 color: root.theme ? root.theme.withAlpha(root.theme.ui.accent, 0.55) : "white" } } // setup form: shown when the service has no usable config yet. // Keystrokes need the popout to hold keyboard focus; the IPC // saveSetup path is the verified entry point. Column { id: setupForm visible: root.setupMode width: parent ? parent.width : 0 spacing: 8 // grab keyboard focus into the first field once the card has // finished claiming the surface focus (callLater wins the race) onVisibleChanged: if (visible) Qt.callLater(urlField.field.forceActiveFocus) Component.onCompleted: if (visible) Qt.callLater(urlField.field.forceActiveFocus) NuguText { theme: root.theme variant: "meta" text: "caldav einrichten" } SetupField { id: urlField theme: root.theme placeholder: "https://host/remote.php/dav" text: root.caldavService ? root.caldavService.setupUrlDraft : "" onEdited: text => { if (root.caldavService) root.caldavService.setupUrlDraft = text; } } SetupField { id: userField theme: root.theme placeholder: "benutzername" text: root.caldavService ? root.caldavService.setupUsernameDraft : "" onEdited: text => { if (root.caldavService) root.caldavService.setupUsernameDraft = text; } } SetupField { id: pwField theme: root.theme placeholder: "app-passwort" secret: true text: root.caldavService ? root.caldavService.setupPasswordDraft : "" onEdited: text => { if (root.caldavService) root.caldavService.setupPasswordDraft = text; } } PopupButton { theme: root.theme width: parent.width height: 30 text: "speichern" role: "primary" onClicked: if (root.caldavService) root.caldavService.saveSetup(urlField.text, userField.text, pwField.text) } NuguText { visible: root.caldavService && root.caldavService.secretError.length > 0 theme: root.theme variant: "meta" color: root.theme ? root.theme.error : "red" text: root.caldavService ? root.caldavService.secretError : "" } } }