From 8a2b81aafb329365ab6276af59a76d4b9e4e4757 Mon Sep 17 00:00:00 2001 From: bbedward Date: Sat, 12 Jul 2025 13:26:09 -0400 Subject: [PATCH] Implement calendar events with khal --- Services/CalendarService.qml | 314 +++++++++++++++++ Services/qmldir | 3 +- .../CenterCommandCenter/CalendarWidget.qml | 100 ++++++ .../CenterCommandCenter.qml | 106 ++++-- Widgets/CenterCommandCenter/EventsWidget.qml | 316 ++++++++++++++++++ Widgets/CenterCommandCenter/qmldir | 3 +- Widgets/ControlCenter/ControlCenterPopup.qml | 64 +++- shell.qml | 4 + 8 files changed, 876 insertions(+), 34 deletions(-) create mode 100644 Services/CalendarService.qml create mode 100644 Widgets/CenterCommandCenter/EventsWidget.qml diff --git a/Services/CalendarService.qml b/Services/CalendarService.qml new file mode 100644 index 00000000..781687ca --- /dev/null +++ b/Services/CalendarService.qml @@ -0,0 +1,314 @@ +import QtQuick +import Quickshell +import Quickshell.Io +pragma Singleton +pragma ComponentBehavior: Bound + +Singleton { + id: root + + property bool khalAvailable: false + property var eventsByDate: ({}) + property bool isLoading: false + property string lastError: "" + + // Periodic refresh timer (5 minutes) + Timer { + id: refreshTimer + interval: 300000 // 5 minutes + running: root.khalAvailable + repeat: true + onTriggered: { + if (lastStartDate && lastEndDate) { + loadEvents(lastStartDate, lastEndDate) + } + } + } + + property date lastStartDate + property date lastEndDate + + // Process for checking khal configuration + Process { + id: khalCheckProcess + command: ["khal", "list", "today"] + running: false + + onExited: (exitCode) => { + root.khalAvailable = (exitCode === 0) + if (exitCode !== 0) { + console.warn("CalendarService: khal not configured (exit code:", exitCode, ")") + } else { + console.log("CalendarService: khal configured and available") + // Load current month events when khal becomes available + loadCurrentMonth() + } + } + + onStarted: { + console.log("CalendarService: Checking khal configuration...") + } + } + + // Process for loading events + Process { + id: eventsProcess + running: false + + property date requestStartDate + property date requestEndDate + property string rawOutput: "" + + stdout: SplitParser { + splitMarker: "\n" + onRead: (data) => { + eventsProcess.rawOutput += data + "\n" + } + } + + onExited: (exitCode) => { + root.isLoading = false + + if (exitCode !== 0) { + root.lastError = "Failed to load events (exit code: " + exitCode + ")" + console.warn("CalendarService:", root.lastError) + return + } + + try { + let newEventsByDate = {} + let lines = eventsProcess.rawOutput.split('\n') + + for (let line of lines) { + line = line.trim() + if (!line || line === "[]") continue + + // Parse JSON line + let dayEvents = JSON.parse(line) + + // Process each event in this day's array + for (let event of dayEvents) { + if (!event.title) continue + + // Parse start and end dates + let startDate, endDate + if (event['start-date']) { + let startParts = event['start-date'].split('/') + startDate = new Date(parseInt(startParts[2]), parseInt(startParts[0]) - 1, parseInt(startParts[1])) + } else { + startDate = new Date() + } + + if (event['end-date']) { + let endParts = event['end-date'].split('/') + endDate = new Date(parseInt(endParts[2]), parseInt(endParts[0]) - 1, parseInt(endParts[1])) + } else { + endDate = new Date(startDate) + } + + // Create start/end times + let startTime = new Date(startDate) + let endTime = new Date(endDate) + + if (event['start-time'] && event['all-day'] !== "True") { + // Parse time if available and not all-day + let timeStr = event['start-time'] + if (timeStr) { + let timeParts = timeStr.match(/(\d+):(\d+)/) + if (timeParts) { + startTime.setHours(parseInt(timeParts[1]), parseInt(timeParts[2])) + + if (event['end-time']) { + let endTimeParts = event['end-time'].match(/(\d+):(\d+)/) + if (endTimeParts) { + endTime.setHours(parseInt(endTimeParts[1]), parseInt(endTimeParts[2])) + } + } else { + // Default to 1 hour duration on same day + endTime = new Date(startTime) + endTime.setHours(startTime.getHours() + 1) + } + } + } + } + + // Create unique ID for this event (to track multi-day events) + let eventId = event.title + "_" + event['start-date'] + "_" + (event['start-time'] || 'allday') + + // Create event object template + let eventTemplate = { + id: eventId, + title: event.title || "Untitled Event", + start: startTime, + end: endTime, + location: event.location || "", + description: event.description || "", + url: event.url || "", + calendar: "", + color: "", + allDay: event['all-day'] === "True", + isMultiDay: startDate.toDateString() !== endDate.toDateString() + } + + // Add event to each day it spans + let currentDate = new Date(startDate) + while (currentDate <= endDate) { + let dateKey = Qt.formatDate(currentDate, "yyyy-MM-dd") + + if (!newEventsByDate[dateKey]) { + newEventsByDate[dateKey] = [] + } + + // Check if this exact event is already added to this date (prevent duplicates) + let existingEvent = newEventsByDate[dateKey].find(e => e.id === eventId) + if (existingEvent) { + // Move to next day without adding duplicate + currentDate.setDate(currentDate.getDate() + 1) + continue + } + + // Create a copy of the event for this date + let dayEvent = Object.assign({}, eventTemplate) + + // For multi-day events, adjust the display time for this specific day + if (currentDate.getTime() === startDate.getTime()) { + // First day - use original start time + dayEvent.start = new Date(startTime) + } else { + // Subsequent days - start at beginning of day for all-day events + dayEvent.start = new Date(currentDate) + if (!dayEvent.allDay) { + dayEvent.start.setHours(0, 0, 0, 0) + } + } + + if (currentDate.getTime() === endDate.getTime()) { + // Last day - use original end time + dayEvent.end = new Date(endTime) + } else { + // Earlier days - end at end of day for all-day events + dayEvent.end = new Date(currentDate) + if (!dayEvent.allDay) { + dayEvent.end.setHours(23, 59, 59, 999) + } + } + + newEventsByDate[dateKey].push(dayEvent) + + // Move to next day + currentDate.setDate(currentDate.getDate() + 1) + } + } + } + + // Sort events by start time within each date + for (let dateKey in newEventsByDate) { + newEventsByDate[dateKey].sort((a, b) => a.start.getTime() - b.start.getTime()) + } + + root.eventsByDate = newEventsByDate + root.lastError = "" + + console.log("CalendarService: Loaded events for", Object.keys(newEventsByDate).length, "days") + + // Debug: log parsed events + for (let dateKey in newEventsByDate) { + console.log(" " + dateKey + ":", newEventsByDate[dateKey].map(e => e.title).join(", ")) + } + + } catch (error) { + root.lastError = "Failed to parse events JSON: " + error.toString() + console.error("CalendarService:", root.lastError) + console.error("CalendarService: Raw output was:", eventsProcess.rawOutput) + root.eventsByDate = {} + } + + // Reset for next run + eventsProcess.rawOutput = "" + } + + onStarted: { + console.log("CalendarService: Loading events from", Qt.formatDate(requestStartDate, "yyyy-MM-dd"), + "to", Qt.formatDate(requestEndDate, "yyyy-MM-dd")) + } + } + + function checkKhalAvailability() { + if (!khalCheckProcess.running) { + khalCheckProcess.running = true + } + } + + function loadCurrentMonth() { + if (!root.khalAvailable) return + + let today = new Date() + let firstDay = new Date(today.getFullYear(), today.getMonth(), 1) + let lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0) + + // Add padding + let startDate = new Date(firstDay) + startDate.setDate(startDate.getDate() - firstDay.getDay() - 7) + + let endDate = new Date(lastDay) + endDate.setDate(endDate.getDate() + (6 - lastDay.getDay()) + 7) + + loadEvents(startDate, endDate) + } + + function loadEvents(startDate, endDate) { + if (!root.khalAvailable) { + console.warn("CalendarService: khal not available, skipping event loading") + return + } + + if (eventsProcess.running) { + console.log("CalendarService: Event loading already in progress, skipping...") + return + } + + // Store last requested date range for refresh timer + root.lastStartDate = startDate + root.lastEndDate = endDate + + root.isLoading = true + + // Format dates for khal (MM/dd/yyyy based on printformats) + let startDateStr = Qt.formatDate(startDate, "MM/dd/yyyy") + let endDateStr = Qt.formatDate(endDate, "MM/dd/yyyy") + + eventsProcess.requestStartDate = startDate + eventsProcess.requestEndDate = endDate + eventsProcess.command = [ + "khal", "list", + "--json", "title", + "--json", "description", + "--json", "start-date", + "--json", "start-time", + "--json", "end-date", + "--json", "end-time", + "--json", "all-day", + "--json", "location", + "--json", "url", + startDateStr, endDateStr + ] + + eventsProcess.running = true + } + + function getEventsForDate(date) { + let dateKey = Qt.formatDate(date, "yyyy-MM-dd") + return root.eventsByDate[dateKey] || [] + } + + function hasEventsForDate(date) { + let events = getEventsForDate(date) + return events.length > 0 + } + + // Initialize on component completion + Component.onCompleted: { + console.log("CalendarService: Component completed, initializing...") + checkKhalAvailability() + } +} \ No newline at end of file diff --git a/Services/qmldir b/Services/qmldir index 77d237f4..9d84c080 100644 --- a/Services/qmldir +++ b/Services/qmldir @@ -11,4 +11,5 @@ singleton SystemMonitorService 1.0 SystemMonitorService.qml singleton AppSearchService 1.0 AppSearchService.qml singleton PreferencesService 1.0 PreferencesService.qml singleton LauncherService 1.0 LauncherService.qml -singleton NiriWorkspaceService 1.0 NiriWorkspaceService.qml \ No newline at end of file +singleton NiriWorkspaceService 1.0 NiriWorkspaceService.qml +singleton CalendarService 1.0 CalendarService.qml \ No newline at end of file diff --git a/Widgets/CenterCommandCenter/CalendarWidget.qml b/Widgets/CenterCommandCenter/CalendarWidget.qml index 642a8eda..425f18c2 100644 --- a/Widgets/CenterCommandCenter/CalendarWidget.qml +++ b/Widgets/CenterCommandCenter/CalendarWidget.qml @@ -1,6 +1,8 @@ import QtQuick import QtQuick.Controls +import QtQuick.Effects import "../../Common" +import "../../Services" Column { id: calendarWidget @@ -11,6 +13,46 @@ Column { spacing: theme.spacingM + // Load events when display date changes + onDisplayDateChanged: { + loadEventsForMonth() + } + + // Load events when calendar service becomes available + Connections { + target: CalendarService + enabled: CalendarService !== null + function onKhalAvailableChanged() { + if (CalendarService && CalendarService.khalAvailable) { + loadEventsForMonth() + } + } + } + + Component.onCompleted: { + console.log("CalendarWidget: Component completed, CalendarService available:", !!CalendarService) + if (CalendarService) { + console.log("CalendarWidget: khal available:", CalendarService.khalAvailable) + } + loadEventsForMonth() + } + + function loadEventsForMonth() { + if (!CalendarService || !CalendarService.khalAvailable) return + + // Calculate date range with padding + let firstDay = new Date(displayDate.getFullYear(), displayDate.getMonth(), 1) + let dayOfWeek = firstDay.getDay() + let startDate = new Date(firstDay) + startDate.setDate(startDate.getDate() - dayOfWeek - 7) // Extra week padding + + let lastDay = new Date(displayDate.getFullYear(), displayDate.getMonth() + 1, 0) + let endDate = new Date(lastDay) + endDate.setDate(endDate.getDate() + (6 - lastDay.getDay()) + 7) // Extra week padding + + CalendarService.loadEvents(startDate, endDate) + } + // Month navigation header Row { width: parent.width @@ -158,6 +200,64 @@ Column { font.weight: isToday || isSelected ? Font.Medium : Font.Normal } + // Event indicator - full-width elegant bar + Rectangle { + id: eventIndicator + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: 2 + height: 3 + radius: 1.5 + visible: CalendarService && CalendarService.khalAvailable && CalendarService.hasEventsForDate(dayDate) + + // Dynamic color based on state with opacity + color: { + if (isSelected) { + // Use a lighter tint of primary for selected state + return Qt.lighter(theme.primary, 1.3) + } else if (isToday) { + return theme.primary + } else { + return theme.primary + } + } + + opacity: { + if (isSelected) { + return 0.9 + } else if (isToday) { + return 0.8 + } else { + return 0.6 + } + } + + // Subtle animation on hover + scale: dayArea.containsMouse ? 1.05 : 1.0 + + Behavior on scale { + NumberAnimation { + duration: theme.shortDuration + easing.type: theme.standardEasing + } + } + + Behavior on color { + ColorAnimation { + duration: theme.shortDuration + easing.type: theme.standardEasing + } + } + + Behavior on opacity { + NumberAnimation { + duration: theme.shortDuration + easing.type: theme.standardEasing + } + } + } + MouseArea { id: dayArea anchors.fill: parent diff --git a/Widgets/CenterCommandCenter/CenterCommandCenter.qml b/Widgets/CenterCommandCenter/CenterCommandCenter.qml index 49632356..53ede5cf 100644 --- a/Widgets/CenterCommandCenter/CenterCommandCenter.qml +++ b/Widgets/CenterCommandCenter/CenterCommandCenter.qml @@ -69,19 +69,23 @@ PanelWindow { function calculateHeight() { let contentHeight = theme.spacingM * 2 // margins - // Calculate widget heights - media widget is always present + // Main row with widgets and calendar let widgetHeight = 160 // Media widget always present if (weather?.available) { widgetHeight += (weather ? 140 : 80) + theme.spacingM } - - // Calendar height is always 300 let calendarHeight = 300 + let mainRowHeight = Math.max(widgetHeight, calendarHeight) - // Take the max of widgets and calendar - contentHeight += Math.max(widgetHeight, calendarHeight) + contentHeight += mainRowHeight + theme.spacingM // Add spacing between main row and events - return Math.min(contentHeight, parent.height * 0.85) + // Add events widget height - dynamically calculated + if (CalendarService && CalendarService.khalAvailable) { + let eventsHeight = eventsWidget.height || 120 // Use actual widget height or fallback + contentHeight += eventsHeight + } + + return Math.min(contentHeight, parent.height * 0.9) } color: theme.surfaceContainer @@ -123,6 +127,18 @@ PanelWindow { opacity: root.calendarVisible ? 1.0 : 0.0 scale: root.calendarVisible ? 1.0 : 0.92 + // Update height when calendar service events change + Connections { + target: CalendarService + enabled: CalendarService !== null + function onEventsByDateChanged() { + mainContainer.height = mainContainer.calculateHeight() + } + function onKhalAvailableChanged() { + mainContainer.height = mainContainer.calculateHeight() + } + } + Behavior on opacity { NumberAnimation { duration: theme.longDuration @@ -144,44 +160,72 @@ PanelWindow { } } - Row { + Column { anchors.fill: parent anchors.margins: theme.spacingM spacing: theme.spacingM - // Left section for widgets - Column { - id: leftWidgets - width: hasAnyWidgets ? parent.width * 0.45 : 0 - height: childrenRect.height + // Main row with widgets and calendar + Row { + width: parent.width + height: { + let widgetHeight = 160 // Media widget always present + if (weather?.available) { + widgetHeight += (weather ? 140 : 80) + theme.spacingM + } + let calendarHeight = 300 + return Math.max(widgetHeight, calendarHeight) + } spacing: theme.spacingM - visible: hasAnyWidgets - anchors.top: parent.top - property bool hasAnyWidgets: true || weather?.available // Always show media widget - - MediaPlayerWidget { - visible: true // Always visible - shows placeholder when no media - width: parent.width - height: 160 - theme: centerCommandCenter.theme + // Left section for widgets + Column { + id: leftWidgets + width: hasAnyWidgets ? parent.width * 0.45 : 0 + height: childrenRect.height + spacing: theme.spacingM + visible: hasAnyWidgets + anchors.top: parent.top + + property bool hasAnyWidgets: true || weather?.available // Always show media widget + + MediaPlayerWidget { + visible: true // Always visible - shows placeholder when no media + width: parent.width + height: 160 + theme: centerCommandCenter.theme + } + + WeatherWidget { + visible: weather?.available + width: parent.width + height: weather ? 140 : 80 + theme: centerCommandCenter.theme + weather: centerCommandCenter.weather + useFahrenheit: centerCommandCenter.useFahrenheit + } } - WeatherWidget { - visible: weather?.available - width: parent.width - height: weather ? 140 : 80 + // Right section for calendar + CalendarWidget { + id: calendarWidget + width: leftWidgets.hasAnyWidgets ? parent.width * 0.55 - theme.spacingL : parent.width + height: parent.height theme: centerCommandCenter.theme - weather: centerCommandCenter.weather - useFahrenheit: centerCommandCenter.useFahrenheit } } - // Right section for calendar - CalendarWidget { - width: leftWidgets.hasAnyWidgets ? parent.width * 0.55 - theme.spacingL : parent.width - height: parent.height + // Full-width events widget below + EventsWidget { + id: eventsWidget + width: parent.width theme: centerCommandCenter.theme + selectedDate: calendarWidget.selectedDate + + // Update container height when events widget height changes + onHeightChanged: { + mainContainer.height = mainContainer.calculateHeight() + } } } } diff --git a/Widgets/CenterCommandCenter/EventsWidget.qml b/Widgets/CenterCommandCenter/EventsWidget.qml new file mode 100644 index 00000000..17387f5e --- /dev/null +++ b/Widgets/CenterCommandCenter/EventsWidget.qml @@ -0,0 +1,316 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Effects +import "../../Common" +import "../../Services" + +// Events widget for selected date - Material Design 3 style +Rectangle { + id: eventsWidget + + property var theme: Theme + property date selectedDate: new Date() + property var selectedDateEvents: [] + property bool hasEvents: selectedDateEvents && selectedDateEvents.length > 0 + + onSelectedDateEventsChanged: { + console.log("EventsWidget: selectedDateEvents changed, count:", selectedDateEvents.length) + eventsList.model = selectedDateEvents + } + property bool shouldShow: CalendarService && CalendarService.khalAvailable + + width: parent.width + height: shouldShow ? (hasEvents ? Math.min(300, 80 + selectedDateEvents.length * 60) : 120) : 0 + radius: theme.cornerRadiusLarge + color: Qt.rgba(theme.surfaceVariant.r, theme.surfaceVariant.g, theme.surfaceVariant.b, 0.12) + border.color: Qt.rgba(theme.outline.r, theme.outline.g, theme.outline.b, 0.08) + border.width: 1 + visible: shouldShow + + // Material elevation shadow + layer.enabled: true + layer.effect: MultiEffect { + shadowEnabled: true + shadowHorizontalOffset: 0 + shadowVerticalOffset: 2 + shadowBlur: 0.25 + shadowColor: Qt.rgba(0, 0, 0, 0.1) + shadowOpacity: 0.1 + } + + Behavior on height { + NumberAnimation { + duration: theme.mediumDuration + easing.type: theme.emphasizedEasing + } + } + + // Update events when selected date or events change + Connections { + target: CalendarService + enabled: CalendarService !== null + function onEventsByDateChanged() { + updateSelectedDateEvents() + } + function onKhalAvailableChanged() { + updateSelectedDateEvents() + } + } + + Component.onCompleted: { + updateSelectedDateEvents() + } + + onSelectedDateChanged: { + updateSelectedDateEvents() + } + + function updateSelectedDateEvents() { + if (CalendarService && CalendarService.khalAvailable) { + let events = CalendarService.getEventsForDate(selectedDate) + console.log("EventsWidget: Updating events for", Qt.formatDate(selectedDate, "yyyy-MM-dd"), "found", events.length, "events") + selectedDateEvents = events + } else { + selectedDateEvents = [] + } + } + + Item { + anchors.fill: parent + anchors.margins: theme.spacingL + + Column { + anchors.fill: parent + spacing: theme.spacingM + + // Header - always visible when widget is shown + Item { + width: parent.width + height: headerRow.height + + Row { + id: headerRow + width: parent.width + spacing: theme.spacingS + + Text { + text: "event" + font.family: theme.iconFont + font.pixelSize: theme.iconSize - 2 + color: theme.primary + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: hasEvents ? + (Qt.formatDate(selectedDate, "MMM d") + " • " + + (selectedDateEvents.length === 1 ? "1 event" : selectedDateEvents.length + " events")) : + Qt.formatDate(selectedDate, "MMM d") + font.pixelSize: theme.fontSizeMedium + color: theme.surfaceText + font.weight: Font.Medium + anchors.verticalCenter: parent.verticalCenter + } + } + } + + // Content area + Rectangle { + anchors.fill: parent + anchors.margins: theme.spacingL + color: "transparent" + + // No events placeholder - absolutely centered in this gray container + Column { + anchors.centerIn: parent + spacing: theme.spacingXS + visible: !hasEvents + + Text { + text: "event_busy" + font.family: theme.iconFont + font.pixelSize: theme.iconSize + 8 + color: Qt.rgba(theme.surfaceText.r, theme.surfaceText.g, theme.surfaceText.b, 0.3) + anchors.horizontalCenter: parent.horizontalCenter + } + + Text { + text: "No events" + font.pixelSize: theme.fontSizeMedium + color: Qt.rgba(theme.surfaceText.r, theme.surfaceText.g, theme.surfaceText.b, 0.5) + font.weight: Font.Normal + anchors.horizontalCenter: parent.horizontalCenter + } + + } + + // Events list + ListView { + id: eventsList + anchors.fill: parent + anchors.margins: theme.spacingM + visible: hasEvents + clip: true + spacing: theme.spacingS + boundsMovement: Flickable.StopAtBounds + boundsBehavior: Flickable.StopAtBounds + + ScrollBar.vertical: ScrollBar { + policy: eventsList.contentHeight > eventsList.height ? ScrollBar.AsNeeded : ScrollBar.AlwaysOff + } + + delegate: Rectangle { + width: eventsList.width + height: eventContent.implicitHeight + theme.spacingM + radius: theme.cornerRadius + color: { + if (modelData.url && eventMouseArea.containsMouse) { + return Qt.rgba(theme.primary.r, theme.primary.g, theme.primary.b, 0.12) + } else if (eventMouseArea.containsMouse) { + return Qt.rgba(theme.primary.r, theme.primary.g, theme.primary.b, 0.06) + } + return Qt.rgba(theme.surfaceVariant.r, theme.surfaceVariant.g, theme.surfaceVariant.b, 0.06) + } + border.color: { + if (modelData.url && eventMouseArea.containsMouse) { + return Qt.rgba(theme.primary.r, theme.primary.g, theme.primary.b, 0.3) + } else if (eventMouseArea.containsMouse) { + return Qt.rgba(theme.primary.r, theme.primary.g, theme.primary.b, 0.15) + } + return "transparent" + } + border.width: 1 + + // Event indicator strip + Rectangle { + width: 4 + height: parent.height - 8 + anchors.left: parent.left + anchors.leftMargin: 4 + anchors.verticalCenter: parent.verticalCenter + radius: 2 + color: theme.primary + opacity: 0.8 + } + + Column { + id: eventContent + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: theme.spacingL + 4 + anchors.rightMargin: theme.spacingM + spacing: 6 + + Text { + width: parent.width + text: modelData.title + font.pixelSize: theme.fontSizeMedium + color: theme.surfaceText + font.weight: Font.Medium + elide: Text.ElideRight + wrapMode: Text.Wrap + maximumLineCount: 2 + } + + Item { + width: parent.width + height: Math.max(timeRow.height, locationRow.height) + + Row { + id: timeRow + spacing: 4 + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + + Text { + text: "schedule" + font.family: theme.iconFont + font.pixelSize: theme.fontSizeSmall + color: Qt.rgba(theme.surfaceText.r, theme.surfaceText.g, theme.surfaceText.b, 0.7) + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: { + if (modelData.allDay) { + return "All day" + } else { + let startTime = Qt.formatTime(modelData.start, "h:mm AP") + if (modelData.start.toDateString() !== modelData.end.toDateString() || + modelData.start.getTime() !== modelData.end.getTime()) { + return startTime + " – " + Qt.formatTime(modelData.end, "h:mm AP") + } + return startTime + } + } + font.pixelSize: theme.fontSizeSmall + color: Qt.rgba(theme.surfaceText.r, theme.surfaceText.g, theme.surfaceText.b, 0.7) + font.weight: Font.Normal + anchors.verticalCenter: parent.verticalCenter + } + } + + Row { + id: locationRow + spacing: 4 + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + visible: modelData.location !== "" + + Text { + text: "location_on" + font.family: theme.iconFont + font.pixelSize: theme.fontSizeSmall + color: Qt.rgba(theme.surfaceText.r, theme.surfaceText.g, theme.surfaceText.b, 0.7) + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: modelData.location + font.pixelSize: theme.fontSizeSmall + color: Qt.rgba(theme.surfaceText.r, theme.surfaceText.g, theme.surfaceText.b, 0.7) + elide: Text.ElideRight + anchors.verticalCenter: parent.verticalCenter + maximumLineCount: 1 + width: Math.min(implicitWidth, 200) + } + } + } + } + + MouseArea { + id: eventMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: modelData.url ? Qt.PointingHandCursor : Qt.ArrowCursor + enabled: modelData.url !== "" + + onClicked: { + if (modelData.url && modelData.url !== "") { + if (Qt.openUrlExternally(modelData.url) === false) { + console.warn("Couldn't open", modelData.url) + } + } + } + } + + Behavior on color { + ColorAnimation { + duration: theme.shortDuration + easing.type: theme.standardEasing + } + } + + Behavior on border.color { + ColorAnimation { + duration: theme.shortDuration + easing.type: theme.standardEasing + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/Widgets/CenterCommandCenter/qmldir b/Widgets/CenterCommandCenter/qmldir index 3b33776f..7ab10f39 100644 --- a/Widgets/CenterCommandCenter/qmldir +++ b/Widgets/CenterCommandCenter/qmldir @@ -1,4 +1,5 @@ CenterCommandCenter 1.0 CenterCommandCenter.qml MediaPlayerWidget 1.0 MediaPlayerWidget.qml WeatherWidget 1.0 WeatherWidget.qml -CalendarWidget 1.0 CalendarWidget.qml \ No newline at end of file +CalendarWidget 1.0 CalendarWidget.qml +EventsWidget 1.0 EventsWidget.qml \ No newline at end of file diff --git a/Widgets/ControlCenter/ControlCenterPopup.qml b/Widgets/ControlCenter/ControlCenterPopup.qml index f251dab0..15fe8346 100644 --- a/Widgets/ControlCenter/ControlCenterPopup.qml +++ b/Widgets/ControlCenter/ControlCenterPopup.qml @@ -80,7 +80,69 @@ PanelWindow { anchors.verticalCenter: parent.verticalCenter } - Item { width: parent.width - 200; height: 1 } + Item { width: parent.width - 300; height: 1 } + + // Calendar status indicator + Rectangle { + width: 100 + height: 24 + radius: Theme.cornerRadiusSmall + color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.16) + anchors.verticalCenter: parent.verticalCenter + visible: CalendarService && CalendarService.khalAvailable + + Row { + anchors.centerIn: parent + spacing: Theme.spacingXS + + Text { + text: "event" + font.family: Theme.iconFont + font.pixelSize: Theme.iconSize - 6 + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + } + + Text { + id: todayEventsText + property var todayEvents: [] + text: todayEvents.length === 0 ? "No events today" : + todayEvents.length === 1 ? "1 event today" : + todayEvents.length + " events today" + + function updateTodayEvents() { + if (CalendarService && CalendarService.khalAvailable) { + todayEvents = CalendarService.getEventsForDate(new Date()) + } else { + todayEvents = [] + } + } + + Component.onCompleted: { + console.log("ControlCenter: Calendar status text initialized, CalendarService available:", !!CalendarService) + if (CalendarService) { + console.log("ControlCenter: khal available:", CalendarService.khalAvailable) + } + updateTodayEvents() + } + font.pixelSize: Theme.fontSizeXS + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + + // Update when events change or khal becomes available + Connections { + target: CalendarService + enabled: CalendarService !== null + function onEventsByDateChanged() { + todayEventsText.updateTodayEvents() + } + function onKhalAvailableChanged() { + todayEventsText.updateTodayEvents() + } + } + } + } + } } // Tab buttons diff --git a/shell.qml b/shell.qml index 4a783268..2a1583be 100644 --- a/shell.qml +++ b/shell.qml @@ -78,6 +78,10 @@ ShellRoot { // Brightness properties from BrightnessService property int brightnessLevel: BrightnessService.brightnessLevel + // Calendar properties from CalendarService + property bool calendarAvailable: CalendarService.khalAvailable + property var calendarEvents: CalendarService.eventsByDate + // WiFi password dialog property bool wifiPasswordDialogVisible: false property string wifiPasswordSSID: ""