Files
noctalia-shell/Modules/Panels/Calendar/CalendarPanel.qml
Ly-sec 309648d6d6 Calendar: add timer
LocationTab: rework calendar settings
SoundService: add simple service to play & loop sounds
2025-11-26 19:18:30 +01:00

637 lines
24 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Wayland
import "."
import qs.Commons
import qs.Modules.MainScreen
import qs.Modules.Panels.ControlCenter.Cards
import qs.Services.Location
import qs.Services.System
import qs.Services.UI
import qs.Widgets
SmartPanel {
id: root
readonly property var now: Time.now
// Calculate width based on settings
preferredWidth: Math.round((Settings.data.location.showWeekNumberInCalendar ? 460 : 440) * Style.uiScaleRatio)
// Use a reasonable fixed height that accommodates most layouts
preferredHeight: Math.round(700 * Style.uiScaleRatio)
// Helper function to calculate ISO week number
function getISOWeekNumber(date) {
const target = new Date(date.valueOf());
const dayNr = (date.getDay() + 6) % 7;
target.setDate(target.getDate() - dayNr + 3);
const firstThursday = new Date(target.getFullYear(), 0, 4);
const diff = target - firstThursday;
const oneWeek = 1000 * 60 * 60 * 24 * 7;
const weekNumber = 1 + Math.round(diff / oneWeek);
return weekNumber;
}
// Helper function to check if an event is all-day
function isAllDayEvent(event) {
const duration = event.end - event.start;
const startDate = new Date(event.start * 1000);
const isAtMidnight = startDate.getHours() === 0 && startDate.getMinutes() === 0;
return duration === 86400 && isAtMidnight;
}
// Shared calendar state (month/year) accessible by all components
property int calendarMonth: now.getMonth()
property int calendarYear: now.getFullYear()
panelContent: Item {
anchors.fill: parent
// Dynamic height based on actual content height
property real contentPreferredHeight: content.implicitHeight + Style.marginL * 2
ColumnLayout {
id: content
x: Style.marginL
y: Style.marginL
width: parent.width - (Style.marginL * 2)
spacing: Style.marginL
readonly property int firstDayOfWeek: Settings.data.location.firstDayOfWeek === -1 ? I18n.locale.firstDayOfWeek : Settings.data.location.firstDayOfWeek
property bool isCurrentMonth: true
readonly property bool weatherReady: Settings.data.location.weatherEnabled && (LocationService.data.weather !== null)
function checkIsCurrentMonth() {
return (now.getMonth() === root.calendarMonth) && (now.getFullYear() === root.calendarYear);
}
Component.onCompleted: {
isCurrentMonth = checkIsCurrentMonth();
}
Connections {
target: Time
function onNowChanged() {
content.isCurrentMonth = content.checkIsCurrentMonth();
}
}
Connections {
target: I18n
function onLanguageChanged() {
// Force update by toggling month
root.calendarMonth = root.calendarMonth;
}
}
// All calendar items (Banner, Calendar, Timer, Weather, etc.)
Repeater {
model: Settings.data.calendar.cards
Loader {
active: modelData.enabled && (modelData.id !== "weather-card" || Settings.data.location.weatherEnabled)
visible: active
Layout.fillWidth: true
Layout.topMargin: 0
Layout.bottomMargin: 0
sourceComponent: {
switch (modelData.id) {
case "banner-card":
return bannerCard;
case "calendar-card":
return calendarCard;
case "timer-card":
return timerCard;
case "weather-card":
return weatherCard;
default:
return null;
}
}
}
}
}
Component {
id: bannerCard
Rectangle {
id: banner
Layout.fillWidth: true
Layout.minimumHeight: (60 * Style.uiScaleRatio) + (Style.marginM * 2)
Layout.preferredHeight: (60 * Style.uiScaleRatio) + (Style.marginM * 2)
implicitHeight: (60 * Style.uiScaleRatio) + (Style.marginM * 2)
radius: Style.radiusL
color: Color.mPrimary
// Access parent properties
readonly property var now: root.now
readonly property bool isCurrentMonth: content.isCurrentMonth
readonly property bool weatherReady: content.weatherReady
ColumnLayout {
id: capsuleColumn
anchors.top: parent.top
anchors.left: parent.left
anchors.bottom: parent.bottom
anchors.topMargin: Style.marginM
anchors.bottomMargin: Style.marginM
anchors.rightMargin: clockLoader.width + (Style.marginXL * 2)
anchors.leftMargin: Style.marginXL
spacing: 0
// Combined layout for date, month year, location and time-zone
RowLayout {
Layout.fillWidth: true
height: 60 * Style.uiScaleRatio
clip: true
spacing: Style.marginS
// Today day number
NText {
opacity: banner.isCurrentMonth ? 1.0 : 0.0
Layout.preferredWidth: banner.isCurrentMonth ? implicitWidth : 0
elide: Text.ElideNone
clip: true
Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft
text: banner.now.getDate()
pointSize: Style.fontSizeXXXL * 1.5
font.weight: Style.fontWeightBold
color: Color.mOnPrimary
Behavior on opacity {
NumberAnimation {
duration: Style.animationFast
}
}
Behavior on Layout.preferredWidth {
NumberAnimation {
duration: Style.animationFast
easing.type: Easing.InOutQuad
}
}
}
// Month, year, location
ColumnLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft
Layout.bottomMargin: Style.marginXXS
Layout.topMargin: -Style.marginXXS
spacing: -Style.marginXS
RowLayout {
spacing: Style.marginS
NText {
text: I18n.locale.monthName(root.calendarMonth, Locale.LongFormat).toUpperCase()
pointSize: Style.fontSizeXL * 1.1
font.weight: Style.fontWeightBold
color: Color.mOnPrimary
Layout.alignment: Qt.AlignBaseline
elide: Text.ElideRight
}
NText {
text: `${root.calendarYear}`
pointSize: Style.fontSizeM
font.weight: Style.fontWeightBold
color: Qt.alpha(Color.mOnPrimary, 0.7)
Layout.alignment: Qt.AlignBaseline
}
}
RowLayout {
spacing: 0
NText {
text: {
if (!Settings.data.location.weatherEnabled)
return "";
if (!banner.weatherReady)
return I18n.tr("calendar.weather.loading");
const chunks = Settings.data.location.name.split(",");
return chunks[0];
}
pointSize: Style.fontSizeM
font.weight: Style.fontWeightMedium
color: Color.mOnPrimary
Layout.maximumWidth: 150
elide: Text.ElideRight
}
NText {
text: banner.weatherReady ? ` (${LocationService.data.weather.timezone_abbreviation})` : ""
pointSize: Style.fontSizeXS
font.weight: Style.fontWeightMedium
color: Qt.alpha(Color.mOnPrimary, 0.7)
}
}
}
// Spacer
Item {
Layout.fillWidth: true
}
}
}
// Analog clock
NClock {
id: clockLoader
anchors.right: parent.right
anchors.rightMargin: Style.marginXL
anchors.verticalCenter: parent.verticalCenter
clockStyle: Settings.data.location.analogClockInCalendar ? "analog" : "digital"
progressColor: Color.mOnPrimary
Layout.alignment: Qt.AlignVCenter
now: parent.now
}
}
}
Component {
id: calendarCard
NBox {
Layout.fillWidth: true
implicitHeight: calendarContent.implicitHeight + Style.marginM * 2
ColumnLayout {
id: calendarContent
anchors.fill: parent
anchors.margins: Style.marginM
spacing: Style.marginS
// Navigation row
RowLayout {
Layout.fillWidth: true
spacing: Style.marginS
NDivider {
Layout.fillWidth: true
}
NIconButton {
icon: "chevron-left"
onClicked: {
let newDate = new Date(root.calendarYear, root.calendarMonth - 1, 1);
root.calendarYear = newDate.getFullYear();
root.calendarMonth = newDate.getMonth();
content.isCurrentMonth = content.checkIsCurrentMonth();
const now = new Date();
const monthStart = new Date(root.calendarYear, root.calendarMonth, 1);
const monthEnd = new Date(root.calendarYear, root.calendarMonth + 1, 0);
const daysBehind = Math.max(0, Math.ceil((now - monthStart) / (24 * 60 * 60 * 1000)));
const daysAhead = Math.max(0, Math.ceil((monthEnd - now) / (24 * 60 * 60 * 1000)));
CalendarService.loadEvents(daysAhead + 30, daysBehind + 30);
}
}
NIconButton {
icon: "calendar"
onClicked: {
root.calendarMonth = now.getMonth();
root.calendarYear = now.getFullYear();
content.isCurrentMonth = true;
CalendarService.loadEvents();
}
}
NIconButton {
icon: "chevron-right"
onClicked: {
let newDate = new Date(root.calendarYear, root.calendarMonth + 1, 1);
root.calendarYear = newDate.getFullYear();
root.calendarMonth = newDate.getMonth();
content.isCurrentMonth = content.checkIsCurrentMonth();
const now = new Date();
const monthStart = new Date(root.calendarYear, root.calendarMonth, 1);
const monthEnd = new Date(root.calendarYear, root.calendarMonth + 1, 0);
const daysBehind = Math.max(0, Math.ceil((now - monthStart) / (24 * 60 * 60 * 1000)));
const daysAhead = Math.max(0, Math.ceil((monthEnd - now) / (24 * 60 * 60 * 1000)));
CalendarService.loadEvents(daysAhead + 30, daysBehind + 30);
}
}
}
// Day names header
RowLayout {
Layout.fillWidth: true
spacing: 0
Item {
visible: Settings.data.location.showWeekNumberInCalendar
Layout.preferredWidth: visible ? Style.baseWidgetSize * 0.7 : 0
}
GridLayout {
Layout.fillWidth: true
columns: 7
rows: 1
columnSpacing: 0
rowSpacing: 0
Repeater {
model: 7
Item {
Layout.fillWidth: true
Layout.preferredHeight: Style.fontSizeS * 2
NText {
anchors.centerIn: parent
text: {
let dayIndex = (content.firstDayOfWeek + index) % 7;
const dayName = I18n.locale.dayName(dayIndex, Locale.ShortFormat);
return dayName.substring(0, 2).toUpperCase();
}
color: Color.mPrimary
pointSize: Style.fontSizeS
font.weight: Style.fontWeightBold
horizontalAlignment: Text.AlignHCenter
}
}
}
}
}
// Calendar grid with week numbers
RowLayout {
Layout.fillWidth: true
spacing: 0
// Helper functions
function hasEventsOnDate(year, month, day) {
if (!CalendarService.available || CalendarService.events.length === 0)
return false;
const targetDate = new Date(year, month, day);
const targetStart = new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate()).getTime() / 1000;
const targetEnd = targetStart + 86400;
return CalendarService.events.some(event => {
return (event.start >= targetStart && event.start < targetEnd) || (event.end > targetStart && event.end <= targetEnd) || (event.start < targetStart && event.end > targetEnd);
});
}
function getEventsForDate(year, month, day) {
if (!CalendarService.available || CalendarService.events.length === 0)
return [];
const targetDate = new Date(year, month, day);
const targetStart = Math.floor(new Date(targetDate.getFullYear(), targetDate.getMonth(), targetDate.getDate()).getTime() / 1000);
const targetEnd = targetStart + 86400;
return CalendarService.events.filter(event => {
return (event.start >= targetStart && event.start < targetEnd) || (event.end > targetStart && event.end <= targetEnd) || (event.start < targetStart && event.end > targetEnd);
});
}
function isMultiDayEvent(event) {
if (root.isAllDayEvent(event)) {
return false;
}
const startDate = new Date(event.start * 1000);
const endDate = new Date(event.end * 1000);
const startDateOnly = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
const endDateOnly = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate());
return startDateOnly.getTime() !== endDateOnly.getTime();
}
function getEventColor(event, isToday) {
if (isMultiDayEvent(event)) {
return isToday ? Color.mOnSecondary : Color.mTertiary;
} else if (root.isAllDayEvent(event)) {
return isToday ? Color.mOnSecondary : Color.mSecondary;
} else {
return isToday ? Color.mOnSecondary : Color.mPrimary;
}
}
// Week numbers column
ColumnLayout {
visible: Settings.data.location.showWeekNumberInCalendar
Layout.preferredWidth: visible ? Style.baseWidgetSize * 0.7 : 0
Layout.alignment: Qt.AlignTop
spacing: Style.marginXXS
property var weekNumbers: {
if (!grid.daysModel || grid.daysModel.length === 0)
return [];
const weeks = [];
const numWeeks = Math.ceil(grid.daysModel.length / 7);
for (var i = 0; i < numWeeks; i++) {
const dayIndex = i * 7;
if (dayIndex < grid.daysModel.length) {
const weekDay = grid.daysModel[dayIndex];
const date = new Date(weekDay.year, weekDay.month, weekDay.day);
let thursday = new Date(date);
if (content.firstDayOfWeek === 0) {
thursday.setDate(date.getDate() + 4);
} else if (content.firstDayOfWeek === 1) {
thursday.setDate(date.getDate() + 3);
} else {
let daysToThursday = (4 - content.firstDayOfWeek + 7) % 7;
thursday.setDate(date.getDate() + daysToThursday);
}
weeks.push(root.getISOWeekNumber(thursday));
}
}
return weeks;
}
Repeater {
model: parent.weekNumbers
Item {
Layout.preferredWidth: Style.baseWidgetSize * 0.7
Layout.preferredHeight: Style.baseWidgetSize * 0.9
NText {
anchors.centerIn: parent
color: Qt.alpha(Color.mPrimary, 0.7)
pointSize: Style.fontSizeXXS
font.weight: Style.fontWeightMedium
text: modelData
}
}
}
}
// Calendar grid
GridLayout {
id: grid
Layout.fillWidth: true
columns: 7
columnSpacing: Style.marginXXS
rowSpacing: Style.marginXXS
property int month: root.calendarMonth
property int year: root.calendarYear
property var daysModel: {
const firstOfMonth = new Date(year, month, 1);
const lastOfMonth = new Date(year, month + 1, 0);
const daysInMonth = lastOfMonth.getDate();
const firstDayOfWeek = content.firstDayOfWeek;
const firstOfMonthDayOfWeek = firstOfMonth.getDay();
let daysBefore = (firstOfMonthDayOfWeek - firstDayOfWeek + 7) % 7;
const lastOfMonthDayOfWeek = lastOfMonth.getDay();
const daysAfter = (firstDayOfWeek - lastOfMonthDayOfWeek - 1 + 7) % 7;
const days = [];
const today = new Date();
// Previous month days
const prevMonth = new Date(year, month, 0);
const prevMonthDays = prevMonth.getDate();
for (var i = daysBefore - 1; i >= 0; i--) {
const day = prevMonthDays - i;
days.push({
"day": day,
"month": month - 1,
"year": month === 0 ? year - 1 : year,
"today": false,
"currentMonth": false
});
}
// Current month days
for (var day = 1; day <= daysInMonth; day++) {
const date = new Date(year, month, day);
const isToday = date.getFullYear() === today.getFullYear() && date.getMonth() === today.getMonth() && date.getDate() === today.getDate();
days.push({
"day": day,
"month": month,
"year": year,
"today": isToday,
"currentMonth": true
});
}
// Next month days
for (var i = 1; i <= daysAfter; i++) {
days.push({
"day": i,
"month": month + 1,
"year": month === 11 ? year + 1 : year,
"today": false,
"currentMonth": false
});
}
return days;
}
Repeater {
model: grid.daysModel
Item {
Layout.fillWidth: true
Layout.preferredHeight: Style.baseWidgetSize * 0.9
Rectangle {
width: Style.baseWidgetSize * 0.9
height: Style.baseWidgetSize * 0.9
anchors.centerIn: parent
radius: Style.radiusM
color: modelData.today ? Color.mSecondary : Color.transparent
NText {
anchors.centerIn: parent
text: modelData.day
color: {
if (modelData.today)
return Color.mOnSecondary;
if (modelData.currentMonth)
return Color.mOnSurface;
return Color.mOnSurfaceVariant;
}
opacity: modelData.currentMonth ? 1.0 : 0.4
pointSize: Style.fontSizeM
font.weight: modelData.today ? Style.fontWeightBold : Style.fontWeightMedium
}
// Event indicator dots
Row {
visible: Settings.data.location.showCalendarEvents && parent.parent.parent.parent.hasEventsOnDate(modelData.year, modelData.month, modelData.day)
spacing: 2
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: Style.marginXS
Repeater {
model: parent.parent.parent.parent.parent.getEventsForDate(modelData.year, modelData.month, modelData.day)
Rectangle {
width: 4
height: width
radius: width / 2
color: parent.parent.parent.parent.parent.getEventColor(modelData, modelData.today)
}
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
enabled: Settings.data.location.showCalendarEvents
onEntered: {
const events = parent.parent.parent.parent.getEventsForDate(modelData.year, modelData.month, modelData.day);
if (events.length > 0) {
const summaries = events.map(event => {
if (root.isAllDayEvent(event)) {
return event.summary;
} else {
const timeFormat = Settings.data.location.use12hourFormat ? "hh:mm AP" : "HH:mm";
const start = new Date(event.start * 1000);
const startFormatted = I18n.locale.toString(start, timeFormat);
const end = new Date(event.end * 1000);
const endFormatted = I18n.locale.toString(end, timeFormat);
return `${startFormatted}-${endFormatted} ${event.summary}`;
}
}).join('\n');
TooltipService.show(parent, summaries, "auto", Style.tooltipDelay, Settings.data.ui.fontFixed);
}
}
onClicked: {
const dateWithSlashes = `${(modelData.month + 1).toString().padStart(2, '0')}/${modelData.day.toString().padStart(2, '0')}/${modelData.year.toString().substring(2)}`;
if (ProgramCheckerService.gnomeCalendarAvailable) {
Quickshell.execDetached(["gnome-calendar", "--date", dateWithSlashes]);
root.close();
}
}
onExited: {
TooltipService.hide();
}
}
Behavior on color {
ColorAnimation {
duration: Style.animationFast
}
}
}
}
}
}
}
}
}
}
Component {
id: timerCard
TimerCard {
Layout.fillWidth: true
}
}
Component {
id: weatherCard
WeatherCard {
Layout.fillWidth: true
forecastDays: 5
showLocation: false
}
}
}
}