mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2025-12-07 14:05:38 -05:00
Implement calendar events with khal
This commit is contained in:
314
Services/CalendarService.qml
Normal file
314
Services/CalendarService.qml
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,4 +11,5 @@ singleton SystemMonitorService 1.0 SystemMonitorService.qml
|
|||||||
singleton AppSearchService 1.0 AppSearchService.qml
|
singleton AppSearchService 1.0 AppSearchService.qml
|
||||||
singleton PreferencesService 1.0 PreferencesService.qml
|
singleton PreferencesService 1.0 PreferencesService.qml
|
||||||
singleton LauncherService 1.0 LauncherService.qml
|
singleton LauncherService 1.0 LauncherService.qml
|
||||||
singleton NiriWorkspaceService 1.0 NiriWorkspaceService.qml
|
singleton NiriWorkspaceService 1.0 NiriWorkspaceService.qml
|
||||||
|
singleton CalendarService 1.0 CalendarService.qml
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Effects
|
||||||
import "../../Common"
|
import "../../Common"
|
||||||
|
import "../../Services"
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
id: calendarWidget
|
id: calendarWidget
|
||||||
@@ -11,6 +13,46 @@ Column {
|
|||||||
|
|
||||||
spacing: theme.spacingM
|
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
|
// Month navigation header
|
||||||
Row {
|
Row {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
@@ -158,6 +200,64 @@ Column {
|
|||||||
font.weight: isToday || isSelected ? Font.Medium : Font.Normal
|
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 {
|
MouseArea {
|
||||||
id: dayArea
|
id: dayArea
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
|||||||
@@ -69,19 +69,23 @@ PanelWindow {
|
|||||||
function calculateHeight() {
|
function calculateHeight() {
|
||||||
let contentHeight = theme.spacingM * 2 // margins
|
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
|
let widgetHeight = 160 // Media widget always present
|
||||||
if (weather?.available) {
|
if (weather?.available) {
|
||||||
widgetHeight += (weather ? 140 : 80) + theme.spacingM
|
widgetHeight += (weather ? 140 : 80) + theme.spacingM
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calendar height is always 300
|
|
||||||
let calendarHeight = 300
|
let calendarHeight = 300
|
||||||
|
let mainRowHeight = Math.max(widgetHeight, calendarHeight)
|
||||||
|
|
||||||
// Take the max of widgets and calendar
|
contentHeight += mainRowHeight + theme.spacingM // Add spacing between main row and events
|
||||||
contentHeight += Math.max(widgetHeight, calendarHeight)
|
|
||||||
|
|
||||||
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
|
color: theme.surfaceContainer
|
||||||
@@ -123,6 +127,18 @@ PanelWindow {
|
|||||||
opacity: root.calendarVisible ? 1.0 : 0.0
|
opacity: root.calendarVisible ? 1.0 : 0.0
|
||||||
scale: root.calendarVisible ? 1.0 : 0.92
|
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 {
|
Behavior on opacity {
|
||||||
NumberAnimation {
|
NumberAnimation {
|
||||||
duration: theme.longDuration
|
duration: theme.longDuration
|
||||||
@@ -144,44 +160,72 @@ PanelWindow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Row {
|
Column {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: theme.spacingM
|
anchors.margins: theme.spacingM
|
||||||
spacing: theme.spacingM
|
spacing: theme.spacingM
|
||||||
|
|
||||||
// Left section for widgets
|
// Main row with widgets and calendar
|
||||||
Column {
|
Row {
|
||||||
id: leftWidgets
|
width: parent.width
|
||||||
width: hasAnyWidgets ? parent.width * 0.45 : 0
|
height: {
|
||||||
height: childrenRect.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
|
spacing: theme.spacingM
|
||||||
visible: hasAnyWidgets
|
|
||||||
anchors.top: parent.top
|
|
||||||
|
|
||||||
property bool hasAnyWidgets: true || weather?.available // Always show media widget
|
// Left section for widgets
|
||||||
|
Column {
|
||||||
MediaPlayerWidget {
|
id: leftWidgets
|
||||||
visible: true // Always visible - shows placeholder when no media
|
width: hasAnyWidgets ? parent.width * 0.45 : 0
|
||||||
width: parent.width
|
height: childrenRect.height
|
||||||
height: 160
|
spacing: theme.spacingM
|
||||||
theme: centerCommandCenter.theme
|
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 {
|
// Right section for calendar
|
||||||
visible: weather?.available
|
CalendarWidget {
|
||||||
width: parent.width
|
id: calendarWidget
|
||||||
height: weather ? 140 : 80
|
width: leftWidgets.hasAnyWidgets ? parent.width * 0.55 - theme.spacingL : parent.width
|
||||||
|
height: parent.height
|
||||||
theme: centerCommandCenter.theme
|
theme: centerCommandCenter.theme
|
||||||
weather: centerCommandCenter.weather
|
|
||||||
useFahrenheit: centerCommandCenter.useFahrenheit
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Right section for calendar
|
// Full-width events widget below
|
||||||
CalendarWidget {
|
EventsWidget {
|
||||||
width: leftWidgets.hasAnyWidgets ? parent.width * 0.55 - theme.spacingL : parent.width
|
id: eventsWidget
|
||||||
height: parent.height
|
width: parent.width
|
||||||
theme: centerCommandCenter.theme
|
theme: centerCommandCenter.theme
|
||||||
|
selectedDate: calendarWidget.selectedDate
|
||||||
|
|
||||||
|
// Update container height when events widget height changes
|
||||||
|
onHeightChanged: {
|
||||||
|
mainContainer.height = mainContainer.calculateHeight()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
316
Widgets/CenterCommandCenter/EventsWidget.qml
Normal file
316
Widgets/CenterCommandCenter/EventsWidget.qml
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
CenterCommandCenter 1.0 CenterCommandCenter.qml
|
CenterCommandCenter 1.0 CenterCommandCenter.qml
|
||||||
MediaPlayerWidget 1.0 MediaPlayerWidget.qml
|
MediaPlayerWidget 1.0 MediaPlayerWidget.qml
|
||||||
WeatherWidget 1.0 WeatherWidget.qml
|
WeatherWidget 1.0 WeatherWidget.qml
|
||||||
CalendarWidget 1.0 CalendarWidget.qml
|
CalendarWidget 1.0 CalendarWidget.qml
|
||||||
|
EventsWidget 1.0 EventsWidget.qml
|
||||||
@@ -80,7 +80,69 @@ PanelWindow {
|
|||||||
anchors.verticalCenter: parent.verticalCenter
|
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
|
// Tab buttons
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ ShellRoot {
|
|||||||
// Brightness properties from BrightnessService
|
// Brightness properties from BrightnessService
|
||||||
property int brightnessLevel: BrightnessService.brightnessLevel
|
property int brightnessLevel: BrightnessService.brightnessLevel
|
||||||
|
|
||||||
|
// Calendar properties from CalendarService
|
||||||
|
property bool calendarAvailable: CalendarService.khalAvailable
|
||||||
|
property var calendarEvents: CalendarService.eventsByDate
|
||||||
|
|
||||||
// WiFi password dialog
|
// WiFi password dialog
|
||||||
property bool wifiPasswordDialogVisible: false
|
property bool wifiPasswordDialogVisible: false
|
||||||
property string wifiPasswordSSID: ""
|
property string wifiPasswordSSID: ""
|
||||||
|
|||||||
Reference in New Issue
Block a user