From 3859e6456ed9b92da2d84f5ac4df5e8dc33e759d Mon Sep 17 00:00:00 2001 From: flathead Date: Wed, 1 Apr 2026 22:02:34 +0300 Subject: [PATCH 1/3] feat: add calendar integration (Google Calendar + CalDAV) Full calendar integration for the Ambxst shell, including: Backend (scripts/calendar_service.py): - Python service communicating with QML via stdin/stdout JSON protocol - Google Calendar via OAuth2 (google-api-python-client) - CalDAV support (caldav library) - Import of existing gcalcli tokens - Background sync thread with configurable interval - Notification system: reminders and arrival alerts via notify-send - Sound on arrival via canberra-gtk-play / paplay / pw-play / aplay fallback chain - Persisted notification deduplication across restarts (~/.cache/ambxst/calendar_notified.json) - CRUD operations (create, update, delete) with cmd_start/cmd_result protocol - Atomic token writes with 0o600 permissions QML service (modules/services/CalendarService.qml): - Singleton wrapping the Python process - Accounts, calendars, events state - Operation feedback: operationPending, operationResult signal - Arrival state with iconBlinking and 30s auto-dismiss timer - All audio handled by Python; QML does not play sounds Bar widget (modules/bar/BarCalendarIndicator.qml): - Shows upcoming event count or next event title + time - barAlwaysShow mode: splits popup into Upcoming / Past sections - Blinking icon animation on event arrival - barShowNextEvent mode: shows event title and countdown Dashboard panels: - CalendarPanel.qml: accounts management, Google OAuth, CalDAV auth, gcalcli import, per-calendar enable/disable, all settings - EventDetailPanel.qml: full create/edit form with time spinners, calendar selector, reminder, description, location, Google Meet toggle, operation feedback (saving state, timeout, error display), back = cancel edit, close button always exits - EventItem.qml / EventPopup.qml: event list item and quick popup Config: - config/defaults/calendar.js: all defaults - Config.qml: JsonAdapter with full property set including barAlwaysShow i18n: - All user-facing strings in calendar files wrapped with _t(key, fallback) - Helper is safe without the I18n singleton (try/catch fallback) - Translation keys added to feature/i18n branch (en/ru/es) --- config/Config.qml | 62 +- config/defaults/calendar.js | 18 + modules/bar/BarCalendarIndicator.qml | 402 +++++ modules/bar/BarContent.qml | 27 + modules/services/CalendarService.qml | 323 ++++ modules/theme/Icons.qml | 2 + .../dashboard/controls/CalendarPanel.qml | 1279 +++++++++++++++ .../dashboard/controls/SettingsIndex.qml | 8 +- .../dashboard/controls/SettingsTab.qml | 14 +- .../widgets/dashboard/widgets/WidgetsTab.qml | 174 +- .../dashboard/widgets/calendar/Calendar.qml | 104 +- .../widgets/calendar/CalendarDayButton.qml | 102 ++ .../widgets/calendar/EventDetailPanel.qml | 1071 ++++++++++++ .../dashboard/widgets/calendar/EventItem.qml | 144 ++ .../dashboard/widgets/calendar/EventPopup.qml | 731 +++++++++ scripts/calendar_service.py | 1438 +++++++++++++++++ 16 files changed, 5887 insertions(+), 12 deletions(-) create mode 100644 config/defaults/calendar.js create mode 100644 modules/bar/BarCalendarIndicator.qml create mode 100644 modules/services/CalendarService.qml create mode 100644 modules/widgets/dashboard/controls/CalendarPanel.qml create mode 100644 modules/widgets/dashboard/widgets/calendar/EventDetailPanel.qml create mode 100644 modules/widgets/dashboard/widgets/calendar/EventItem.qml create mode 100644 modules/widgets/dashboard/widgets/calendar/EventPopup.qml create mode 100755 scripts/calendar_service.py diff --git a/config/Config.qml b/config/Config.qml index 76bab09b..03da8abf 100644 --- a/config/Config.qml +++ b/config/Config.qml @@ -21,6 +21,7 @@ import "defaults/prefix.js" as PrefixDefaults import "defaults/system.js" as SystemDefaults import "defaults/dock.js" as DockDefaults import "defaults/ai.js" as AiDefaults +import "defaults/calendar.js" as CalendarDefaults import "ConfigValidator.js" as ConfigValidator Singleton { @@ -55,9 +56,10 @@ Singleton { property bool systemReady: false property bool dockReady: false property bool aiReady: false + property bool calendarReady: false property bool keybindsInitialLoadComplete: false - property bool initialLoadComplete: themeReady && barReady && workspacesReady && overviewReady && notchReady && compositorReady && performanceReady && weatherReady && desktopReady && lockscreenReady && prefixReady && systemReady && dockReady && aiReady + property bool initialLoadComplete: themeReady && barReady && workspacesReady && overviewReady && notchReady && compositorReady && performanceReady && weatherReady && desktopReady && lockscreenReady && prefixReady && systemReady && dockReady && aiReady && calendarReady // Compatibility aliases property alias loader: themeLoader @@ -1184,6 +1186,58 @@ Singleton { } } + // ============================================ + // CALENDAR MODULE + // ============================================ + FileView { + id: calendarLoader + path: root.configDir + "/calendar.json" + atomicWrites: true + watchChanges: true + onLoaded: { + if (!root.calendarReady) { + validateModule("calendar", calendarLoader, CalendarDefaults.data, () => { + root.calendarReady = true; + }); + } + } + onLoadFailed: { + if (error.toString().includes("FileNotFound") && !root.calendarReady) { + handleMissingConfig("calendar", calendarLoader, CalendarDefaults.data, () => { + root.calendarReady = true; + }); + } + } + onFileChanged: { + root.pauseAutoSave = true; + reload(); + root.pauseAutoSave = false; + } + onPathChanged: reload() + onAdapterUpdated: { + if (root.calendarReady && !root.pauseAutoSave) { + calendarLoader.writeAdapter(); + } + } + + adapter: JsonAdapter { + property bool enabled: true + property int syncInterval: 15 + property bool notifications: true + property bool barIndicator: true + property bool barShowNextEvent: false + property int defaultReminder: 15 + property string googleClientId: "" + property string googleClientSecret: "" + property list accounts: [] + property var calendars: ({}) + property bool soundOnArrival: true + property bool blinkOnArrival: true + property string arrivalSoundPath: "" + property bool barAlwaysShow: false + } + } + // Keybinds (binds.json) // Timer to repair keybinds after initial load Timer { @@ -3150,6 +3204,9 @@ Singleton { // AI configuration property QtObject ai: aiLoader.adapter + // Calendar configuration + property QtObject calendar: calendarLoader.adapter + // Module save functions function saveBar() { barLoader.writeAdapter(); @@ -3193,6 +3250,9 @@ Singleton { function saveAi() { aiLoader.writeAdapter(); } + function saveCalendar() { + calendarLoader.writeAdapter(); + } // Color helpers function isHexColor(colorValue) { diff --git a/config/defaults/calendar.js b/config/defaults/calendar.js new file mode 100644 index 00000000..c226de17 --- /dev/null +++ b/config/defaults/calendar.js @@ -0,0 +1,18 @@ +.pragma library + +var data = { + "enabled": true, + "syncInterval": 15, + "notifications": true, + "barIndicator": true, + "barShowNextEvent": false, + "defaultReminder": 15, + "soundOnArrival": true, + "arrivalSoundPath": "", + "blinkOnArrival": true, + "barAlwaysShow": false, + "googleClientId": "", + "googleClientSecret": "", + "accounts": [], + "calendars": {} +} diff --git a/modules/bar/BarCalendarIndicator.qml b/modules/bar/BarCalendarIndicator.qml new file mode 100644 index 00000000..0c695325 --- /dev/null +++ b/modules/bar/BarCalendarIndicator.qml @@ -0,0 +1,402 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Layouts +import qs.modules.services +import qs.modules.components +import qs.modules.theme +import qs.modules.globals +import qs.config + +Item { + id: root + + required property var bar + + property bool vertical: bar.orientation === "vertical" + property bool isHovered: false + property bool layerEnabled: true + + property real radius: 0 + property real startRadius: radius + property real endRadius: radius + + property bool popupOpen: calPopup.isOpen + + // i18n helper — works with or without the I18n singleton + function _t(key, fallback) { + let str; + try { str = I18n.t(key); } catch(e) { str = fallback; } + for (let i = 2; i < arguments.length; i++) + str = str.replace("%" + (i - 1), arguments[i]); + return str; + } + + readonly property bool showNextEvent: CalendarService.barShowNextEvent + readonly property bool alwaysShow: CalendarService.barAlwaysShow + readonly property var nextEvent: showNextEvent ? CalendarService.nextUpcomingEvent() : null + property var todayEvents: CalendarService.todayEvents() + + // Split today's events into upcoming (allDay + future timed) and past (past timed) + readonly property var upcomingTodayEvents: { + void _tick; + const now = new Date(); + return todayEvents.filter(ev => { + if (ev.allDay) return true; + try { return new Date(ev.end || ev.start) >= now; } catch(e) { return true; } + }); + } + readonly property var pastTodayEvents: { + void _tick; + const now = new Date(); + return todayEvents.filter(ev => { + if (ev.allDay) return false; + try { return new Date(ev.end || ev.start) < now; } catch(e) { return false; } + }); + } + + readonly property bool hasEvents: { + if (showNextEvent && nextEvent !== null) return true; + if (alwaysShow) return todayEvents.length > 0; + return upcomingTodayEvents.length > 0; + } + + Connections { + target: CalendarService + function onEventsChanged() { + root.todayEvents = CalendarService.todayEvents(); + } + } + + + visible: root.hasEvents + + implicitWidth: root.hasEvents ? (root.vertical ? 0 : bg.implicitWidth) : 0 + implicitHeight: root.hasEvents ? bg.implicitHeight : 0 + Layout.preferredWidth: root.hasEvents ? (root.vertical ? 0 : bg.implicitWidth) : 0 + Layout.preferredHeight: root.hasEvents ? bg.implicitHeight : 0 + Layout.fillWidth: root.vertical + + HoverHandler { + onHoveredChanged: root.isHovered = hovered + } + + StyledRect { + id: bg + variant: root.popupOpen ? "primary" : "bg" + anchors.fill: parent + enableShadow: root.layerEnabled + + implicitWidth: root.vertical ? 0 : (itemsRow.implicitWidth + 16) + implicitHeight: root.vertical ? (itemsCol.implicitHeight + 8) : 36 + + topLeftRadius: root.vertical ? root.startRadius : root.startRadius + topRightRadius: root.vertical ? root.startRadius : root.endRadius + bottomLeftRadius: root.vertical ? root.endRadius : root.startRadius + bottomRightRadius: root.vertical ? root.endRadius : root.endRadius + + readonly property color itemColor: root.popupOpen ? Styling.srItem("primary") : Colors.overBackground + + Rectangle { + anchors.fill: parent + color: Styling.srItem("overprimary") + opacity: root.popupOpen ? 0 : (root.isHovered ? 0.25 : 0) + radius: parent.radius ?? 0 + Behavior on opacity { + enabled: Config.animDuration > 0 + NumberAnimation { duration: Config.animDuration / 2 } + } + } + + // Horizontal layout + RowLayout { + id: itemsRow + anchors.centerIn: parent + visible: !root.vertical + spacing: 6 + + Text { + id: blinkIconH + text: Icons.calendarBlank + font.family: Icons.font + font.pixelSize: 13 + color: root.popupOpen ? bg.itemColor : Colors.primary + } + + ColumnLayout { + spacing: 0 + visible: root.showNextEvent && root.nextEvent !== null + + Text { + text: root.nextEvent ? root.nextEvent.title : "" + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-3) + font.weight: Font.Medium + color: bg.itemColor + elide: Text.ElideRight + Layout.maximumWidth: 100 + } + + Text { + text: root.timeUntilLive + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-4) + color: root.popupOpen ? bg.itemColor : Colors.outline + } + } + + // Today-only mode: show count badge (upcoming events only) + Text { + visible: !root.showNextEvent && root.upcomingTodayEvents.length > 0 + text: root.upcomingTodayEvents.length.toString() + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-3) + font.weight: Font.Medium + color: root.popupOpen ? bg.itemColor : Colors.outline + } + } + + // Vertical layout + ColumnLayout { + id: itemsCol + anchors.centerIn: parent + visible: root.vertical + spacing: 2 + + Text { + id: blinkIconV + Layout.alignment: Qt.AlignHCenter + text: Icons.calendarBlank + font.family: Icons.font + font.pixelSize: 13 + color: root.popupOpen ? bg.itemColor : Colors.primary + } + + Text { + Layout.alignment: Qt.AlignHCenter + text: root.upcomingTodayEvents.length.toString() + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-3) + font.weight: Font.Medium + color: bg.itemColor + } + } + + MouseArea { + anchors.fill: parent + onClicked: { + if (CalendarService.iconBlinking) + CalendarService.dismissArrival(); + calPopup.toggle(); + } + } + } + + // Blinking animation for the calendar icon when an event is starting now + readonly property alias _blinkIconH: blinkIconH + readonly property alias _blinkIconV: blinkIconV + + SequentialAnimation { + id: blinkAnim + loops: Animation.Infinite + NumberAnimation { targets: [blinkIconH, blinkIconV]; property: "opacity"; from: 1.0; to: 0.15; duration: 450; easing.type: Easing.InOutSine } + NumberAnimation { targets: [blinkIconH, blinkIconV]; property: "opacity"; from: 0.15; to: 1.0; duration: 450; easing.type: Easing.InOutSine } + } + + Connections { + target: CalendarService + function onIconBlinkingChanged() { + if (CalendarService.iconBlinking) { + blinkAnim.start(); + } else { + blinkAnim.stop(); + blinkIconH.opacity = 1.0; + blinkIconV.opacity = 1.0; + } + } + } + + // Popup with today's events + BarPopup { + id: calPopup + anchorItem: bg + bar: root.bar + popupPadding: 12 + contentWidth: root.vertical ? 260 : Math.max(bg.width, 260) + contentHeight: popupColumn.implicitHeight + 24 + + ColumnLayout { + id: popupColumn + width: parent.width + spacing: 8 + + // Header + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Text { + text: Icons.calendarBlank + font.family: Icons.font + font.pixelSize: 14 + color: Colors.primary + } + + Text { + Layout.fillWidth: true + text: { + const now = new Date(); + return now.toLocaleDateString(Qt.locale(), "d MMMM, dddd"); + } + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(0) + font.weight: Font.DemiBold + color: Colors.overBackground + elide: Text.ElideRight + } + } + + Separator { + Layout.fillWidth: true + vert: false + Layout.topMargin: -4 + Layout.bottomMargin: -4 + } + + // Section label component + component SectionLabel: Text { + required property string labelText + Layout.fillWidth: true + text: labelText + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-3) + font.weight: Font.DemiBold + color: Colors.outline + Layout.topMargin: 2 + } + + // Event row component + component EventCard: StyledRect { + required property var eventData + property bool dimmed: false + Layout.fillWidth: true + variant: "common" + radius: Styling.radius(-2) + implicitHeight: evRow.implicitHeight + 14 + opacity: dimmed ? 0.5 : 1.0 + + RowLayout { + id: evRow + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + anchors.topMargin: 7 + anchors.bottomMargin: 7 + spacing: 10 + + Rectangle { + width: 3 + Layout.fillHeight: true + radius: 2 + color: CalendarService.calendarColor(eventData.calendarId) + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + text: eventData.title || root._t("calendar.event.untitled", "Untitled") + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + font.weight: Font.Medium + color: Colors.overBackground + elide: Text.ElideRight + Layout.fillWidth: true + } + + Text { + text: { + if (eventData.allDay) return root._t("calendar.event.all_day", "All day"); + const s = eventData.start || ""; + const e = eventData.end || ""; + const st = s.includes("T") ? s.split("T")[1].substring(0,5) : ""; + const et = e.includes("T") ? e.split("T")[1].substring(0,5) : ""; + return st + (et ? " – " + et : ""); + } + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-3) + color: Colors.outline + } + } + } + } + + // Upcoming events (always shown) + SectionLabel { + labelText: root._t("calendar.bar.section_upcoming", "Upcoming") + visible: root.alwaysShow && root.pastTodayEvents.length > 0 + } + + Repeater { + model: root.alwaysShow ? root.upcomingTodayEvents : root.todayEvents + + delegate: EventCard { + required property var modelData + eventData: modelData + dimmed: false + } + } + + // Past events section (only when alwaysShow is on) + SectionLabel { + labelText: root._t("calendar.bar.section_past", "Past") + visible: root.alwaysShow && root.pastTodayEvents.length > 0 + } + + Repeater { + model: root.alwaysShow ? root.pastTodayEvents : [] + + delegate: EventCard { + required property var modelData + eventData: modelData + dimmed: true + } + } + + // Empty state + Text { + visible: root.todayEvents.length === 0 + text: root._t("calendar.bar.no_events", "No events today") + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(-2) + color: Colors.outline + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: 4 + Layout.bottomMargin: 4 + } + } + } + + // Refresh timer — force re-evaluation of timeUntil + property int _tick: 0 + readonly property string timeUntilLive: { + void _tick; // depend on tick to force re-eval + if (!nextEvent) return ""; + try { + const now = new Date(); + const start = new Date(nextEvent.start); + const diff = Math.max(0, Math.floor((start.getTime() - now.getTime()) / 60000)); + if (diff < 1) return root._t("calendar.bar.time_now", "now"); + if (diff < 60) return root._t("calendar.bar.time_minutes", "in %1 min", diff); + const hours = Math.floor(diff / 60); + return root._t("calendar.bar.time_hours", "in %1h %2m", hours, diff % 60); + } catch(e) { return ""; } + } + + Timer { + running: root.visible && (root.nextEvent !== null || root.alwaysShow) + interval: 30000 + repeat: true + onTriggered: root._tick++ + } +} diff --git a/modules/bar/BarContent.qml b/modules/bar/BarContent.qml index b9e57ca7..e2609d80 100644 --- a/modules/bar/BarContent.qml +++ b/modules/bar/BarContent.qml @@ -536,6 +536,19 @@ Item { endRadius: root.innerRadius } + Loader { + active: CalendarService.barIndicatorEnabled && CalendarService.hasAccounts + visible: active && (item ? item.hasEvents : false) + sourceComponent: Component { + Bar.BarCalendarIndicator { + bar: root + layerEnabled: root.shadowsEnabled + startRadius: root.innerRadius + endRadius: root.innerRadius + } + } + } + Clock { id: clockComponent bar: root @@ -743,6 +756,20 @@ Item { endRadius: root.innerRadius } + Loader { + active: CalendarService.barIndicatorEnabled && CalendarService.hasAccounts + visible: active && (item ? item.hasEvents : false) + Layout.fillWidth: true + sourceComponent: Component { + Bar.BarCalendarIndicator { + bar: root + layerEnabled: root.shadowsEnabled + startRadius: root.innerRadius + endRadius: root.innerRadius + } + } + } + Clock { id: clockComponentVert bar: root diff --git a/modules/services/CalendarService.qml b/modules/services/CalendarService.qml new file mode 100644 index 00000000..3a3d5b6d --- /dev/null +++ b/modules/services/CalendarService.qml @@ -0,0 +1,323 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io +import qs.config +import qs.modules.globals +import qs.modules.theme +pragma ComponentBehavior: Bound + +// Calendar integration service — Python process, stdin/stdout JSON protocol. +Singleton { + id: root + + // Accounts and calendars + property var accounts: [] + property var calendars: [] + + // Events + property var events: [] + + // Status + property bool syncing: false + property bool hasAccounts: accounts.length > 0 + property bool gcalcliFound: false + property string authError: "" + + // Config shortcuts + readonly property bool enabled: Config.calendar && Config.calendar.enabled !== false + readonly property int syncInterval: Config.calendar ? (Config.calendar.syncInterval || 15) : 15 + readonly property bool notificationsEnabled: Config.calendar ? Config.calendar.notifications !== false : true + readonly property bool barIndicatorEnabled: Config.calendar ? Config.calendar.barIndicator !== false : true + readonly property bool barShowNextEvent: Config.calendar ? (Config.calendar.barShowNextEvent === true) : false + readonly property bool barAlwaysShow: Config.calendar ? (Config.calendar.barAlwaysShow === true) : false + readonly property int defaultReminder: Config.calendar ? (Config.calendar.defaultReminder || 15) : 15 + readonly property bool soundOnArrival: Config.calendar ? Config.calendar.soundOnArrival !== false : true + readonly property string arrivalSoundPath: Config.calendar ? (Config.calendar.arrivalSoundPath || "") : "" + readonly property bool blinkOnArrival: Config.calendar ? Config.calendar.blinkOnArrival !== false : true + + // Arrival state — set when an event fires the "starting now" notification + property var arrivingEvent: null + property bool iconBlinking: false + + // CRUD operation state + property bool operationPending: false + property string _lastProviderError: "" + signal operationResult(bool success, string message) + + // Main calendar process + property Process calendarProcess: Process { + id: calendarProcess + stdinEnabled: true + + command: [ + "python3", + Quickshell.shellDir + "/scripts/calendar_service.py", + root.syncInterval.toString(), + root.defaultReminder.toString(), + root.soundOnArrival ? "1" : "0", + root.arrivalSoundPath, + root.blinkOnArrival ? "1" : "0" + ] + + stdout: SplitParser { + onRead: data => { + try { + const msg = JSON.parse(data); + root.handleMessage(msg); + } catch (e) { + console.warn("CalendarService: Failed to parse: " + e); + } + } + } + } + + // Send command to Python process + function sendCommand(cmd) { + if (!calendarProcess.running) return; + calendarProcess.write(JSON.stringify(cmd) + "\n"); + } + + // ── Public API ── + + function sync() { + sendCommand({"cmd": "sync"}); + } + + function createEvent(event) { + sendCommand({"cmd": "create", "event": event}); + } + + function updateEvent(event) { + sendCommand({"cmd": "update", "event": event}); + } + + function deleteEvent(calendarId, eventId) { + sendCommand({"cmd": "delete", "calendarId": calendarId, "eventId": eventId}); + } + + function authGoogle() { + const clientId = Config.calendar ? (Config.calendar.googleClientId || "") : ""; + const clientSecret = Config.calendar ? (Config.calendar.googleClientSecret || "") : ""; + sendCommand({"cmd": "auth_google", "client_id": clientId, "client_secret": clientSecret}); + } + + function authCalDAV(url, user, pass_, name) { + sendCommand({"cmd": "auth_caldav", "url": url, "user": user, "pass": pass_, "name": name || ""}); + } + + function removeAccount(accountId) { + sendCommand({"cmd": "remove_account", "accountId": accountId}); + } + + function importGcalcli() { + sendCommand({"cmd": "import_gcalcli"}); + } + + function setCalendarEnabled(calendarId, enabled) { + sendCommand({"cmd": "set_calendar_enabled", "calendarId": calendarId, "enabled": enabled}); + } + + function setSyncInterval(interval) { + sendCommand({"cmd": "set_sync_interval", "interval": interval}); + } + + // ── Query helpers ── + + function eventsForDate(year, month, day) { + const dateStr = year + "-" + + String(month).padStart(2, "0") + "-" + + String(day).padStart(2, "0"); + let result = []; + for (let i = 0; i < events.length; i++) { + const ev = events[i]; + const start = ev.start || ""; + if (start.startsWith(dateStr)) { + result.push(ev); + } else if (ev.allDay && start.substring(0, 10) <= dateStr && (ev.end || "").substring(0, 10) > dateStr) { + result.push(ev); + } + } + return result; + } + + function hasEventsOnDate(year, month, day) { + return eventsForDate(year, month, day).length > 0; + } + + function nextUpcomingEvent() { + const now = new Date(); + let closest = null; + let closestTime = Infinity; + for (let i = 0; i < events.length; i++) { + const ev = events[i]; + if (ev.allDay) continue; + try { + const start = new Date(ev.start); + const end = ev.end ? new Date(ev.end) : start; + // Include in-progress events (start <= now < end) — rank them by start time + const inProgress = start <= now && now < end; + const diff = inProgress ? 0 : (start.getTime() - now.getTime()); + if ((inProgress || diff > 0) && diff < closestTime) { + closestTime = diff; + closest = ev; + } + } catch (e) { /* unparseable date, skip */ } + } + return closest; + } + + function todayEvents() { + const now = new Date(); + const all = eventsForDate(now.getFullYear(), now.getMonth() + 1, now.getDate()); + const upcoming = []; + const past = []; + for (let i = 0; i < all.length; i++) { + const ev = all[i]; + if (ev.allDay) { upcoming.push(ev); continue; } + try { + const end = new Date(ev.end || ev.start); + if (end >= now) upcoming.push(ev); + else past.push(ev); + } catch (e) { upcoming.push(ev); } + } + upcoming.sort((a, b) => new Date(a.start) - new Date(b.start)); + past.sort((a, b) => new Date(b.start) - new Date(a.start)); + return upcoming.concat(past); + } + + function calendarColor(calendarId) { + for (let i = 0; i < calendars.length; i++) { + if (calendars[i].id === calendarId) + return calendars[i].color || Colors.primary; + } + return Colors.primary; + } + + function calendarName(calendarId) { + for (let i = 0; i < calendars.length; i++) { + if (calendars[i].id === calendarId) + return calendars[i].name || "Calendar"; + } + return "Calendar"; + } + + function calendarProvider(calendarId) { + for (let i = 0; i < calendars.length; i++) { + if (calendars[i].id === calendarId) { + const accountId = calendars[i].accountId; + for (let j = 0; j < accounts.length; j++) { + if (accounts[j].id === accountId) + return accounts[j].provider || ""; + } + } + } + return ""; + } + + function accountName(accountId) { + for (let i = 0; i < accounts.length; i++) { + if (accounts[i].id === accountId) + return accounts[i].email || accounts[i].name || accountId; + } + return accountId; + } + + // ── Message handler ── + + function handleMessage(msg) { + switch (msg.type) { + case "static": + root.accounts = msg.accounts || []; + root.calendars = msg.calendars || []; + break; + case "events": + root.events = msg.data || []; + break; + case "sync_status": + root.syncing = msg.syncing || false; + break; + case "gcalcli_status": + root.gcalcliFound = msg.found || false; + break; + case "auth_complete": + root.authError = ""; + break; + case "auth_error": + root.authError = msg.message || "Unknown error"; + console.warn("CalendarService auth error: " + msg.message); + break; + case "notify": + // Sound played by Python process — no duplicate here. + break; + case "notify_arrive": + root.arrivingEvent = msg.event || null; + if (root.blinkOnArrival) { + root.iconBlinking = true; + blinkStopTimer.restart(); + } + // Sound played by Python process — no duplicate here. + break; + case "cmd_start": + root.operationPending = true; + root._lastProviderError = ""; + break; + case "cmd_result": { + root.operationPending = false; + const errMsg = msg.success ? "" : (msg.message || root._lastProviderError || "Operation failed"); + root._lastProviderError = ""; + root.operationResult(msg.success === true, errMsg); + break; + } + case "error": + // Store for the next cmd_result so the real error surfaces in the UI + root._lastProviderError = msg.message || ""; + console.warn("CalendarService error: " + msg.message); + break; + } + } + + Component.onCompleted: { + if (root.enabled && Config.initialLoadComplete) + calendarProcess.running = true; + } + + onEnabledChanged: { + if (enabled && Config.initialLoadComplete) calendarProcess.running = true; + else calendarProcess.running = false; + } + + onSyncIntervalChanged: if (calendarProcess.running) restartProcess() + onSoundOnArrivalChanged: if (calendarProcess.running) restartProcess() + onArrivalSoundPathChanged: if (calendarProcess.running) restartProcess() + onBlinkOnArrivalChanged: if (calendarProcess.running) restartProcess() + + Connections { + target: Config + function onInitialLoadCompleteChanged() { + if (Config.initialLoadComplete && root.enabled) + calendarProcess.running = true; + } + } + + function restartProcess() { + calendarProcess.running = false; + Qt.callLater(() => { calendarProcess.running = true; }); + } + + // Stop blinking after 30 seconds (or user can dismiss by clicking the bar icon) + Timer { + id: blinkStopTimer + interval: 30000 + repeat: false + onTriggered: root.iconBlinking = false + } + + function dismissArrival() { + root.iconBlinking = false; + root.arrivingEvent = null; + blinkStopTimer.stop(); + } + + +} diff --git a/modules/theme/Icons.qml b/modules/theme/Icons.qml index c6a690a5..7d16db65 100644 --- a/modules/theme/Icons.qml +++ b/modules/theme/Icons.qml @@ -124,6 +124,8 @@ QtObject { readonly property string chromium: "" readonly property string telegram: "" + // Calendar + readonly property string calendarBlank: "\ue0d0" // Clock readonly property string clock: "" readonly property string alarm: "" diff --git a/modules/widgets/dashboard/controls/CalendarPanel.qml b/modules/widgets/dashboard/controls/CalendarPanel.qml new file mode 100644 index 00000000..a6560d0a --- /dev/null +++ b/modules/widgets/dashboard/controls/CalendarPanel.qml @@ -0,0 +1,1279 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.modules.theme +import qs.modules.components +import qs.modules.services +import qs.config + +Item { + id: root + + property int maxContentWidth: 480 + readonly property int contentWidth: Math.min(width, maxContentWidth) + readonly property real sideMargin: (width - contentWidth) / 2 + + property string currentSection: "" + + // i18n helper — works with or without the I18n singleton + function _t(key, fallback) { + let str; + try { str = I18n.t(key); } catch(e) { str = fallback; } + for (let i = 2; i < arguments.length; i++) + str = str.replace("%" + (i - 1), arguments[i]); + return str; + } + + // CalDAV form state + property string caldavName: "" + property string caldavUrl: "" + property string caldavUser: "" + property string caldavPass: "" + + // Pending settings + property int pendingSyncInterval: CalendarService.syncInterval + property bool pendingNotifications: CalendarService.notificationsEnabled + property bool pendingBarIndicator: CalendarService.barIndicatorEnabled + property bool pendingBarShowNextEvent: Config.calendar ? (Config.calendar.barShowNextEvent === true) : false + property bool pendingBarAlwaysShow: Config.calendar ? (Config.calendar.barAlwaysShow === true) : false + property int pendingDefaultReminder: CalendarService.defaultReminder + property bool pendingSoundOnArrival: CalendarService.soundOnArrival + property string pendingArrivalSoundPath: Config.calendar ? (Config.calendar.arrivalSoundPath || "") : "" + property bool pendingBlinkOnArrival: CalendarService.blinkOnArrival + property string pendingGoogleClientId: Config.calendar ? (Config.calendar.googleClientId || "") : "" + property string pendingGoogleClientSecret: Config.calendar ? (Config.calendar.googleClientSecret || "") : "" + + readonly property bool hasGoogleAccount: { + for (let i = 0; i < CalendarService.accounts.length; i++) { + if (CalendarService.accounts[i].provider === "google") return true; + } + return false; + } + + readonly property bool hasChanges: { + if (!Config.calendar) return false; + return pendingSyncInterval !== (Config.calendar.syncInterval || 15) + || pendingNotifications !== (Config.calendar.notifications !== false) + || pendingBarIndicator !== (Config.calendar.barIndicator !== false) + || pendingDefaultReminder !== (Config.calendar.defaultReminder || 15) + || pendingSoundOnArrival !== (Config.calendar.soundOnArrival !== false) + || pendingArrivalSoundPath !== (Config.calendar.arrivalSoundPath || "") + || pendingBlinkOnArrival !== (Config.calendar.blinkOnArrival !== false) + || pendingGoogleClientId !== (Config.calendar.googleClientId || "") + || pendingGoogleClientSecret !== (Config.calendar.googleClientSecret || "") + || pendingBarShowNextEvent !== (Config.calendar.barShowNextEvent === true) + || pendingBarAlwaysShow !== (Config.calendar.barAlwaysShow === true); + } + + function resetToConfig() { + if (!Config.calendar) return; + pendingSyncInterval = Config.calendar.syncInterval || 15; + pendingNotifications = Config.calendar.notifications !== false; + pendingBarIndicator = Config.calendar.barIndicator !== false; + pendingDefaultReminder = Config.calendar.defaultReminder || 15; + pendingSoundOnArrival = Config.calendar.soundOnArrival !== false; + pendingArrivalSoundPath = Config.calendar.arrivalSoundPath || ""; + pendingBlinkOnArrival = Config.calendar.blinkOnArrival !== false; + pendingGoogleClientId = Config.calendar.googleClientId || ""; + pendingGoogleClientSecret = Config.calendar.googleClientSecret || ""; + pendingBarShowNextEvent = Config.calendar.barShowNextEvent === true; + pendingBarAlwaysShow = Config.calendar.barAlwaysShow === true; + } + + function saveToConfig() { + if (!Config.calendar) return; + Config.calendar.syncInterval = pendingSyncInterval; + Config.calendar.notifications = pendingNotifications; + Config.calendar.barIndicator = pendingBarIndicator; + Config.calendar.barShowNextEvent = pendingBarShowNextEvent; + Config.calendar.barAlwaysShow = pendingBarAlwaysShow; + Config.calendar.defaultReminder = pendingDefaultReminder; + Config.calendar.soundOnArrival = pendingSoundOnArrival; + Config.calendar.arrivalSoundPath = pendingArrivalSoundPath; + Config.calendar.blinkOnArrival = pendingBlinkOnArrival; + Config.calendar.googleClientId = pendingGoogleClientId; + Config.calendar.googleClientSecret = pendingGoogleClientSecret; + Config.saveCalendar(); + CalendarService.setSyncInterval(pendingSyncInterval); + } + + component SectionButton: StyledRect { + id: sectionBtn + required property string text + required property string sectionId + + property bool isHovered: false + + variant: isHovered ? "focus" : "pane" + Layout.fillWidth: true + Layout.preferredHeight: 56 + radius: Styling.radius(0) + + RowLayout { + anchors.fill: parent + anchors.margins: 16 + spacing: 16 + + Text { + text: sectionBtn.text + font.family: Config.theme.font + font.pixelSize: Styling.fontSize(0) + font.bold: true + color: Colors.overBackground + Layout.fillWidth: true + } + + Text { + text: Icons.caretRight + font.family: Icons.font + font.pixelSize: 20 + color: Colors.overSurfaceVariant + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onEntered: sectionBtn.isHovered = true + onExited: sectionBtn.isHovered = false + onClicked: root.currentSection = sectionBtn.sectionId + } + } + + Flickable { + id: mainFlickable + anchors.fill: parent + contentHeight: mainColumn.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + + ColumnLayout { + id: mainColumn + width: mainFlickable.width + spacing: 8 + + // Header + Item { + Layout.fillWidth: true + Layout.preferredHeight: titlebar.height + + PanelTitlebar { + id: titlebar + width: root.contentWidth + anchors.horizontalCenter: parent.horizontalCenter + title: root.currentSection === "" ? "Calendar" : (root.currentSection === "accounts" ? "Accounts" : "Settings") + statusText: CalendarService.syncing ? "Syncing..." : "" + statusColor: Colors.primary + + actions: { + let acts = []; + if (root.currentSection !== "") { + acts.push({ + icon: Icons.arrowLeft, + tooltip: "Back", + onClicked: function() { root.currentSection = ""; } + }); + } + if (CalendarService.hasAccounts) { + acts.push({ + icon: Icons.sync, + tooltip: "Sync now", + loading: CalendarService.syncing, + onClicked: function() { CalendarService.sync(); } + }); + } + return acts; + } + } + } + + // Content + Item { + Layout.fillWidth: true + Layout.preferredHeight: contentColumn.implicitHeight + + ColumnLayout { + id: contentColumn + width: root.contentWidth + anchors.horizontalCenter: parent.horizontalCenter + spacing: 16 + + // ── Menu ── + ColumnLayout { + visible: root.currentSection === "" + Layout.fillWidth: true + spacing: 8 + + SectionButton { + text: "Accounts" + sectionId: "accounts" + } + + SectionButton { + text: "Settings" + sectionId: "settings" + } + } + + // ── Accounts section ── + ColumnLayout { + visible: root.currentSection === "accounts" + Layout.fillWidth: true + spacing: 12 + + // Connected accounts + Repeater { + model: CalendarService.accounts + + delegate: StyledRect { + required property var modelData + required property int index + Layout.fillWidth: true + variant: "pane" + radius: Styling.radius(0) + implicitHeight: accountRow.implicitHeight + 20 + + RowLayout { + id: accountRow + anchors.fill: parent + anchors.margins: 10 + spacing: 10 + + // Provider icon + StyledRect { + variant: "common" + implicitWidth: 32 + implicitHeight: 32 + radius: Styling.radius(-2) + + Text { + anchors.centerIn: parent + text: modelData.provider === "google" ? "G" : "C" + font.family: Config.defaultFont + font.pixelSize: 14 + font.weight: Font.Bold + color: modelData.provider === "google" ? "#4285f4" : "#0082c9" + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + text: modelData.email || modelData.name || modelData.id + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + elide: Text.ElideRight + Layout.fillWidth: true + } + + Text { + text: modelData.provider === "google" ? "Google Calendar" : "CalDAV" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + color: Colors.outline + } + } + + StyledRect { + variant: removeMouseArea.containsMouse ? "focus" : "common" + implicitWidth: removeText.implicitWidth + 16 + implicitHeight: 24 + radius: Styling.radius(-4) + + Text { + id: removeText + anchors.centerIn: parent + text: "Remove" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + color: Colors.red + } + + MouseArea { + id: removeMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: CalendarService.removeAccount(modelData.id) + } + } + } + } + } + + // Calendars list (if accounts exist) + ColumnLayout { + visible: CalendarService.calendars.length > 0 + Layout.fillWidth: true + spacing: 4 + + Text { + text: "CALENDARS" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + color: Colors.outline + Layout.topMargin: 8 + } + + Repeater { + model: CalendarService.calendars + + delegate: RowLayout { + required property var modelData + required property int index + Layout.fillWidth: true + spacing: 8 + + Rectangle { + width: 12 + height: 12 + radius: 3 + color: modelData.color || Colors.primary + } + + Text { + Layout.fillWidth: true + text: modelData.name || "Calendar" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + elide: Text.ElideRight + } + + Text { + text: CalendarService.accountName(modelData.accountId) + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + color: Colors.outline + } + + Switch { + checked: modelData.enabled !== false + onToggled: CalendarService.setCalendarEnabled(modelData.id, checked) + indicator: Rectangle { + implicitWidth: 36 + implicitHeight: 18 + radius: 9 + color: parent.checked ? Colors.primary : Colors.surfaceBright + + Rectangle { + x: parent.parent.checked ? parent.width - width - 2 : 2 + y: 2 + width: 14 + height: 14 + radius: 7 + color: Colors.primary.hslLightness > 0.5 ? "black" : "white" + Behavior on x { NumberAnimation { duration: 150 } } + } + } + background: null + } + } + } + } + + // gcalcli detected — quick import + StyledRect { + visible: CalendarService.gcalcliFound && !root.hasGoogleAccount + Layout.fillWidth: true + Layout.topMargin: 4 + variant: "pane" + radius: Styling.radius(0) + implicitHeight: gcalcliCol.implicitHeight + 20 + + ColumnLayout { + id: gcalcliCol + anchors.fill: parent + anchors.margins: 10 + spacing: 8 + + Text { + text: "gcalcli token found" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-2) + color: Colors.outline + } + + Text { + text: "An existing Google Calendar token from gcalcli was detected. You can import it to connect your account instantly." + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-2) + color: Colors.overBackground + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + StyledRect { + Layout.fillWidth: true + variant: gcalcliMouse.containsMouse ? "primaryfocus" : "primary" + implicitHeight: 36 + radius: Styling.radius(-2) + + Text { + anchors.centerIn: parent + text: "Import from gcalcli" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + font.weight: Font.DemiBold + color: Colors.primary.hslLightness > 0.5 ? "black" : "white" + } + + MouseArea { + id: gcalcliMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: CalendarService.importGcalcli() + } + } + } + } + + // Add account buttons + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 8 + spacing: 8 + + StyledRect { + Layout.fillWidth: true + variant: googleMouse.containsMouse ? "primaryfocus" : "primary" + implicitHeight: 36 + radius: Styling.radius(-2) + + Text { + anchors.centerIn: parent + text: "+ Google OAuth" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.primary.hslLightness > 0.5 ? "black" : "white" + } + + MouseArea { + id: googleMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: CalendarService.authGoogle() + } + } + + StyledRect { + Layout.fillWidth: true + variant: caldavMouse.containsMouse ? "primaryfocus" : "primary" + implicitHeight: 36 + radius: Styling.radius(-2) + + Text { + anchors.centerIn: parent + text: "+ CalDAV" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.primary.hslLightness > 0.5 ? "black" : "white" + } + + MouseArea { + id: caldavMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: caldavForm.visible = !caldavForm.visible + } + } + } + + Text { + visible: !CalendarService.gcalcliFound + text: "Enter your Google OAuth Client ID and Secret above, then click Connect. If you have gcalcli installed and authenticated, its token will be detected automatically." + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + color: Colors.outline + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + // Auth error display + Text { + visible: CalendarService.authError !== "" + text: CalendarService.authError + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-2) + color: Colors.red + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + // CalDAV connection form + ColumnLayout { + id: caldavForm + visible: false + Layout.fillWidth: true + spacing: 8 + + Text { + text: "CalDAV Server" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-2) + font.weight: Font.Medium + color: Colors.overBackground + } + + TextField { + id: caldavNameField + Layout.fillWidth: true + implicitHeight: 36 + leftPadding: 8; rightPadding: 8 + topPadding: 0; bottomPadding: 0 + verticalAlignment: TextInput.AlignVCenter + placeholderText: "Account name (optional)" + placeholderTextColor: Colors.outline + text: root.caldavName + onTextChanged: root.caldavName = text + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + selectByMouse: true + background: Item { + StyledRect { + anchors.fill: parent + variant: "common" + radius: Styling.radius(-2) + enableBorder: false + } + Rectangle { + anchors.fill: parent + color: "transparent" + border.color: caldavNameField.activeFocus ? Colors.primary : Colors.outlineVariant + border.width: 1 + radius: Styling.radius(-2) + } + } + } + + TextField { + id: caldavUrlField + Layout.fillWidth: true + implicitHeight: 36 + leftPadding: 8; rightPadding: 8 + topPadding: 0; bottomPadding: 0 + verticalAlignment: TextInput.AlignVCenter + placeholderText: "https://cloud.example.com/remote.php/dav" + placeholderTextColor: Colors.outline + text: root.caldavUrl + onTextChanged: root.caldavUrl = text.replace(/[\r\n\t ]/g, "") + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + selectByMouse: true + background: Item { + StyledRect { + anchors.fill: parent + variant: "common" + radius: Styling.radius(-2) + enableBorder: false + } + Rectangle { + anchors.fill: parent + color: "transparent" + border.color: caldavUrlField.activeFocus ? Colors.primary : Colors.outlineVariant + border.width: 1 + radius: Styling.radius(-2) + } + } + } + + TextField { + id: caldavUserField + Layout.fillWidth: true + implicitHeight: 36 + leftPadding: 8; rightPadding: 8 + topPadding: 0; bottomPadding: 0 + verticalAlignment: TextInput.AlignVCenter + placeholderText: "Username" + placeholderTextColor: Colors.outline + text: root.caldavUser + onTextChanged: root.caldavUser = text + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + selectByMouse: true + background: Item { + StyledRect { + anchors.fill: parent + variant: "common" + radius: Styling.radius(-2) + enableBorder: false + } + Rectangle { + anchors.fill: parent + color: "transparent" + border.color: caldavUserField.activeFocus ? Colors.primary : Colors.outlineVariant + border.width: 1 + radius: Styling.radius(-2) + } + } + } + + TextField { + id: caldavPassField + Layout.fillWidth: true + implicitHeight: 36 + leftPadding: 8; rightPadding: 8 + topPadding: 0; bottomPadding: 0 + verticalAlignment: TextInput.AlignVCenter + placeholderText: "Password" + placeholderTextColor: Colors.outline + text: root.caldavPass + onTextChanged: root.caldavPass = text + echoMode: TextInput.Password + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + selectByMouse: true + background: Item { + StyledRect { + anchors.fill: parent + variant: "common" + radius: Styling.radius(-2) + enableBorder: false + } + Rectangle { + anchors.fill: parent + color: "transparent" + border.color: caldavPassField.activeFocus ? Colors.primary : Colors.outlineVariant + border.width: 1 + radius: Styling.radius(-2) + } + } + } + + StyledRect { + Layout.fillWidth: true + variant: connectMouse.containsMouse ? "primaryfocus" : "primary" + implicitHeight: 32 + radius: Styling.radius(-2) + + Text { + anchors.centerIn: parent + text: "Connect" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + font.weight: Font.DemiBold + color: Colors.primary.hslLightness > 0.5 ? "black" : "white" + } + + MouseArea { + id: connectMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + CalendarService.authCalDAV(root.caldavUrl, root.caldavUser, root.caldavPass, root.caldavName); + root.caldavName = ""; + root.caldavUrl = ""; + root.caldavUser = ""; + root.caldavPass = ""; + caldavForm.visible = false; + } + } + } + } + } + + // ── Settings section ── + ColumnLayout { + visible: root.currentSection === "settings" + Layout.fillWidth: true + spacing: 12 + + // Google OAuth credentials + Text { + text: "GOOGLE OAUTH" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + color: Colors.outline + } + + TextField { + id: googleClientIdField + Layout.fillWidth: true + implicitHeight: 36 + leftPadding: 8; rightPadding: 8 + topPadding: 0; bottomPadding: 0 + verticalAlignment: TextInput.AlignVCenter + placeholderText: "Google Client ID" + placeholderTextColor: Colors.outline + text: root.pendingGoogleClientId + onTextChanged: root.pendingGoogleClientId = text + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + selectByMouse: true + background: Item { + StyledRect { + anchors.fill: parent + variant: "common" + radius: Styling.radius(-2) + enableBorder: false + } + Rectangle { + anchors.fill: parent + color: "transparent" + border.color: googleClientIdField.activeFocus ? Colors.primary : Colors.outlineVariant + border.width: 1 + radius: Styling.radius(-2) + } + } + } + + TextField { + id: googleClientSecretField + Layout.fillWidth: true + implicitHeight: 36 + leftPadding: 8; rightPadding: 8 + topPadding: 0; bottomPadding: 0 + verticalAlignment: TextInput.AlignVCenter + placeholderText: "Google Client Secret" + placeholderTextColor: Colors.outline + text: root.pendingGoogleClientSecret + onTextChanged: root.pendingGoogleClientSecret = text + echoMode: TextInput.Password + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + selectByMouse: true + background: Item { + StyledRect { + anchors.fill: parent + variant: "common" + radius: Styling.radius(-2) + enableBorder: false + } + Rectangle { + anchors.fill: parent + color: "transparent" + border.color: googleClientSecretField.activeFocus ? Colors.primary : Colors.outlineVariant + border.width: 1 + radius: Styling.radius(-2) + } + } + } + + Text { + text: "Create credentials at console.cloud.google.com (Calendar API, Desktop app type)." + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + color: Colors.outline + wrapMode: Text.WordWrap + Layout.fillWidth: true + Layout.bottomMargin: 8 + } + + // Sync interval + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Text { + text: "Sync interval" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + Layout.fillWidth: true + } + + ComboBox { + id: syncIntervalCombo + implicitHeight: 36 + model: [ + { text: "5 min", value: 5 }, + { text: "15 min", value: 15 }, + { text: "30 min", value: 30 }, + { text: "60 min", value: 60 } + ] + textRole: "text" + valueRole: "value" + currentIndex: { + const vals = [5, 15, 30, 60]; + const idx = vals.indexOf(root.pendingSyncInterval); + return idx >= 0 ? idx : 1; + } + onCurrentValueChanged: if (currentValue) root.pendingSyncInterval = currentValue + background: Rectangle { + color: syncIntervalCombo.hovered ? Colors.surfaceContainerHigh : Colors.surfaceContainer + radius: Styling.radius(-2) + border.color: Colors.outlineVariant + border.width: 1 + } + contentItem: Text { + leftPadding: 10 + rightPadding: 32 + text: syncIntervalCombo.displayText + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + indicator: Text { + x: syncIntervalCombo.width - width - 10 + anchors.verticalCenter: parent.verticalCenter + text: Icons.caretDown + font.family: Icons.font + font.pixelSize: 14 + color: Colors.overBackground + } + popup: Popup { + y: syncIntervalCombo.height + 4 + width: syncIntervalCombo.width + padding: 4 + background: Rectangle { + color: Colors.surfaceContainerLow + radius: Styling.radius(-1) + border.color: Colors.outlineVariant + border.width: 1 + } + contentItem: ListView { + clip: true + implicitHeight: Math.min(contentHeight, 200) + model: syncIntervalCombo.popup.visible ? syncIntervalCombo.delegateModel : null + currentIndex: syncIntervalCombo.highlightedIndex + ScrollIndicator.vertical: ScrollIndicator {} + } + } + delegate: ItemDelegate { + id: syncIntervalDelegate + required property var modelData + required property int index + width: ListView.view.width + height: 32 + highlighted: syncIntervalCombo.highlightedIndex === index + background: Rectangle { + color: syncIntervalDelegate.highlighted ? Colors.surfaceContainerHigh : "transparent" + radius: Styling.radius(-2) + } + contentItem: Text { + leftPadding: 8 + text: syncIntervalDelegate.modelData.text || "" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + verticalAlignment: Text.AlignVCenter + } + } + } + } + + // Notifications toggle + RowLayout { + Layout.fillWidth: true + + Text { + text: "Notifications" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + Layout.fillWidth: true + } + + Switch { + checked: root.pendingNotifications + onToggled: root.pendingNotifications = checked + indicator: Rectangle { + implicitWidth: 36 + implicitHeight: 18 + radius: 9 + color: parent.checked ? Colors.primary : Colors.surfaceBright + Rectangle { + x: parent.parent.checked ? parent.width - width - 2 : 2 + y: 2; width: 14; height: 14; radius: 7 + color: Colors.primary.hslLightness > 0.5 ? "black" : "white" + Behavior on x { NumberAnimation { duration: 150 } } + } + } + background: null + } + } + + // Bar indicator toggle + RowLayout { + Layout.fillWidth: true + + Text { + text: "Bar indicator" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + Layout.fillWidth: true + } + + Switch { + checked: root.pendingBarIndicator + onToggled: root.pendingBarIndicator = checked + indicator: Rectangle { + implicitWidth: 36 + implicitHeight: 18 + radius: 9 + color: parent.checked ? Colors.primary : Colors.surfaceBright + Rectangle { + x: parent.parent.checked ? parent.width - width - 2 : 2 + y: 2; width: 14; height: 14; radius: 7 + color: Colors.primary.hslLightness > 0.5 ? "black" : "white" + Behavior on x { NumberAnimation { duration: 150 } } + } + } + background: null + } + } + + // Bar — show next event toggle + RowLayout { + Layout.fillWidth: true + + Text { + text: "Show next upcoming event" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: root.pendingBarIndicator ? Colors.overBackground : Colors.outline + Layout.fillWidth: true + } + + Switch { + enabled: root.pendingBarIndicator + checked: root.pendingBarShowNextEvent + onToggled: root.pendingBarShowNextEvent = checked + indicator: Rectangle { + implicitWidth: 36 + implicitHeight: 18 + radius: 9 + color: parent.checked && parent.enabled ? Colors.primary : Colors.surfaceBright + opacity: parent.enabled ? 1 : 0.4 + Rectangle { + x: parent.parent.checked ? parent.width - width - 2 : 2 + y: 2; width: 14; height: 14; radius: 7 + color: Colors.primary.hslLightness > 0.5 ? "black" : "white" + Behavior on x { NumberAnimation { duration: 150 } } + } + } + background: null + } + } + + // Bar — always show toggle + RowLayout { + Layout.fillWidth: true + + Text { + text: root._t("calendar.settings.bar_always_show", "Always show in bar") + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: root.pendingBarIndicator ? Colors.overBackground : Colors.outline + Layout.fillWidth: true + } + + Switch { + enabled: root.pendingBarIndicator + checked: root.pendingBarAlwaysShow + onToggled: root.pendingBarAlwaysShow = checked + indicator: Rectangle { + implicitWidth: 36 + implicitHeight: 18 + radius: 9 + color: parent.checked && parent.enabled ? Colors.primary : Colors.surfaceBright + opacity: parent.enabled ? 1 : 0.4 + Rectangle { + x: parent.parent.checked ? parent.width - width - 2 : 2 + y: 2; width: 14; height: 14; radius: 7 + color: Colors.primary.hslLightness > 0.5 ? "black" : "white" + Behavior on x { NumberAnimation { duration: 150 } } + } + } + background: null + } + } + + // Default reminder + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Text { + text: "Default reminder" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + Layout.fillWidth: true + } + + ComboBox { + id: defaultReminderCombo + implicitHeight: 36 + model: [ + { text: "None", value: 0 }, + { text: "5 min", value: 5 }, + { text: "10 min", value: 10 }, + { text: "15 min", value: 15 }, + { text: "30 min", value: 30 }, + { text: "1 hour", value: 60 } + ] + textRole: "text" + valueRole: "value" + currentIndex: { + const vals = [0, 5, 10, 15, 30, 60]; + const idx = vals.indexOf(root.pendingDefaultReminder); + return idx >= 0 ? idx : 3; + } + onCurrentValueChanged: if (currentValue !== undefined) root.pendingDefaultReminder = currentValue + background: Rectangle { + color: defaultReminderCombo.hovered ? Colors.surfaceContainerHigh : Colors.surfaceContainer + radius: Styling.radius(-2) + border.color: Colors.outlineVariant + border.width: 1 + } + contentItem: Text { + leftPadding: 10 + rightPadding: 32 + text: defaultReminderCombo.displayText + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + indicator: Text { + x: defaultReminderCombo.width - width - 10 + anchors.verticalCenter: parent.verticalCenter + text: Icons.caretDown + font.family: Icons.font + font.pixelSize: 14 + color: Colors.overBackground + } + popup: Popup { + y: defaultReminderCombo.height + 4 + width: defaultReminderCombo.width + padding: 4 + background: Rectangle { + color: Colors.surfaceContainerLow + radius: Styling.radius(-1) + border.color: Colors.outlineVariant + border.width: 1 + } + contentItem: ListView { + clip: true + implicitHeight: Math.min(contentHeight, 200) + model: defaultReminderCombo.popup.visible ? defaultReminderCombo.delegateModel : null + currentIndex: defaultReminderCombo.highlightedIndex + ScrollIndicator.vertical: ScrollIndicator {} + } + } + delegate: ItemDelegate { + id: defaultReminderDelegate + required property var modelData + required property int index + width: ListView.view.width + height: 32 + highlighted: defaultReminderCombo.highlightedIndex === index + background: Rectangle { + color: defaultReminderDelegate.highlighted ? Colors.surfaceContainerHigh : "transparent" + radius: Styling.radius(-2) + } + contentItem: Text { + leftPadding: 8 + text: defaultReminderDelegate.modelData.text || "" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + verticalAlignment: Text.AlignVCenter + } + } + } + } + + // Arrival attention settings + Text { + text: "ARRIVAL ATTENTION" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + color: Colors.outline + Layout.topMargin: 4 + } + + RowLayout { + Layout.fillWidth: true + + Text { + text: "Sound on arrival" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: root.pendingNotifications ? Colors.overBackground : Colors.outline + Layout.fillWidth: true + } + + Switch { + enabled: root.pendingNotifications + checked: root.pendingSoundOnArrival + onToggled: root.pendingSoundOnArrival = checked + indicator: Rectangle { + implicitWidth: 36; implicitHeight: 18; radius: 9 + color: parent.checked && parent.enabled ? Colors.primary : Colors.surfaceBright + opacity: parent.enabled ? 1 : 0.4 + Rectangle { + x: parent.parent.checked ? parent.width - width - 2 : 2 + y: 2; width: 14; height: 14; radius: 7 + color: Colors.primary.hslLightness > 0.5 ? "black" : "white" + Behavior on x { NumberAnimation { duration: 150 } } + } + } + background: null + } + } + + // Custom sound path (shown when sound is enabled) + ColumnLayout { + visible: root.pendingSoundOnArrival && root.pendingNotifications + Layout.fillWidth: true + spacing: 4 + + Text { + text: "Custom sound file" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + color: Colors.outline + } + + TextField { + id: arrivalSoundField + Layout.fillWidth: true + implicitHeight: 36 + leftPadding: 8; rightPadding: 8 + topPadding: 0; bottomPadding: 0 + verticalAlignment: TextInput.AlignVCenter + placeholderText: "Leave empty to use system default" + placeholderTextColor: Colors.outline + text: root.pendingArrivalSoundPath + onTextChanged: root.pendingArrivalSoundPath = text + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + selectByMouse: true + background: Item { + StyledRect { + anchors.fill: parent + variant: "common" + radius: Styling.radius(-2) + enableBorder: false + } + Rectangle { + anchors.fill: parent + color: "transparent" + border.color: arrivalSoundField.activeFocus ? Colors.primary : Colors.outlineVariant + border.width: 1 + radius: Styling.radius(-2) + } + } + } + + Text { + text: "Supports .oga, .ogg, .wav, .mp3" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-4) + color: Colors.outline + } + } + + RowLayout { + Layout.fillWidth: true + + Text { + text: "Blink icon on arrival" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: root.pendingBarIndicator ? Colors.overBackground : Colors.outline + Layout.fillWidth: true + } + + Switch { + enabled: root.pendingBarIndicator + checked: root.pendingBlinkOnArrival + onToggled: root.pendingBlinkOnArrival = checked + indicator: Rectangle { + implicitWidth: 36; implicitHeight: 18; radius: 9 + color: parent.checked && parent.enabled ? Colors.primary : Colors.surfaceBright + opacity: parent.enabled ? 1 : 0.4 + Rectangle { + x: parent.parent.checked ? parent.width - width - 2 : 2 + y: 2; width: 14; height: 14; radius: 7 + color: Colors.primary.hslLightness > 0.5 ? "black" : "white" + Behavior on x { NumberAnimation { duration: 150 } } + } + } + background: null + } + } + + // Save / Reset buttons + RowLayout { + visible: root.hasChanges + Layout.fillWidth: true + Layout.topMargin: 8 + spacing: 8 + + StyledRect { + Layout.fillWidth: true + variant: saveBtnMouse.containsMouse ? "primaryfocus" : "primary" + implicitHeight: 32 + radius: Styling.radius(-2) + + Text { + anchors.centerIn: parent + text: "Save" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + font.weight: Font.DemiBold + color: Colors.primary.hslLightness > 0.5 ? "black" : "white" + } + + MouseArea { + id: saveBtnMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.saveToConfig() + } + } + + StyledRect { + variant: resetBtnMouse.containsMouse ? "focus" : "common" + implicitWidth: 64 + implicitHeight: 32 + radius: Styling.radius(-2) + + Text { + anchors.centerIn: parent + text: "Reset" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.outline + } + + MouseArea { + id: resetBtnMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.resetToConfig() + } + } + } + } + } + } + } + } +} diff --git a/modules/widgets/dashboard/controls/SettingsIndex.qml b/modules/widgets/dashboard/controls/SettingsIndex.qml index 6a606409..972be8ec 100644 --- a/modules/widgets/dashboard/controls/SettingsIndex.qml +++ b/modules/widgets/dashboard/controls/SettingsIndex.qml @@ -207,7 +207,13 @@ QtObject { { label: "Desktop Text Color", keywords: "label font", section: 8, subSection: "desktop", subLabel: "Ambxst > Desktop", icon: Icons.palette, isIcon: true }, // Ambxst > System - { label: "Shell System", keywords: "config settings ambxst", section: 8, subSection: "system", subLabel: "Ambxst > System", icon: Icons.circuitry, isIcon: true } + { label: "Shell System", keywords: "config settings ambxst", section: 8, subSection: "system", subLabel: "Ambxst > System", icon: Icons.circuitry, isIcon: true }, + + // --- Calendar --- + { label: "Calendar", keywords: "events google caldav schedule sync reminder notifications", section: 9, subSection: "", subLabel: "", icon: Icons.calendarBlank, isIcon: true }, + { label: "Calendar Accounts", keywords: "google caldav nextcloud connect login oauth", section: 9, subSection: "accounts", subLabel: "Calendar > Accounts", icon: Icons.calendarBlank, isIcon: true }, + { label: "Calendar Sync", keywords: "interval refresh update", section: 9, subSection: "settings", subLabel: "Calendar > Settings", icon: Icons.sync, isIcon: true }, + { label: "Calendar Notifications", keywords: "reminder alert notify", section: 9, subSection: "settings", subLabel: "Calendar > Settings", icon: Icons.bell, isIcon: true } ] property var items: staticItems.concat(dynamicItems) diff --git a/modules/widgets/dashboard/controls/SettingsTab.qml b/modules/widgets/dashboard/controls/SettingsTab.qml index fcaeef56..9c631ff5 100644 --- a/modules/widgets/dashboard/controls/SettingsTab.qml +++ b/modules/widgets/dashboard/controls/SettingsTab.qml @@ -254,10 +254,16 @@ Rectangle { section: 8, isIcon: true }, + { + icon: Icons.calendarBlank, + label: "Calendar", + section: 9, + isIcon: true + }, { icon: Qt.resolvedUrl("../../../../assets/ambxst/ambxst-icon.svg"), label: "Ambxst", - section: 9, + section: 10, isIcon: false } ] @@ -593,8 +599,12 @@ Rectangle { section: 8 }, { - component: "ShellPanel.qml", + component: "CalendarPanel.qml", section: 9 + }, + { + component: "ShellPanel.qml", + section: 10 } ] diff --git a/modules/widgets/dashboard/widgets/WidgetsTab.qml b/modules/widgets/dashboard/widgets/WidgetsTab.qml index 18082e7d..998d17a5 100644 --- a/modules/widgets/dashboard/widgets/WidgetsTab.qml +++ b/modules/widgets/dashboard/widgets/WidgetsTab.qml @@ -13,11 +13,30 @@ import qs.config import "calendar" Rectangle { + id: root color: "transparent" implicitWidth: 600 implicitHeight: 750 property int leftPanelWidth: 0 + property int calDay: 0 + property int calMonth: 0 + property int calYear: 0 + + function resetCalendarState() { + calDay = 0; + calMonth = 0; + calYear = 0; + calendarWidget.clearSelection(); + } + + Connections { + target: GlobalStates + function onDashboardOpenChanged() { + if (!GlobalStates.dashboardOpen) + resetCalendarState(); + } + } RowLayout { anchors.fill: parent @@ -57,23 +76,172 @@ Rectangle { } Calendar { + id: calendarWidget Layout.fillWidth: true Layout.preferredHeight: width + onOpenCalendarSettings: { + GlobalStates.settingsWindowVisible = true; + Qt.callLater(() => { GlobalStates.settingsCurrentTab = 9; }); + } + onDaySelected: (d, m, y) => { + calDay = d; + calMonth = m; + calYear = y; + } } + // Today's events panel StyledRect { + id: todayEventsPanel + visible: CalendarService.hasAccounts && allEvents.length > 0 variant: "pane" + radius: Styling.radius(4) Layout.fillWidth: true - Layout.preferredHeight: 150 + implicitHeight: todayEventsCol.implicitHeight + 16 + + property var allEvents: CalendarService.todayEvents() + property var upcomingEvents: { + const now = new Date(); + return allEvents.filter(ev => { + if (ev.allDay) return true; + try { return new Date(ev.end || ev.start) >= now; } catch(e) { return true; } + }); + } + property var pastEvents: { + const now = new Date(); + return allEvents.filter(ev => { + if (ev.allDay) return false; + try { return new Date(ev.end || ev.start) < now; } catch(e) { return false; } + }); + } + + Connections { + target: CalendarService + function onEventsChanged() { todayEventsPanel.allEvents = CalendarService.todayEvents(); } + } + + component EventRow: RowLayout { + required property var modelData + Layout.fillWidth: true + spacing: 8 + Rectangle { + width: 3; height: 32; radius: 2 + color: CalendarService.calendarColor(modelData.calendarId) + Layout.alignment: Qt.AlignVCenter + } + ColumnLayout { + Layout.fillWidth: true + spacing: 1 + Text { + Layout.fillWidth: true + text: modelData.title || "Untitled" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-2) + font.weight: Font.Medium + color: Colors.overSurface + elide: Text.ElideRight + } + Text { + text: { + if (modelData.allDay) return "All day"; + const s = modelData.start || ""; + const e = modelData.end || ""; + const st = s.includes("T") ? s.split("T")[1].substring(0,5) : ""; + const et = e.includes("T") ? e.split("T")[1].substring(0,5) : ""; + return st + (et ? " – " + et : ""); + } + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + color: Colors.outline + } + } + } + + ColumnLayout { + id: todayEventsCol + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 8 + spacing: 4 + + // Upcoming section + Text { + visible: todayEventsPanel.upcomingEvents.length > 0 + text: "Upcoming" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + font.weight: Font.Medium + color: Colors.outline + Layout.topMargin: 2 + } + + Repeater { + model: todayEventsPanel.upcomingEvents + delegate: EventRow {} + } + + // Past section + Text { + visible: todayEventsPanel.pastEvents.length > 0 + text: "Past" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + font.weight: Font.Medium + color: Colors.outline + Layout.topMargin: todayEventsPanel.upcomingEvents.length > 0 ? 4 : 2 + opacity: 0.6 + } + + Repeater { + model: todayEventsPanel.pastEvents + delegate: Item { + required property var modelData + Layout.fillWidth: true + implicitHeight: pastRow.implicitHeight + opacity: 0.5 + EventRow { + id: pastRow + anchors.left: parent.left + anchors.right: parent.right + modelData: parent.modelData + } + } + } + } } } } } - // Notification History - NotificationHistory { + // Notification / Event detail area + Item { Layout.fillWidth: true Layout.fillHeight: true + + NotificationHistory { + anchors.fill: parent + opacity: root.calDay === 0 ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { + enabled: Config.animDuration > 0 + NumberAnimation { duration: Config.animDuration / 2; easing.type: Easing.OutCubic } + } + } + + EventDetailPanel { + anchors.fill: parent + opacity: root.calDay > 0 ? 1 : 0 + visible: opacity > 0 + day: root.calDay + month: root.calMonth + year: root.calYear + onClosed: { root.calDay = 0; calendarWidget.clearSelection(); } + Behavior on opacity { + enabled: Config.animDuration > 0 + NumberAnimation { duration: Config.animDuration / 2; easing.type: Easing.OutCubic } + } + } } // Circular controls column diff --git a/modules/widgets/dashboard/widgets/calendar/Calendar.qml b/modules/widgets/dashboard/widgets/calendar/Calendar.qml index 085d6930..956cfa49 100644 --- a/modules/widgets/dashboard/widgets/calendar/Calendar.qml +++ b/modules/widgets/dashboard/widgets/calendar/Calendar.qml @@ -1,8 +1,12 @@ +pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import QtQuick.Controls import qs.modules.theme import qs.modules.components +import qs.modules.services import qs.config +import qs.modules.globals import "layout.js" as CalendarLayout Item { @@ -20,15 +24,50 @@ Item { return (now.getDay() + 6) % 7; } - // Helper function to get localized day abbreviation + // Currently selected day for EventPopup + property int selectedDay: 0 + property int selectedMonth: 0 + property int selectedYear: 0 + property var selectedDayEvents: [] + + // Viewing month/year for passing to day buttons + readonly property int viewingMonth: viewingDate.getMonth() + 1 + readonly property int viewingYear: viewingDate.getFullYear() + + signal openCalendarSettings() + signal daySelected(int day, int month, int year) + + function clearSelection() { + selectedDay = 0; + selectedMonth = 0; + selectedYear = 0; + } + function getDayAbbrev(dayIndex) { - // Create a date for a known Monday (e.g., 2024-01-01 was a Monday) var d = new Date(2024, 0, 1 + dayIndex); var dayName = d.toLocaleDateString(Qt.locale(), "ddd"); - // Capitalize first letter and limit to 2 chars return (dayName.charAt(0).toUpperCase() + dayName.slice(1, 2)).replace(".", ""); } + function openDayPopup(dayData, item) { + const d = parseInt(dayData.day); + if (isNaN(d)) return; + // Determine actual month/year for this day cell + let m = root.viewingMonth; + let y = root.viewingYear; + if (dayData.today === -1) { + // Day from adjacent month — not current viewing month + if (d > 15) { m--; } else { m++; } + if (m < 1) { m = 12; y--; } + if (m > 12) { m = 1; y++; } + } + root.selectedDay = d; + root.selectedMonth = m; + root.selectedYear = y; + root.selectedDayEvents = CalendarService.eventsForDate(y, m, d); + root.daySelected(d, m, y); + } + ColumnLayout { anchors.fill: parent spacing: 0 @@ -51,6 +90,34 @@ Item { Layout.maximumHeight: 32 spacing: 4 + // Gear button for calendar settings + StyledRect { + id: gearButton + variant: gearMouseArea.pressed ? "primary" : (gearMouseArea.containsMouse ? "focus" : "internalbg") + Layout.preferredWidth: 32 + Layout.fillHeight: true + radius: Styling.radius(0) + visible: true + + readonly property color buttonItem: gearMouseArea.pressed ? itemColor : Styling.srItem("overprimary") + + Text { + anchors.centerIn: parent + text: Icons.gear + font.family: Icons.font + font.pixelSize: 14 + color: gearButton.buttonItem + } + + MouseArea { + id: gearMouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: root.openCalendarSettings() + cursorShape: Qt.PointingHandCursor + } + } + StyledRect { id: titleRect variant: "internalbg" @@ -177,8 +244,33 @@ Item { model: 7 delegate: CalendarDayButton { required property int index - day: calendarLayout[rowIndex][index].day - isToday: calendarLayout[rowIndex][index].today + readonly property var cellData: calendarLayout[rowIndex][index] + day: cellData.day + isToday: cellData.today + year: { + let y = root.viewingYear; + if (cellData.today === -1) { + let m = root.viewingMonth; + const d = parseInt(cellData.day); + if (d > 15) { m--; } else { m++; } + if (m < 1) y--; + if (m > 12) y++; + } + return y; + } + month: { + if (cellData.today === -1) { + let m = root.viewingMonth; + const d = parseInt(cellData.day); + if (d > 15) { m--; } else { m++; } + if (m < 1) m = 12; + if (m > 12) m = 1; + return m; + } + return root.viewingMonth; + } + isSelected: root.selectedDay > 0 && parseInt(cellData.day) === root.selectedDay && month === root.selectedMonth && year === root.selectedYear + onClicked: root.openDayPopup(cellData, this) } } } @@ -187,6 +279,8 @@ Item { } } } + + } } } diff --git a/modules/widgets/dashboard/widgets/calendar/CalendarDayButton.qml b/modules/widgets/dashboard/widgets/calendar/CalendarDayButton.qml index 4a99c804..cea88aec 100644 --- a/modules/widgets/dashboard/widgets/calendar/CalendarDayButton.qml +++ b/modules/widgets/dashboard/widgets/calendar/CalendarDayButton.qml @@ -1,7 +1,9 @@ +pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts import qs.modules.theme import qs.modules.components +import qs.modules.services import qs.config Rectangle { @@ -11,6 +13,23 @@ Rectangle { required property int isToday property bool bold: false property bool isCurrentDayOfWeek: false + property int year: 0 + property int month: 0 + property bool isHeaderDay: bold + property bool isSelected: false + + readonly property bool accountsConnected: CalendarService.hasAccounts + + signal clicked() + + readonly property var dayEvents: { + if (!accountsConnected) return []; + if (isHeaderDay || year === 0 || day === "") return []; + const d = parseInt(day); + if (isNaN(d)) return []; + return CalendarService.eventsForDate(year, month, d); + } + readonly property bool hasEvents: dayEvents.length > 0 Layout.fillWidth: true Layout.fillHeight: false @@ -20,7 +39,9 @@ Rectangle { color: "transparent" radius: Styling.radius(-2) + // Original layout — no accounts, static calendar StyledRect { + visible: !button.accountsConnected anchors.centerIn: parent width: Math.min(parent.width, parent.height) height: width @@ -53,5 +74,86 @@ Rectangle { } } } + + } + + // Enhanced layout — with accounts, event dots inside cell + StyledRect { + visible: button.accountsConnected + anchors.centerIn: parent + width: Math.min(parent.width, parent.height) + height: width + variant: (isToday === 1) ? "primary" : (dayMouseArea.containsMouse && !isHeaderDay ? "focus" : "transparent") + radius: button.radius + + Text { + anchors.centerIn: parent + text: day + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font.weight: Font.Bold + font.pixelSize: Styling.fontSize(-2) + font.family: Config.defaultFont + color: { + if (isToday === 1) + return Styling.srItem("primary"); + if (bold) { + return isCurrentDayOfWeek ? Colors.overBackground : Colors.outline; + } + if (isToday === 0) + return Colors.overSurface; + return Colors.surfaceBright; + } + + Behavior on color { + enabled: Config.animDuration > 0 + ColorAnimation { duration: 150 } + } + } + + // Event indicator dots — overlay at bottom of cell + Row { + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: 2 + spacing: 2 + visible: button.hasEvents && !button.isHeaderDay + + Repeater { + model: Math.min(button.dayEvents.length, 3) + delegate: Rectangle { + required property int index + width: 3 + height: 3 + radius: 2 + color: { + const ev = button.dayEvents[index]; + if (!ev) return Colors.primary; + return CalendarService.calendarColor(ev.calendarId); + } + } + } + } + + MouseArea { + id: dayMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: isHeaderDay ? Qt.ArrowCursor : Qt.PointingHandCursor + onClicked: if (!isHeaderDay) button.clicked() + } + } + + // Selection border — sibling of StyledRects, not clipped by them + Rectangle { + anchors.centerIn: parent + width: Math.min(parent.width, parent.height) + height: width + color: "transparent" + border.color: Colors.primary + border.width: 1.5 + radius: Styling.radius(-2) + visible: button.isSelected + opacity: 0.7 } } diff --git a/modules/widgets/dashboard/widgets/calendar/EventDetailPanel.qml b/modules/widgets/dashboard/widgets/calendar/EventDetailPanel.qml new file mode 100644 index 00000000..906a7f3e --- /dev/null +++ b/modules/widgets/dashboard/widgets/calendar/EventDetailPanel.qml @@ -0,0 +1,1071 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.modules.theme +import qs.modules.components +import qs.modules.services +import qs.config + +Item { + id: root + + // i18n helper — works with or without the I18n singleton + function _t(key, fallback) { + let str; + try { str = I18n.t(key); } catch(e) { str = fallback; } + for (let i = 2; i < arguments.length; i++) + str = str.replace("%" + (i - 1), arguments[i]); + return str; + } + + property int day: 0 + property int month: 0 + property int year: 0 + property var dayEvents: [] + + property bool editing: false + property bool creating: false + property var editingEvent: null + + signal closed() + + onDayChanged: refreshEvents() + onMonthChanged: refreshEvents() + onYearChanged: refreshEvents() + + function refreshEvents() { + dayEvents = CalendarService.eventsForDate(year, month, day); + } + + Connections { + target: CalendarService + function onEventsChanged() { root.refreshEvents(); } + } + + readonly property var enabledCalendars: { + let result = []; + for (let i = 0; i < CalendarService.calendars.length; i++) { + if (CalendarService.calendars[i].enabled !== false) + result.push(CalendarService.calendars[i]); + } + return result; + } + + function startCreate() { + const dateStr = year + "-" + + String(month).padStart(2, "0") + "-" + + String(day).padStart(2, "0"); + editingEvent = { + calendarId: enabledCalendars.length > 0 ? enabledCalendars[0].id : "", + title: "", + description: "", + start: dateStr + "T09:00:00", + end: dateStr + "T10:00:00", + allDay: false, + reminder: CalendarService.defaultReminder, + }; + creating = true; + editing = true; + } + + function startEdit(event) { + editingEvent = JSON.parse(JSON.stringify(event)); + creating = false; + editing = true; + } + + property string saveError: "" + property bool saving: false + + function saveEvent() { + if (!editingEvent) return; + if (!editingEvent.title || editingEvent.title.trim() === "") { + root.saveError = root._t("calendar.form.error_title_required", "Title is required"); + return; + } + if (!editingEvent.start || !editingEvent.end) { + root.saveError = root._t("calendar.form.error_time_required", "Start and end time are required"); + return; + } + if (!editingEvent.allDay && editingEvent.end <= editingEvent.start) { + root.saveError = root._t("calendar.form.error_end_after_start", "End time must be after start"); + return; + } + root.saveError = ""; + root.saving = true; + saveTimeout.restart(); + if (creating) { + CalendarService.createEvent(editingEvent); + } else { + CalendarService.updateEvent(editingEvent); + } + } + + function deleteEvent() { + if (!editingEvent || !editingEvent.id) return; + root.saving = true; + saveTimeout.restart(); + CalendarService.deleteEvent(editingEvent.calendarId, editingEvent.id); + } + + // Timeout: if Python doesn't respond within 15s, show error + Timer { + id: saveTimeout + interval: 15000 + repeat: false + onTriggered: { + root.saving = false; + root.saveError = root._t("calendar.form.error_timeout", "Request timed out — check your connection"); + } + } + + Connections { + target: CalendarService + function onOperationResult(success, message) { + if (!root.saving) return; + saveTimeout.stop(); + root.saving = false; + if (success) { + root.saveError = ""; + root.editing = false; + root.creating = false; + root.editingEvent = null; + Qt.callLater(root.refreshEvents); + } else { + root.saveError = message || root._t("calendar.form.error_operation_failed", "Operation failed"); + } + } + } + + function timeHours(dtStr) { + if (!dtStr || !dtStr.includes("T")) return 9; + return parseInt(dtStr.split("T")[1].split(":")[0]) || 0; + } + function timeMinutes(dtStr) { + if (!dtStr || !dtStr.includes("T")) return 0; + return parseInt(dtStr.split("T")[1].split(":")[1]) || 0; + } + function setEventTime(isStart, h, m) { + if (!root.editingEvent) return; + const pad = n => String(n).padStart(2, "0"); + const timeStr = pad(h) + ":" + pad(m) + ":00"; + // Extract date portion safely regardless of whether "T" is present + const datePart = s => (s || "").includes("T") ? s.split("T")[0] : (s || "").substring(0, 10); + try { + // Clone the object so QML detects the property change and re-evaluates bindings + const ev = Object.assign({}, root.editingEvent); + if (isStart) { + ev.start = datePart(ev.start) + "T" + timeStr; + // Auto-set end = start + 30min if end is at or before new start + const startTotal = h * 60 + m; + const endTotal = root.timeHours(ev.end) * 60 + root.timeMinutes(ev.end); + if (endTotal <= startTotal) { + const newEndTotal = (startTotal + 30) % (24 * 60); + ev.end = datePart(ev.end) + "T" + pad(Math.floor(newEndTotal / 60)) + ":" + pad(newEndTotal % 60) + ":00"; + } + } else { + ev.end = datePart(ev.end) + "T" + timeStr; + } + root.editingEvent = ev; + } catch (e) { + console.warn("setEventTime error:", e); + } + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + // ── Header ── + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Layout.leftMargin: 8 + Layout.rightMargin: 8 + Layout.topMargin: 8 + spacing: 6 + + // Back button — cancel edit → list, list → closed + StyledRect { + variant: backMouse.containsMouse ? "focus" : "common" + implicitWidth: 28 + implicitHeight: 28 + radius: Styling.radius(-2) + + Text { + anchors.centerIn: parent + text: Icons.arrowLeft + font.family: Icons.font + font.pixelSize: 14 + color: Colors.overBackground + } + + MouseArea { + id: backMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (root.editing) { + root.editing = false; + root.creating = false; + root.editingEvent = null; + } else { + root.closed(); + } + } + } + } + + // Date info column + ColumnLayout { + spacing: 1 + + Text { + text: { + if (root.day <= 0) return ""; + const d = new Date(root.year, root.month - 1, root.day); + return d.toLocaleDateString(Qt.locale(), "d MMMM yyyy"); + } + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(0) + font.weight: Font.DemiBold + color: Colors.overBackground + elide: Text.ElideRight + } + + Text { + text: { + if (root.day <= 0) return ""; + const d = new Date(root.year, root.month - 1, root.day); + const dayName = d.toLocaleDateString(Qt.locale(), "dddd"); + const count = root.dayEvents.length; + return dayName + " · " + count + " " + root._t(count === 1 ? "calendar.form.event_singular" : "calendar.form.events_plural", count === 1 ? "event" : "events"); + } + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + color: Colors.outline + elide: Text.ElideRight + } + } + + Item { Layout.fillWidth: true } + + // Close button — always visible, always exits to calendar + StyledRect { + variant: closeMouse.containsMouse ? "focus" : "transparent" + implicitWidth: 28 + implicitHeight: 28 + radius: Styling.radius(-2) + + Text { + anchors.centerIn: parent + text: "✕" + font.pixelSize: 13 + color: Colors.outline + } + + MouseArea { + id: closeMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.editing = false; + root.creating = false; + root.editingEvent = null; + root.closed(); + } + } + } + + // Add button (list mode) + StyledRect { + visible: !root.editing && root.enabledCalendars.length > 0 + variant: addMouse.containsMouse ? "primaryfocus" : "primary" + implicitWidth: addText.implicitWidth + 16 + implicitHeight: 28 + radius: Styling.radius(-2) + + Text { + id: addText + anchors.centerIn: parent + text: root._t("calendar.form.add_event", "+ Add") + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-2) + font.weight: Font.DemiBold + color: Colors.primary.hslLightness > 0.5 ? "black" : "white" + } + + MouseArea { + id: addMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.startCreate() + } + } + } + + Separator { + Layout.fillWidth: true + vert: false + } + + // ── Scrollable content ── + Flickable { + Layout.fillWidth: true + Layout.fillHeight: true + contentHeight: contentCol.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + + ColumnLayout { + id: contentCol + width: parent.width + spacing: 4 + + // ── List mode ── + ColumnLayout { + Layout.fillWidth: true + Layout.margins: 8 + spacing: 4 + visible: !root.editing + + Repeater { + model: root.dayEvents + + EventItem { + required property var modelData + required property int index + Layout.fillWidth: true + event: modelData + onEditRequested: root.startEdit(modelData) + } + } + + Text { + visible: root.dayEvents.length === 0 + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.bottomMargin: 8 + text: CalendarService.hasAccounts ? root._t("calendar.form.no_events", "No events") : root._t("calendar.form.no_calendar", "No calendar connected") + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-2) + color: Colors.outline + horizontalAlignment: Text.AlignHCenter + } + } + + // ── Edit/Create form ── + ColumnLayout { + Layout.fillWidth: true + Layout.margins: 12 + spacing: 10 + visible: root.editing && root.editingEvent !== null + + // Title + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + Text { + text: root._t("calendar.form.label_title", "Title") + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + color: Colors.outline + } + + TextField { + id: titleField + Layout.fillWidth: true + implicitHeight: 36 + leftPadding: 8; rightPadding: 8 + topPadding: 0; bottomPadding: 0 + verticalAlignment: TextInput.AlignVCenter + text: root.editingEvent ? root.editingEvent.title : "" + placeholderText: root._t("calendar.form.placeholder_title", "Event title") + placeholderTextColor: Colors.outline + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + selectByMouse: true + background: Item { + StyledRect { + anchors.fill: parent + variant: "common" + radius: Styling.radius(-2) + enableBorder: false + } + Rectangle { + anchors.fill: parent + color: "transparent" + border.color: titleField.activeFocus ? Colors.primary : Colors.outlineVariant + border.width: 1 + radius: Styling.radius(-2) + } + } + onTextChanged: if (root.editingEvent) root.editingEvent.title = text + } + } + + // Start / End time + // SpinnerField: up/down arrows + direct text input + mouse wheel. + // + // Design notes: + // • No IntValidator — it blocks keystrokes when typing mid-value (e.g. "09"→"091") + // instead we use a digits-only regex and clamp in JS on commit. + // • selectAll() on focus-gain so any keystroke immediately replaces the whole value. + // • Qt.callLater for text reset so it runs after QML binding re-evaluation. + // • _busy guard prevents double-fire from onEditingFinished + onActiveFocusChanged. + // • External value changes (arrows, wheel, other field) sync via onSpinValueChanged + // only when the TextInput is not focused. + component SpinnerField: ColumnLayout { + id: spinnerField + + // Driven by a parent binding; never written from inside the component. + required property int spinValue + required property int spinMax // 23 for hours, 59 for minutes + + signal commit(int newValue) + + spacing: 0 + + // ── helpers ──────────────────────────────────────────────────── + function _wrap(v) { + const size = spinnerField.spinMax + 1; + return ((v % size) + size) % size; + } + function _formatted(v) { + return String(v).padStart(2, "0"); + } + + // ── up arrow ─────────────────────────────────────────────────── + StyledRect { + implicitWidth: 28; implicitHeight: 16; radius: Styling.radius(-4) + variant: spinUpMouse.containsMouse ? "focus" : "transparent" + Text { + anchors.centerIn: parent + text: Icons.caretUp; font.family: Icons.font + font.pixelSize: 9; color: Colors.outline + } + MouseArea { + id: spinUpMouse + anchors.fill: parent; hoverEnabled: true + cursorShape: Qt.PointingHandCursor + preventStealing: true + onClicked: spinnerField.commit(spinnerField._wrap(spinnerField.spinValue + 1)) + } + } + + // ── editable value ───────────────────────────────────────────── + TextInput { + id: spinInput + Layout.alignment: Qt.AlignHCenter + width: 26 + horizontalAlignment: TextInput.AlignHCenter + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(0) + font.weight: Font.Medium + color: Colors.overBackground + selectByMouse: true + maximumLength: 2 + // Digits only — allows any 1-2 digit intermediate value without blocking + validator: RegularExpressionValidator { regularExpression: /^\d{0,2}$/ } + + // Guard: prevents onEditingFinished + onActiveFocusChanged double-fire + property bool _busy: false + + // ── sync external → display ─────────────────────────────── + Component.onCompleted: { + spinInput.text = spinnerField._formatted(spinnerField.spinValue); + } + // Reactive binding to spinValue — fires whenever spinValue changes + // (arrow click, wheel, or the parent's editingEvent binding re-evaluates). + // Only update text while the user is NOT editing. + readonly property int externalValue: spinnerField.spinValue + onExternalValueChanged: { + if (!spinInput.activeFocus) + spinInput.text = spinnerField._formatted(spinnerField.spinValue); + } + + // ── commit typed value ──────────────────────────────────── + function applyTyped() { + if (spinInput._busy) return; + spinInput._busy = true; + + const digits = spinInput.text.replace(/\D/g, ""); + const v = digits.length > 0 ? parseInt(digits, 10) : NaN; + + if (!isNaN(v) && v >= 0 && v <= spinnerField.spinMax) { + spinnerField.commit(v); + } + + // Defer text normalisation so QML binding on spinValue + // has time to re-evaluate after commit → setEventTime. + Qt.callLater(function() { + spinInput.text = spinnerField._formatted(spinnerField.spinValue); + spinInput._busy = false; + }); + } + + // ── focus events ────────────────────────────────────────── + onActiveFocusChanged: { + if (activeFocus) { + // Select all so the first keystroke replaces the value outright + spinInput.selectAll(); + } else { + applyTyped(); + } + } + // Enter / Return key — onEditingFinished fires here too + onEditingFinished: applyTyped() + + // ── mouse wheel ─────────────────────────────────────────── + WheelHandler { + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + onWheel: event => { + const delta = event.angleDelta.y > 0 ? 1 : -1; + spinnerField.commit(spinnerField._wrap(spinnerField.spinValue + delta)); + } + } + } + + // ── down arrow ───────────────────────────────────────────────── + StyledRect { + implicitWidth: 28; implicitHeight: 16; radius: Styling.radius(-4) + variant: spinDnMouse.containsMouse ? "focus" : "transparent" + Text { + anchors.centerIn: parent + text: Icons.caretDown; font.family: Icons.font + font.pixelSize: 9; color: Colors.outline + } + MouseArea { + id: spinDnMouse + anchors.fill: parent; hoverEnabled: true + cursorShape: Qt.PointingHandCursor + preventStealing: true + onClicked: spinnerField.commit(spinnerField._wrap(spinnerField.spinValue - 1)) + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + // Start time picker + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + Text { + text: root._t("calendar.form.label_start", "Start") + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + color: Colors.outline + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: 64 + variant: "common" + radius: Styling.radius(-2) + + RowLayout { + anchors.centerIn: parent + spacing: 4 + + SpinnerField { + spinValue: root.editingEvent ? root.timeHours(root.editingEvent.start) : 9 + spinMax: 23 + onCommit: v => root.setEventTime(true, v, root.timeMinutes(root.editingEvent?.start)) + } + + Text { text: ":"; font.family: Config.defaultFont; font.pixelSize: Styling.fontSize(0); color: Colors.outline; bottomPadding: 2 } + + SpinnerField { + spinValue: root.editingEvent ? root.timeMinutes(root.editingEvent.start) : 0 + spinMax: 59 + onCommit: v => root.setEventTime(true, root.timeHours(root.editingEvent?.start), v) + } + } + } + } + + // End time picker + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + Text { + text: root._t("calendar.form.label_end", "End") + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + color: Colors.outline + } + + StyledRect { + Layout.fillWidth: true + implicitHeight: 64 + variant: "common" + radius: Styling.radius(-2) + + RowLayout { + anchors.centerIn: parent + spacing: 4 + + SpinnerField { + spinValue: root.editingEvent ? root.timeHours(root.editingEvent.end) : 10 + spinMax: 23 + onCommit: v => root.setEventTime(false, v, root.timeMinutes(root.editingEvent?.end)) + } + + Text { text: ":"; font.family: Config.defaultFont; font.pixelSize: Styling.fontSize(0); color: Colors.outline; bottomPadding: 2 } + + SpinnerField { + spinValue: root.editingEvent ? root.timeMinutes(root.editingEvent.end) : 0 + spinMax: 59 + onCommit: v => root.setEventTime(false, root.timeHours(root.editingEvent?.end), v) + } + } + } + } + } + + // Calendar selector + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + Text { + text: root._t("calendar.form.label_calendar", "Calendar") + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + color: Colors.outline + } + + ComboBox { + id: calSelector + Layout.fillWidth: true + implicitHeight: 36 + model: root.enabledCalendars + textRole: "name" + valueRole: "id" + currentIndex: { + if (!root.editingEvent) return 0; + for (let i = 0; i < root.enabledCalendars.length; i++) { + if (root.enabledCalendars[i].id === root.editingEvent.calendarId) + return i; + } + return 0; + } + onCurrentValueChanged: { + if (!root.editingEvent || !currentValue) return; + const isGoogle = CalendarService.calendarProvider(currentValue) === "google"; + root.editingEvent = Object.assign({}, root.editingEvent, { + calendarId: currentValue, + meetLink: isGoogle ? (root.editingEvent.meetLink || "") : "" + }); + } + background: Rectangle { + color: calSelector.hovered ? Colors.surfaceContainerHigh : Colors.surfaceContainer + radius: Styling.radius(-2) + border.color: Colors.outlineVariant + border.width: 1 + } + contentItem: Text { + leftPadding: 10 + rightPadding: 32 + text: calSelector.displayText + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + indicator: Text { + x: calSelector.width - width - 10 + anchors.verticalCenter: parent.verticalCenter + text: Icons.caretDown + font.family: Icons.font + font.pixelSize: 14 + color: Colors.overBackground + } + popup: Popup { + y: calSelector.height + 4 + width: calSelector.width + padding: 4 + background: Rectangle { + color: Colors.surfaceContainerLow + radius: Styling.radius(-1) + border.color: Colors.outlineVariant + border.width: 1 + } + contentItem: ListView { + clip: true + implicitHeight: Math.min(contentHeight, 200) + model: calSelector.popup.visible ? calSelector.delegateModel : null + currentIndex: calSelector.highlightedIndex + ScrollIndicator.vertical: ScrollIndicator {} + } + } + delegate: ItemDelegate { + id: calDelegate + required property var modelData + required property int index + width: ListView.view.width + height: 32 + highlighted: calSelector.highlightedIndex === index + background: Rectangle { + color: calDelegate.highlighted ? Colors.surfaceContainerHigh : "transparent" + radius: Styling.radius(-2) + } + contentItem: RowLayout { + spacing: 8 + Rectangle { + width: 10; height: 10; radius: 2 + color: calDelegate.modelData.color || Colors.primary + Layout.leftMargin: 8 + } + Text { + text: calDelegate.modelData.name || "" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + verticalAlignment: Text.AlignVCenter + Layout.fillWidth: true + } + } + } + } + } + + // Reminder selector + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + Text { + text: root._t("calendar.form.label_reminder", "Reminder") + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + color: Colors.outline + } + + ComboBox { + id: reminderSelector + Layout.fillWidth: true + implicitHeight: 36 + model: [ + { text: root._t("calendar.form.reminder_none", "None"), value: 0 }, + { text: root._t("calendar.form.reminder_minutes", "%1 min", 5), value: 5 }, + { text: root._t("calendar.form.reminder_minutes", "%1 min", 10), value: 10 }, + { text: root._t("calendar.form.reminder_minutes", "%1 min", 15), value: 15 }, + { text: root._t("calendar.form.reminder_minutes", "%1 min", 30), value: 30 }, + { text: root._t("calendar.form.reminder_1hour", "1 hour"), value: 60 }, + { text: root._t("calendar.form.reminder_1day", "1 day"), value: 1440 }, + ] + textRole: "text" + valueRole: "value" + currentIndex: { + if (!root.editingEvent) return 3; + const r = root.editingEvent.reminder || 0; + const vals = [0, 5, 10, 15, 30, 60, 1440]; + const idx = vals.indexOf(r); + return idx >= 0 ? idx : 3; + } + onCurrentValueChanged: { + if (root.editingEvent && currentValue !== undefined) + root.editingEvent.reminder = currentValue; + } + background: Rectangle { + color: reminderSelector.hovered ? Colors.surfaceContainerHigh : Colors.surfaceContainer + radius: Styling.radius(-2) + border.color: Colors.outlineVariant + border.width: 1 + } + contentItem: Text { + leftPadding: 10 + rightPadding: 32 + text: reminderSelector.displayText + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + indicator: Text { + x: reminderSelector.width - width - 10 + anchors.verticalCenter: parent.verticalCenter + text: Icons.caretDown + font.family: Icons.font + font.pixelSize: 14 + color: Colors.overBackground + } + popup: Popup { + y: reminderSelector.height + 4 + width: reminderSelector.width + padding: 4 + background: Rectangle { + color: Colors.surfaceContainerLow + radius: Styling.radius(-1) + border.color: Colors.outlineVariant + border.width: 1 + } + contentItem: ListView { + clip: true + implicitHeight: Math.min(contentHeight, 200) + model: reminderSelector.popup.visible ? reminderSelector.delegateModel : null + currentIndex: reminderSelector.highlightedIndex + ScrollIndicator.vertical: ScrollIndicator {} + } + } + delegate: ItemDelegate { + id: reminderDelegate + required property var modelData + required property int index + width: ListView.view.width + height: 32 + highlighted: reminderSelector.highlightedIndex === index + background: Rectangle { + color: reminderDelegate.highlighted ? Colors.surfaceContainerHigh : "transparent" + radius: Styling.radius(-2) + } + contentItem: Text { + leftPadding: 8 + text: reminderDelegate.modelData.text || "" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + verticalAlignment: Text.AlignVCenter + } + } + } + } + + // Description + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + Text { + text: root._t("calendar.form.label_description", "Description") + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + color: Colors.outline + } + + TextField { + id: descField + Layout.fillWidth: true + implicitHeight: 36 + leftPadding: 8; rightPadding: 8 + topPadding: 0; bottomPadding: 0 + verticalAlignment: TextInput.AlignVCenter + text: root.editingEvent ? (root.editingEvent.description || "") : "" + placeholderText: root._t("calendar.form.placeholder_description", "Optional...") + placeholderTextColor: Colors.outline + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + selectByMouse: true + background: Item { + StyledRect { + anchors.fill: parent + variant: "common" + radius: Styling.radius(-2) + enableBorder: false + } + Rectangle { + anchors.fill: parent + color: "transparent" + border.color: descField.activeFocus ? Colors.primary : Colors.outlineVariant + border.width: 1 + radius: Styling.radius(-2) + } + } + onTextChanged: if (root.editingEvent) root.editingEvent.description = text + } + } + + // Location / Link + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + Text { + text: root._t("calendar.form.label_location", "Location / Link") + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + color: Colors.outline + } + + TextField { + id: locationField + Layout.fillWidth: true + implicitHeight: 36 + leftPadding: 8; rightPadding: 8 + topPadding: 0; bottomPadding: 0 + verticalAlignment: TextInput.AlignVCenter + text: root.editingEvent ? (root.editingEvent.location || "") : "" + placeholderText: root._t("calendar.form.placeholder_location", "URL or address...") + placeholderTextColor: Colors.outline + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + selectByMouse: true + background: Item { + StyledRect { + anchors.fill: parent + variant: "common" + radius: Styling.radius(-2) + enableBorder: false + } + Rectangle { + anchors.fill: parent + color: "transparent" + border.color: locationField.activeFocus ? Colors.primary : Colors.outlineVariant + border.width: 1 + radius: Styling.radius(-2) + } + } + onTextChanged: if (root.editingEvent) root.editingEvent.location = text + } + } + + // Google Meet — only for Google calendars + RowLayout { + Layout.fillWidth: true + visible: root.editingEvent ? CalendarService.calendarProvider(root.editingEvent.calendarId) === "google" : false + + Text { + text: root._t("calendar.form.label_meet", "Google Meet") + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + color: Colors.outline + Layout.fillWidth: true + } + + StyledRect { + id: meetToggle + readonly property bool hasMeet: root.editingEvent ? (root.editingEvent.meetLink || "") !== "" : false + variant: meetMouse.containsMouse ? (hasMeet ? "focus" : "primaryfocus") : (hasMeet ? "common" : "primary") + implicitWidth: meetLabel.implicitWidth + 20 + implicitHeight: 28 + radius: Styling.radius(-2) + + Text { + id: meetLabel + anchors.centerIn: parent + text: meetToggle.hasMeet ? root._t("calendar.form.meet_remove", "Remove Meet") : root._t("calendar.form.meet_add", "+ Add Meet") + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-2) + font.weight: Font.Medium + color: meetToggle.hasMeet ? Colors.outline : (Colors.primary.hslLightness > 0.5 ? "black" : "white") + } + + MouseArea { + id: meetMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + preventStealing: true + onClicked: { + if (!root.editingEvent) return; + if (meetToggle.hasMeet) { + root.editingEvent.meetLink = ""; + } else { + root.editingEvent.meetLink = "request"; + } + // force property re-read + root.editingEvent = Object.assign({}, root.editingEvent); + } + } + } + } + + } + } + } + + // ── Action footer (outside Flickable to avoid first-click swallowed by flick gesture) ── + ColumnLayout { + visible: root.editing + Layout.fillWidth: true + Layout.margins: 8 + Layout.topMargin: 4 + spacing: 6 + + // Validation error + Text { + visible: root.saveError !== "" + text: root.saveError + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + color: "#e06c75" + Layout.fillWidth: true + wrapMode: Text.WordWrap + } + + // Action buttons + RowLayout { + Layout.fillWidth: true + spacing: 8 + + StyledRect { + Layout.fillWidth: true + variant: (!root.saving && saveMouse.containsMouse) ? "primaryfocus" : "primary" + implicitHeight: 32 + radius: Styling.radius(-2) + opacity: root.saving ? 0.6 : 1.0 + + Text { + anchors.centerIn: parent + text: root.saving ? root._t("calendar.form.saving", "Saving…") : root._t("calendar.form.save", "Save") + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + font.weight: Font.DemiBold + color: Colors.primary.hslLightness > 0.5 ? "black" : "white" + } + + MouseArea { + id: saveMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: root.saving ? Qt.ArrowCursor : Qt.PointingHandCursor + preventStealing: true + onClicked: if (!root.saving) root.saveEvent() + } + } + + StyledRect { + visible: !root.creating + variant: (!root.saving && deleteMouse.containsMouse) ? "focus" : "common" + implicitWidth: 64 + implicitHeight: 32 + radius: Styling.radius(-2) + opacity: root.saving ? 0.6 : 1.0 + + Text { + anchors.centerIn: parent + text: root._t("calendar.form.delete", "Delete") + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.red + } + + MouseArea { + id: deleteMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: root.saving ? Qt.ArrowCursor : Qt.PointingHandCursor + preventStealing: true + onClicked: if (!root.saving) root.deleteEvent() + } + } + } + } + } +} diff --git a/modules/widgets/dashboard/widgets/calendar/EventItem.qml b/modules/widgets/dashboard/widgets/calendar/EventItem.qml new file mode 100644 index 00000000..d4acca81 --- /dev/null +++ b/modules/widgets/dashboard/widgets/calendar/EventItem.qml @@ -0,0 +1,144 @@ +import QtQuick +import QtQuick.Layouts +import qs.modules.theme +import qs.modules.components +import qs.modules.services +import qs.config + +StyledRect { + id: root + + required property var event + signal editRequested() + + variant: itemMouse.containsMouse ? "focus" : "common" + radius: Styling.radius(-2) + implicitHeight: itemContent.implicitHeight + 16 + + RowLayout { + id: itemContent + anchors.fill: parent + anchors.margins: 8 + spacing: 8 + + // Calendar color bar + Rectangle { + Layout.preferredWidth: 3 + Layout.fillHeight: true + Layout.topMargin: 2 + Layout.bottomMargin: 2 + radius: 2 + color: CalendarService.calendarColor(root.event.calendarId) + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Text { + Layout.fillWidth: true + text: root.event.title || "Untitled" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + font.weight: Font.Medium + color: Colors.overSurface + elide: Text.ElideRight + } + + Text { + Layout.fillWidth: true + text: { + let parts = []; + if (root.event.allDay) { + parts.push("All day"); + } else { + const s = root.event.start || ""; + const e = root.event.end || ""; + const startTime = s.includes("T") ? s.split("T")[1].substring(0, 5) : ""; + const endTime = e.includes("T") ? e.split("T")[1].substring(0, 5) : ""; + if (startTime) parts.push(startTime + (endTime ? " – " + endTime : "")); + } + const calName = CalendarService.calendarName(root.event.calendarId); + if (calName) parts.push(calName); + return parts.join(" · "); + } + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + color: Colors.outline + elide: Text.ElideRight + } + + // Link buttons row + RowLayout { + Layout.fillWidth: true + spacing: 4 + visible: (root.event.location || "") !== "" || (root.event.meetLink || "") !== "" + + StyledRect { + visible: (root.event.meetLink || "") !== "" && root.event.meetLink !== "request" + variant: meetBtnMouse.containsMouse ? "primaryfocus" : "primary" + implicitWidth: meetBtnText.implicitWidth + 12 + implicitHeight: 20 + radius: Styling.radius(-4) + + Text { + id: meetBtnText + anchors.centerIn: parent + text: "Meet" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-4) + font.weight: Font.Medium + color: Colors.primary.hslLightness > 0.5 ? "black" : "white" + } + + MouseArea { + id: meetBtnMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + const link = root.event.meetLink || ""; + if (link.startsWith("https://")) Qt.openUrlExternally(link); + } + } + } + + StyledRect { + visible: (root.event.location || "") !== "" + variant: linkBtnMouse.containsMouse ? "focus" : "common" + implicitWidth: linkBtnText.implicitWidth + 12 + implicitHeight: 20 + radius: Styling.radius(-4) + + Text { + id: linkBtnText + anchors.centerIn: parent + text: (root.event.location || "").startsWith("http") ? "Link" : "Location" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-4) + color: Colors.overSurface + } + + MouseArea { + id: linkBtnMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + const loc = root.event.location || ""; + if (loc.startsWith("http")) Qt.openUrlExternally(loc); + } + } + } + } + } + } + + MouseArea { + id: itemMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.editRequested() + } +} diff --git a/modules/widgets/dashboard/widgets/calendar/EventPopup.qml b/modules/widgets/dashboard/widgets/calendar/EventPopup.qml new file mode 100644 index 00000000..14165623 --- /dev/null +++ b/modules/widgets/dashboard/widgets/calendar/EventPopup.qml @@ -0,0 +1,731 @@ +pragma ComponentBehavior: Bound +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.modules.theme +import qs.modules.components +import qs.modules.services +import qs.config + +Popup { + id: root + + property int day: 0 + property int month: 0 + property int year: 0 + property var dayEvents: [] + + // Edit mode state + property bool editing: false + property bool creating: false + property var editingEvent: null + + width: 320 + height: contentColumn.implicitHeight + 24 + padding: 0 + modal: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + onOpened: { + editing = false; + creating = false; + editingEvent = null; + refreshEvents(); + } + + function refreshEvents() { + dayEvents = CalendarService.eventsForDate(year, month, day); + } + + Connections { + target: CalendarService + function onEventsChanged() { + if (root.opened) root.refreshEvents(); + } + } + + readonly property var enabledCalendars: { + let result = []; + for (let i = 0; i < CalendarService.calendars.length; i++) { + if (CalendarService.calendars[i].enabled !== false) + result.push(CalendarService.calendars[i]); + } + return result; + } + + function startCreate() { + const dateStr = year + "-" + + String(month).padStart(2, "0") + "-" + + String(day).padStart(2, "0"); + editingEvent = { + calendarId: enabledCalendars.length > 0 ? enabledCalendars[0].id : "", + title: "", + description: "", + start: dateStr + "T09:00:00", + end: dateStr + "T10:00:00", + allDay: false, + reminder: CalendarService.defaultReminder, + }; + creating = true; + editing = true; + } + + function startEdit(event) { + editingEvent = JSON.parse(JSON.stringify(event)); + creating = false; + editing = true; + } + + function saveEvent() { + if (!editingEvent) return; + if (creating) { + CalendarService.createEvent(editingEvent); + } else { + CalendarService.updateEvent(editingEvent); + } + editing = false; + creating = false; + editingEvent = null; + Qt.callLater(refreshEvents); + } + + function deleteEvent() { + if (!editingEvent || !editingEvent.id) return; + CalendarService.deleteEvent(editingEvent.calendarId, editingEvent.id); + editing = false; + creating = false; + editingEvent = null; + Qt.callLater(refreshEvents); + } + + background: StyledRect { + variant: "bg" + radius: Styling.radius(2) + border.width: 1 + border.color: Colors.surfaceBright + } + + contentItem: ColumnLayout { + id: contentColumn + width: root.width + spacing: 0 + + // ── Header ── + Item { + Layout.fillWidth: true + Layout.preferredHeight: headerCol.implicitHeight + Layout.margins: 12 + Layout.bottomMargin: 8 + + // Date info (list mode) + ColumnLayout { + id: headerCol + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + visible: !root.editing + + Text { + text: { + const d = new Date(root.year, root.month - 1, root.day); + return d.toLocaleDateString(Qt.locale(), "d MMMM yyyy"); + } + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(1) + font.weight: Font.DemiBold + color: Colors.overBackground + } + + Text { + text: { + const d = new Date(root.year, root.month - 1, root.day); + const dayName = d.toLocaleDateString(Qt.locale(), "dddd"); + const count = root.dayEvents.length; + return dayName + " · " + count + (count === 1 ? " event" : " events"); + } + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + color: Colors.outline + } + } + + // Title (edit mode) + Text { + visible: root.editing + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: root.creating ? "New Event" : "Edit Event" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(1) + font.weight: Font.DemiBold + color: Colors.overBackground + } + + // Add button (list mode) — anchored right + StyledRect { + visible: !root.editing && root.enabledCalendars.length > 0 + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + variant: addMouse.containsMouse ? "primaryfocus" : "primary" + implicitWidth: addText.implicitWidth + 16 + implicitHeight: 28 + radius: Styling.radius(-2) + + Text { + id: addText + anchors.centerIn: parent + text: "+ Add" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-2) + font.weight: Font.DemiBold + color: Colors.primary.hslLightness > 0.5 ? "black" : "white" + } + + MouseArea { + id: addMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.startCreate() + } + } + + // Close button (edit mode) — anchored right + StyledRect { + visible: root.editing + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + variant: closeMouse.containsMouse ? "focus" : "common" + implicitWidth: 28 + implicitHeight: 28 + radius: Styling.radius(-2) + + Text { + anchors.centerIn: parent + text: "✕" + font.pixelSize: 14 + color: Colors.outline + } + + MouseArea { + id: closeMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { root.editing = false; root.creating = false; } + } + } + } + + Separator { + Layout.fillWidth: true + Layout.leftMargin: 8 + Layout.rightMargin: 8 + vert: false + } + + // ── List mode ── + ColumnLayout { + Layout.fillWidth: true + Layout.margins: 8 + spacing: 4 + visible: !root.editing + + // Events list + Repeater { + model: root.dayEvents + + EventItem { + required property var modelData + required property int index + Layout.fillWidth: true + event: modelData + onEditRequested: root.startEdit(modelData) + } + } + + // Empty state + Text { + visible: root.dayEvents.length === 0 + Layout.fillWidth: true + Layout.topMargin: 8 + Layout.bottomMargin: 8 + text: CalendarService.hasAccounts ? "No events" : "No calendar connected" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-2) + color: Colors.outline + horizontalAlignment: Text.AlignHCenter + } + } + + // ── Edit/Create form ── + ColumnLayout { + Layout.fillWidth: true + Layout.margins: 12 + spacing: 10 + visible: root.editing && root.editingEvent !== null + + // Title + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + Text { + text: "Title" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + color: Colors.outline + } + + TextField { + id: titleField + Layout.fillWidth: true + implicitHeight: 36 + leftPadding: 8; rightPadding: 8 + topPadding: 0; bottomPadding: 0 + verticalAlignment: TextInput.AlignVCenter + text: root.editingEvent ? root.editingEvent.title : "" + placeholderText: "Event title" + placeholderTextColor: Colors.outline + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + selectByMouse: true + background: Item { + StyledRect { + anchors.fill: parent + variant: "common" + radius: Styling.radius(-2) + enableBorder: false + } + Rectangle { + anchors.fill: parent + color: "transparent" + border.color: titleField.activeFocus ? Colors.primary : Colors.outlineVariant + border.width: 1 + radius: Styling.radius(-2) + } + } + onTextChanged: if (root.editingEvent) root.editingEvent.title = text + } + } + + // Start / End time + RowLayout { + Layout.fillWidth: true + spacing: 8 + + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + Text { + text: "Start" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + color: Colors.outline + } + + TextField { + id: startField + Layout.fillWidth: true + implicitHeight: 36 + leftPadding: 8; rightPadding: 8 + topPadding: 0; bottomPadding: 0 + verticalAlignment: TextInput.AlignVCenter + text: { + if (!root.editingEvent) return ""; + const s = root.editingEvent.start || ""; + return s.includes("T") ? s.split("T")[1].substring(0, 5) : ""; + } + placeholderText: "HH:MM" + placeholderTextColor: Colors.outline + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + selectByMouse: true + background: Item { + StyledRect { + anchors.fill: parent + variant: "common" + radius: Styling.radius(-2) + enableBorder: false + } + Rectangle { + anchors.fill: parent + color: "transparent" + border.color: startField.activeFocus ? Colors.primary : Colors.outlineVariant + border.width: 1 + radius: Styling.radius(-2) + } + } + onTextChanged: { + if (!root.editingEvent) return; + const dateStr = root.editingEvent.start.split("T")[0]; + root.editingEvent.start = dateStr + "T" + text + ":00"; + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + Text { + text: "End" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + color: Colors.outline + } + + TextField { + id: endField + Layout.fillWidth: true + implicitHeight: 36 + leftPadding: 8; rightPadding: 8 + topPadding: 0; bottomPadding: 0 + verticalAlignment: TextInput.AlignVCenter + text: { + if (!root.editingEvent) return ""; + const e = root.editingEvent.end || ""; + return e.includes("T") ? e.split("T")[1].substring(0, 5) : ""; + } + placeholderText: "HH:MM" + placeholderTextColor: Colors.outline + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + selectByMouse: true + background: Item { + StyledRect { + anchors.fill: parent + variant: "common" + radius: Styling.radius(-2) + enableBorder: false + } + Rectangle { + anchors.fill: parent + color: "transparent" + border.color: endField.activeFocus ? Colors.primary : Colors.outlineVariant + border.width: 1 + radius: Styling.radius(-2) + } + } + onTextChanged: { + if (!root.editingEvent) return; + const dateStr = root.editingEvent.end.split("T")[0]; + root.editingEvent.end = dateStr + "T" + text + ":00"; + } + } + } + } + + // Calendar selector + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + Text { + text: "Calendar" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + color: Colors.outline + } + + ComboBox { + id: calSelector + Layout.fillWidth: true + implicitHeight: 36 + model: root.enabledCalendars + textRole: "name" + valueRole: "id" + currentIndex: { + if (!root.editingEvent) return 0; + for (let i = 0; i < root.enabledCalendars.length; i++) { + if (root.enabledCalendars[i].id === root.editingEvent.calendarId) + return i; + } + return 0; + } + onCurrentValueChanged: { + if (root.editingEvent && currentValue) + root.editingEvent.calendarId = currentValue; + } + background: Rectangle { + color: calSelector.hovered ? Colors.surfaceContainerHigh : Colors.surfaceContainer + radius: Styling.radius(-2) + border.color: Colors.outlineVariant + border.width: 1 + } + contentItem: Text { + leftPadding: 10 + rightPadding: 32 + text: calSelector.displayText + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + indicator: Text { + x: calSelector.width - width - 10 + anchors.verticalCenter: parent.verticalCenter + text: Icons.caretDown + font.family: Icons.font + font.pixelSize: 14 + color: Colors.overBackground + } + popup: Popup { + y: calSelector.height + 4 + width: calSelector.width + padding: 4 + background: Rectangle { + color: Colors.surfaceContainerLow + radius: Styling.radius(-1) + border.color: Colors.outlineVariant + border.width: 1 + } + contentItem: ListView { + clip: true + implicitHeight: Math.min(contentHeight, 200) + model: calSelector.popup.visible ? calSelector.delegateModel : null + currentIndex: calSelector.highlightedIndex + ScrollIndicator.vertical: ScrollIndicator {} + } + } + delegate: ItemDelegate { + id: calDelegate + required property var modelData + required property int index + width: ListView.view.width + height: 32 + highlighted: calSelector.highlightedIndex === index + background: Rectangle { + color: calDelegate.highlighted ? Colors.surfaceContainerHigh : "transparent" + radius: Styling.radius(-2) + } + contentItem: RowLayout { + spacing: 8 + Rectangle { + width: 10; height: 10; radius: 2 + color: calDelegate.modelData.color || Colors.primary + Layout.leftMargin: 8 + } + Text { + text: calDelegate.modelData.name || "" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + verticalAlignment: Text.AlignVCenter + Layout.fillWidth: true + } + } + } + } + } + + // Reminder + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + Text { + text: "Reminder" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + color: Colors.outline + } + + ComboBox { + id: reminderSelector + Layout.fillWidth: true + implicitHeight: 36 + model: [ + { text: "None", value: 0 }, + { text: "5 min", value: 5 }, + { text: "10 min", value: 10 }, + { text: "15 min", value: 15 }, + { text: "30 min", value: 30 }, + { text: "1 hour", value: 60 }, + { text: "1 day", value: 1440 }, + ] + textRole: "text" + valueRole: "value" + currentIndex: { + if (!root.editingEvent) return 3; + const r = root.editingEvent.reminder || 0; + const vals = [0, 5, 10, 15, 30, 60, 1440]; + const idx = vals.indexOf(r); + return idx >= 0 ? idx : 3; + } + onCurrentValueChanged: { + if (root.editingEvent && currentValue !== undefined) + root.editingEvent.reminder = currentValue; + } + background: Rectangle { + color: reminderSelector.hovered ? Colors.surfaceContainerHigh : Colors.surfaceContainer + radius: Styling.radius(-2) + border.color: Colors.outlineVariant + border.width: 1 + } + contentItem: Text { + leftPadding: 10 + rightPadding: 32 + text: reminderSelector.displayText + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + indicator: Text { + x: reminderSelector.width - width - 10 + anchors.verticalCenter: parent.verticalCenter + text: Icons.caretDown + font.family: Icons.font + font.pixelSize: 14 + color: Colors.overBackground + } + popup: Popup { + y: reminderSelector.height + 4 + width: reminderSelector.width + padding: 4 + background: Rectangle { + color: Colors.surfaceContainerLow + radius: Styling.radius(-1) + border.color: Colors.outlineVariant + border.width: 1 + } + contentItem: ListView { + clip: true + implicitHeight: Math.min(contentHeight, 200) + model: reminderSelector.popup.visible ? reminderSelector.delegateModel : null + currentIndex: reminderSelector.highlightedIndex + ScrollIndicator.vertical: ScrollIndicator {} + } + } + delegate: ItemDelegate { + id: reminderDelegate + required property var modelData + required property int index + width: ListView.view.width + height: 32 + highlighted: reminderSelector.highlightedIndex === index + background: Rectangle { + color: reminderDelegate.highlighted ? Colors.surfaceContainerHigh : "transparent" + radius: Styling.radius(-2) + } + contentItem: Text { + leftPadding: 8 + text: reminderDelegate.modelData.text || "" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + verticalAlignment: Text.AlignVCenter + } + } + } + } + + // Description + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + Text { + text: "Description" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-3) + color: Colors.outline + } + + TextField { + id: descField + Layout.fillWidth: true + implicitHeight: 36 + leftPadding: 8; rightPadding: 8 + topPadding: 0; bottomPadding: 0 + verticalAlignment: TextInput.AlignVCenter + text: root.editingEvent ? (root.editingEvent.description || "") : "" + placeholderText: "Optional..." + placeholderTextColor: Colors.outline + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.overBackground + selectByMouse: true + background: Item { + StyledRect { + anchors.fill: parent + variant: "common" + radius: Styling.radius(-2) + enableBorder: false + } + Rectangle { + anchors.fill: parent + color: "transparent" + border.color: descField.activeFocus ? Colors.primary : Colors.outlineVariant + border.width: 1 + radius: Styling.radius(-2) + } + } + onTextChanged: if (root.editingEvent) root.editingEvent.description = text + } + } + + // Action buttons + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 4 + spacing: 8 + + StyledRect { + Layout.fillWidth: true + variant: saveMouse.containsMouse ? "primaryfocus" : "primary" + implicitHeight: 32 + radius: Styling.radius(-2) + + Text { + anchors.centerIn: parent + text: "Save" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + font.weight: Font.DemiBold + color: Colors.primary.hslLightness > 0.5 ? "black" : "white" + } + + MouseArea { + id: saveMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.saveEvent() + } + } + + StyledRect { + visible: !root.creating + variant: deleteMouse.containsMouse ? "focus" : "common" + implicitWidth: 64 + implicitHeight: 32 + radius: Styling.radius(-2) + + Text { + anchors.centerIn: parent + text: "Delete" + font.family: Config.defaultFont + font.pixelSize: Styling.fontSize(-1) + color: Colors.red + } + + MouseArea { + id: deleteMouse + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.deleteEvent() + } + } + } + } + } +} diff --git a/scripts/calendar_service.py b/scripts/calendar_service.py new file mode 100755 index 00000000..0091e472 --- /dev/null +++ b/scripts/calendar_service.py @@ -0,0 +1,1438 @@ +#!/usr/bin/env python3 +""" +Calendar integration service for Ambxst. +Communicates with QML via stdin (commands) / stdout (JSON lines). +Supports Google Calendar (OAuth 2.0) and CalDAV providers. +""" + +import json +import os +import re +import sys +import time +import threading +import subprocess +import signal +from datetime import datetime, timedelta +from urllib.parse import urlparse, parse_qs + +# ── paths ────────────────────────────────────────────────────────── + +XDG_CONFIG = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config")) +XDG_CACHE = os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache")) +CONFIG_DIR = os.path.join(XDG_CONFIG, "ambxst") +CACHE_DIR = os.path.join(XDG_CACHE, "ambxst") +TOKENS_PATH = os.path.join(CONFIG_DIR, "calendar_tokens.json") +CACHE_PATH = os.path.join(CACHE_DIR, "calendar_events.json") +NOTIFIED_PATH = os.path.join(CACHE_DIR, "calendar_notified.json") + +# Google OAuth client (public / installed-app type – no secret needed for +# device or localhost redirect flows with PKCE). +GOOGLE_CLIENT_ID = "" +GOOGLE_CLIENT_SECRET = "" +GOOGLE_SCOPES = ["https://www.googleapis.com/auth/calendar"] + +# gcalcli token paths (pickle format, google.oauth2.credentials.Credentials) +XDG_DATA = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share")) +GCALCLI_TOKEN_PATH = os.path.join(XDG_DATA, "gcalcli", "oauth") + +os.makedirs(CONFIG_DIR, exist_ok=True) +os.makedirs(CACHE_DIR, exist_ok=True) + +# Bundled sound file (shipped with the shell) +_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +BUNDLED_SOUND = os.path.join(_SCRIPT_DIR, "..", "assets", "sound", "polite-warning-tone.wav") + + +# ── helpers ──────────────────────────────────────────────────────── + +def emit(obj): + """Send a JSON message to QML (stdout).""" + print(json.dumps(obj, ensure_ascii=False), flush=True) + + +def load_json(path, default=None): + try: + with open(path, "r") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return default if default is not None else {} + + +def save_json(path, data): + tmp = path + ".tmp" + with open(tmp, "w") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + # Secure the tmp file BEFORE the atomic rename so the file is never + # visible at the target path with open permissions (closes a race window). + if path == TOKENS_PATH: + os.chmod(tmp, 0o600) + os.replace(tmp, path) + + +def iso_now(): + return datetime.now().astimezone().isoformat(timespec="seconds") + + +# ── Google Calendar provider ────────────────────────────────────── + +class GoogleProvider: + """Handles Google Calendar OAuth + API calls.""" + + def __init__(self, account_data, tokens): + self.account_id = account_data["id"] + self.email = account_data.get("email", "") + self.tokens = tokens # reference to the global tokens dict + # NOTE: _service is intentionally NOT cached here. + # The google-api-python-client Service object holds a reference to the + # Credentials object at build time. After a token refresh the old Service + # silently continues sending the stale access_token and gets 401 errors. + # Re-building on every call is cheap because the discovery document is + # cached internally by the library. + + def _get_credentials(self): + from google.oauth2.credentials import Credentials + tok = self.tokens.get("google", {}).get(self.account_id) + if not tok: + return None + # Use stored client_id/secret (may come from gcalcli import) + client_id = tok.get("client_id") or GOOGLE_CLIENT_ID + client_secret = tok.get("client_secret") or GOOGLE_CLIENT_SECRET + + # Restore stored expiry so creds.expired is accurate (avoids unnecessary refreshes) + expiry = None + if tok.get("token_expiry"): + try: + expiry = datetime.fromisoformat(tok["token_expiry"]) + except (ValueError, TypeError): + pass + + creds = Credentials( + token=tok.get("access_token"), + refresh_token=tok.get("refresh_token"), + token_uri="https://oauth2.googleapis.com/token", + client_id=client_id, + client_secret=client_secret, + scopes=GOOGLE_SCOPES, + expiry=expiry, + ) + if creds.expired and creds.refresh_token: + from google.auth.transport.requests import Request + try: + creds.refresh(Request()) + tok["access_token"] = creds.token + if creds.expiry: + tok["token_expiry"] = creds.expiry.isoformat() + save_json(TOKENS_PATH, self.tokens) + except Exception as e: + emit({"type": "auth_error", "message": f"Google token refresh failed: {e}"}) + return None + return creds + + def _build_service(self): + creds = self._get_credentials() + if creds is None: + return None + try: + from googleapiclient.discovery import build + return build("calendar", "v3", credentials=creds) + except Exception as e: + emit({"type": "error", "message": f"Google service build failed: {e}"}) + return None + + def list_calendars(self): + svc = self._build_service() + if not svc: + return [] + try: + result = svc.calendarList().list().execute() + cals = [] + for item in result.get("items", []): + cals.append({ + "id": item["id"], + "accountId": self.account_id, + "name": item.get("summary", "Untitled"), + "color": item.get("backgroundColor", "#bd93f9"), + "enabled": True, + }) + return cals + except Exception as e: + emit({"type": "error", "message": f"Google list calendars: {e}"}) + return [] + + @staticmethod + def _extract_google_meet_link(item): + """Return the Google Meet / video conference URL from a Google Calendar event item.""" + # 1. Modern API: conferenceData.entryPoints[type=video].uri + for ep in (item.get("conferenceData") or {}).get("entryPoints") or []: + if ep.get("entryPointType") == "video": + uri = (ep.get("uri") or "").strip() + if uri.startswith("https://"): + return uri + # 2. Legacy hangoutLink field (still present on many personal-account events) + link = (item.get("hangoutLink") or "").strip() + if link.startswith("https://"): + return link + return "" + + def fetch_events(self, calendar_id, time_min, time_max): + svc = self._build_service() + if not svc: + return [] + try: + result = svc.events().list( + calendarId=calendar_id, + timeMin=time_min, + timeMax=time_max, + singleEvents=True, + orderBy="startTime", + maxResults=500, + ).execute() + events = [] + for item in result.get("items", []): + start = item.get("start", {}) + end = item.get("end", {}) + all_day = "date" in start + meet_link = self._extract_google_meet_link(item) + events.append({ + "id": item["id"], + "calendarId": calendar_id, + "title": item.get("summary", ""), + "description": item.get("description", ""), + "location": item.get("location", ""), + "meetLink": meet_link, + "start": start.get("date") or start.get("dateTime", ""), + "end": end.get("date") or end.get("dateTime", ""), + "allDay": all_day, + "reminder": self._extract_reminder(item), + }) + return events + except Exception as e: + emit({"type": "error", "message": f"Google fetch events: {e}"}) + return [] + + def _extract_reminder(self, item): + overrides = item.get("reminders", {}).get("overrides", []) + if overrides: + return overrides[0].get("minutes", 15) + if item.get("reminders", {}).get("useDefault", True): + return 15 + return 0 + + def create_event(self, event): + svc = self._build_service() + if not svc: + return None + body = self._build_event_body(event) + try: + result = svc.events().insert( + calendarId=event["calendarId"], + body=body, + conferenceDataVersion=1, # always request so conferenceData is in the response + ).execute() + # Google returns the created event with conferenceData already populated + # (creation is typically synchronous). Update the caller's dict in-place + # so the cached copy immediately has the real meet link. + meet = self._extract_google_meet_link(result) + if meet: + event["meetLink"] = meet + return result.get("id") + except Exception as e: + emit({"type": "error", "message": f"Google create event: {e}"}) + return None + + def update_event(self, event): + svc = self._build_service() + if not svc: + return False + body = self._build_event_body(event) + try: + result = svc.events().update( + calendarId=event["calendarId"], + eventId=event["id"], + body=body, + conferenceDataVersion=1, + ).execute() + meet = self._extract_google_meet_link(result) + if meet: + event["meetLink"] = meet + return True + except Exception as e: + emit({"type": "error", "message": f"Google update event: {e}"}) + return False + + def delete_event(self, calendar_id, event_id): + svc = self._build_service() + if not svc: + return False + try: + svc.events().delete(calendarId=calendar_id, eventId=event_id).execute() + return True + except Exception as e: + emit({"type": "error", "message": f"Google delete event: {e}"}) + return False + + def _build_event_body(self, event): + body = { + "summary": event.get("title", ""), + "description": event.get("description", ""), + } + if event.get("location"): + body["location"] = event["location"] + meet_link = event.get("meetLink", "") + if meet_link == "request": + body["conferenceData"] = { + "createRequest": { + "requestId": f"ambxst-{event.get('id') or __import__('uuid').uuid4().hex[:16]}", + "conferenceSolutionKey": {"type": "hangoutsMeet"} + } + } + if event.get("allDay"): + body["start"] = {"date": event["start"][:10]} + body["end"] = {"date": event["end"][:10]} + else: + start_str = event["start"] + end_str = event["end"] + # Google API requires timezone in dateTime — add local offset if missing + if "+" not in start_str and "-" not in start_str[10:] and "Z" not in start_str: + tz = datetime.now().astimezone().strftime("%z") + tz_fmt = tz[:3] + ":" + tz[3:] + start_str += tz_fmt + end_str += tz_fmt + body["start"] = {"dateTime": start_str} + body["end"] = {"dateTime": end_str} + reminder = event.get("reminder", 0) + if reminder > 0: + body["reminders"] = { + "useDefault": False, + "overrides": [{"method": "popup", "minutes": reminder}], + } + return body + + +# ── CalDAV provider ─────────────────────────────────────────────── + +class CalDAVProvider: + """Handles CalDAV calendar operations.""" + + def __init__(self, account_data, tokens): + self.account_id = account_data["id"] + self.url = account_data.get("url", "") + self.tokens = tokens + # NOTE: DAVClient is intentionally NOT cached. + # A cached client becomes stale after network interruptions and raises + # connection errors on subsequent calls with no way to recover. + # DAVClient creation is cheap (no TCP connection until the first request). + + def _get_client(self): + import caldav + from requests.auth import HTTPBasicAuth + tok = self.tokens.get("caldav", {}).get(self.account_id, {}) + username = tok.get("username", "") + password = tok.get("password", "") + # Strip embedded whitespace/control chars (may survive from initial paste → storage) + url = re.sub(r'\s', '', self.url) + if not url.endswith("/"): + url += "/" + return caldav.DAVClient( + url=url, + username=username, + password=password, + auth=HTTPBasicAuth(username, password), + ssl_verify_cert=True, + ) + + def _find_calendar(self, client, calendar_id): + """Return the caldav Calendar object for *calendar_id*, or None.""" + try: + principal = client.principal() + for cal in principal.calendars(): + if str(cal.url) == calendar_id: + return cal + except Exception: + pass + return None + + def list_calendars(self): + try: + client = self._get_client() + principal = client.principal() + cals = [] + colors = ["#50fa7b", "#ff79c6", "#8be9fd", "#ffb86c", "#bd93f9"] + for i, cal in enumerate(principal.calendars()): + cal_id = str(cal.url) + name = cal.name or "Calendar" + cals.append({ + "id": cal_id, + "accountId": self.account_id, + "name": name, + "color": colors[i % len(colors)], + "enabled": True, + }) + return cals + except Exception as e: + emit({"type": "error", "message": f"CalDAV list calendars: {e}"}) + return [] + + def fetch_events(self, calendar_id, time_min, time_max): + try: + from icalendar import Calendar as iCalendar + client = self._get_client() + cal = self._find_calendar(client, calendar_id) + if not cal: + return [] + + start_dt = datetime.fromisoformat(time_min.replace("Z", "+00:00")) + end_dt = datetime.fromisoformat(time_max.replace("Z", "+00:00")) + results = cal.search(start=start_dt, end=end_dt, event=True, expand=True) + + events = [] + for item in results: + try: + ical = iCalendar.from_ical(item.data) + except Exception: + continue + for component in ical.walk(): + if component.name != "VEVENT": + continue + dtstart = component.get("dtstart") + dtend = component.get("dtend") + if not dtstart: + continue + start_val = dtstart.dt + end_val = dtend.dt if dtend else start_val + all_day = not hasattr(start_val, "hour") + events.append({ + "id": str(component.get("uid", "")), + "calendarId": calendar_id, + "title": str(component.get("summary", "")), + "description": str(component.get("description", "")), + "location": str(component.get("location", "")), + "meetLink": self._extract_caldav_meet_link(component), + "start": start_val.isoformat() if hasattr(start_val, "isoformat") else str(start_val), + "end": end_val.isoformat() if hasattr(end_val, "isoformat") else str(end_val), + "allDay": all_day, + "reminder": self._extract_reminder(component), + }) + return events + except Exception as e: + emit({"type": "error", "message": f"CalDAV fetch events: {e}"}) + return [] + + # Conference link URL patterns we recognise as "Meet-like" (worth showing a button) + _CONF_PATTERNS = ( + "meet.google.com", + "zoom.us", + "teams.microsoft.com", + "webex.com", + "telemost.yandex", + "whereby.com", + "jitsi", + "gotomeet", + "meet.", + ) + + def _extract_caldav_meet_link(self, component): + """Return the first conference / video URL from a VEVENT component, or ''.""" + # 1. Standard iCal URL property + url_prop = component.get("url") + if url_prop: + url = str(url_prop).strip() + if url.startswith("https://") and any(p in url for p in self._CONF_PATTERNS): + return url + + # 2. RFC 7986 CONFERENCE property (used by many modern CalDAV servers) + conf_prop = component.get("conference") + if conf_prop: + val = str(conf_prop).strip() + if val.startswith("https://"): + return val + + # 3. X-GOOGLE-CONFERENCE / X-TELEMOST-URL and similar vendor extensions + for key in component.keys(): + if key.upper().startswith("X-") and ("CONF" in key.upper() or + "MEET" in key.upper() or + "TELEMOST" in key.upper()): + val = str(component[key]).strip() + if val.startswith("https://"): + return val + + # 4. Scan description for a bare conference URL on its own line + desc = str(component.get("description", "") or "") + for line in desc.splitlines(): + line = line.strip() + if line.startswith("https://") and any(p in line for p in self._CONF_PATTERNS): + return line + + return "" + + def _extract_reminder(self, component): + for alarm in component.walk(): + if alarm.name == "VALARM": + trigger = alarm.get("trigger") + if trigger and hasattr(trigger.dt, "total_seconds"): + return abs(int(trigger.dt.total_seconds() // 60)) + return 0 + + def _build_vevent(self, event, uid): + """Build an icalendar Event component from an event dict.""" + from icalendar import Event as iEvent, Alarm + vevent = iEvent() + vevent.add("uid", uid) + vevent.add("summary", event.get("title", "")) + vevent.add("description", event.get("description", "")) + if event.get("location"): + vevent.add("location", event["location"]) + meet = (event.get("meetLink") or "").strip() + if meet and meet != "request" and meet.startswith("https://"): + vevent.add("url", meet) + if event.get("allDay"): + from datetime import date + vevent.add("dtstart", date.fromisoformat(event["start"][:10])) + vevent.add("dtend", date.fromisoformat(event["end"][:10])) + else: + dtstart = datetime.fromisoformat(event["start"]) + dtend = datetime.fromisoformat(event["end"]) + if dtstart.tzinfo is None: + dtstart = dtstart.astimezone() + if dtend.tzinfo is None: + dtend = dtend.astimezone() + vevent.add("dtstart", dtstart) + vevent.add("dtend", dtend) + if event.get("reminder", 0) > 0: + alarm = Alarm() + alarm.add("action", "DISPLAY") + alarm.add("trigger", timedelta(minutes=-event["reminder"])) + alarm.add("description", event.get("title", "Reminder")) + vevent.add_component(alarm) + return vevent + + def create_event(self, event): + try: + from icalendar import Calendar as iCalendar + import uuid + client = self._get_client() + cal = self._find_calendar(client, event["calendarId"]) + if not cal: + return None + + uid = str(uuid.uuid4()) + ical = iCalendar() + ical.add("prodid", "-//Ambxst//Calendar//EN") + ical.add("version", "2.0") + ical.add_component(self._build_vevent(event, uid)) + cal.save_event(ical.to_ical().decode("utf-8")) + return uid + except Exception as e: + emit({"type": "error", "message": f"CalDAV create event: {e}"}) + return None + + def update_event(self, event): + """Update an existing CalDAV event in-place via PUT, preserving its UID. + + The old delete-then-create approach is dangerous: if create fails the + event is silently lost and the new UID diverges from the cached one, + producing duplicates after the next sync. + """ + try: + from icalendar import Calendar as iCalendar + client = self._get_client() + cal = self._find_calendar(client, event["calendarId"]) + if not cal: + return False + + uid = event.get("id", "") + target_ev = None + for ev in cal.events(): + try: + ical = iCalendar.from_ical(ev.data) + except Exception: + continue + for component in ical.walk(): + if component.name == "VEVENT" and str(component.get("uid", "")) == uid: + target_ev = ev + break + if target_ev: + break + + if not target_ev: + # Event not on server yet — create it (keeps same UID via create_event path) + return self.create_event(event) is not None + + # Replace the iCal data in-place (same URL/UID, server does a PUT) + new_ical = iCalendar() + new_ical.add("prodid", "-//Ambxst//Calendar//EN") + new_ical.add("version", "2.0") + new_ical.add_component(self._build_vevent(event, uid)) + target_ev.data = new_ical.to_ical().decode("utf-8") + target_ev.save() + return True + except Exception as e: + emit({"type": "error", "message": f"CalDAV update event: {e}"}) + return False + + def delete_event(self, calendar_id, event_id): + try: + from icalendar import Calendar as iCalendar + client = self._get_client() + cal = self._find_calendar(client, calendar_id) + if not cal: + return False + for ev in cal.events(): + try: + ical = iCalendar.from_ical(ev.data) + except Exception: + continue + for component in ical.walk(): + if component.name == "VEVENT" and str(component.get("uid", "")) == event_id: + ev.delete() + return True + return False + except Exception as e: + emit({"type": "error", "message": f"CalDAV delete event: {e}"}) + return False + + +# ── Calendar Service ────────────────────────────────────────────── + +class CalendarService: + def __init__(self, sync_interval=15, default_reminder=15, + sound_on_arrival=True, arrival_sound_path="", blink_on_arrival=True): + self.sync_interval = sync_interval # minutes + self.default_reminder = default_reminder + self.sound_on_arrival = sound_on_arrival + self.arrival_sound_path = arrival_sound_path or "" + self.blink_on_arrival = blink_on_arrival + self.tokens = load_json(TOKENS_PATH, {"google": {}, "caldav": {}}) + self.cache = load_json(CACHE_PATH, { + "last_sync": "", + "accounts": [], + "calendars": [], + "events": [], + }) + self.providers = {} + self._stop = threading.Event() + self._lock = threading.Lock() # guards self.cache and self._notified* + # Notification keys are strings "::" where + # kind is "reminder" or "arrive". Including start_iso means rescheduled + # events will always fire again regardless of their previous notification state. + # Load persisted notification keys so restarts don't re-fire today's alerts. + persisted = load_json(NOTIFIED_PATH, []) + self._notified = set(persisted) if isinstance(persisted, list) else set() + self._init_providers() + + def _init_providers(self): + for acc in self.cache.get("accounts", []): + self._create_provider(acc) + + def _create_provider(self, account): + if account["provider"] == "google": + self.providers[account["id"]] = GoogleProvider(account, self.tokens) + elif account["provider"] == "caldav": + self.providers[account["id"]] = CalDAVProvider(account, self.tokens) + + def run(self): + # Emit initial state + self._emit_static() + self._emit_events() + # Check for gcalcli token availability + self._check_gcalcli() + + # Start sync & notification threads + sync_thread = threading.Thread(target=self._sync_loop, daemon=True) + sync_thread.start() + notify_thread = threading.Thread(target=self._notify_loop, daemon=True) + notify_thread.start() + + # Read commands from stdin + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + cmd = json.loads(line) + self._handle_command(cmd) + except json.JSONDecodeError: + emit({"type": "error", "message": "Invalid JSON command"}) + except Exception as e: + emit({"type": "error", "message": str(e)}) + + def _handle_command(self, cmd): + action = cmd.get("cmd", "") + if action == "sync": + self._do_sync() + elif action == "create": + self._create_event(cmd.get("event", {})) + elif action == "update": + self._update_event(cmd.get("event", {})) + elif action == "delete": + self._delete_event(cmd.get("calendarId", ""), cmd.get("eventId", "")) + elif action == "auth_google": + self._auth_google(cmd.get("client_id", ""), cmd.get("client_secret", "")) + elif action == "import_gcalcli": + self._import_gcalcli() + elif action == "auth_caldav": + self._auth_caldav(cmd) + elif action == "remove_account": + self._remove_account(cmd.get("accountId", "")) + elif action == "set_sync_interval": + self.sync_interval = cmd.get("interval", 15) + elif action == "set_calendar_enabled": + self._set_calendar_enabled(cmd.get("calendarId", ""), cmd.get("enabled", True)) + + # ── Auth ── + + def _account_exists(self, account_id): + return any(a["id"] == account_id for a in self.cache.get("accounts", [])) + + def _check_gcalcli(self): + """Check if gcalcli token exists and report to QML.""" + found = os.path.isfile(GCALCLI_TOKEN_PATH) + emit({"type": "gcalcli_status", "found": found}) + + def _import_gcalcli(self): + """Import Google credentials from gcalcli.""" + if not os.path.isfile(GCALCLI_TOKEN_PATH): + emit({"type": "auth_error", "message": "gcalcli token not found"}) + return + try: + import pickle + with open(GCALCLI_TOKEN_PATH, "rb") as f: + creds = pickle.load(f) + + # Refresh if expired + if creds.expired and creds.refresh_token: + from google.auth.transport.requests import Request + creds.refresh(Request()) + + # Get user email from calendar API + from googleapiclient.discovery import build + service = build("calendar", "v3", credentials=creds) + cal_list = service.calendarList().list().execute() + primary = next((c for c in cal_list.get("items", []) if c.get("primary")), None) + email = primary["id"] if primary else "unknown" + + account_id = f"google_{email}" + if self._account_exists(account_id): + emit({"type": "auth_error", "message": f"Google account {email} is already connected. Remove it first to re-add."}) + return + # Store tokens with gcalcli's client_id/secret for refresh + if "google" not in self.tokens: + self.tokens["google"] = {} + self.tokens["google"][account_id] = { + "access_token": creds.token, + "refresh_token": creds.refresh_token, + "client_id": creds.client_id, + "client_secret": creds.client_secret, + "token_expiry": creds.expiry.isoformat() if creds.expiry else None, + } + save_json(TOKENS_PATH, self.tokens) + + # Add account + account = {"id": account_id, "provider": "google", "email": email} + accounts = [a for a in self.cache.get("accounts", []) if a["id"] != account_id] + accounts.append(account) + self.cache["accounts"] = accounts + + # Create provider & discover calendars + self._create_provider(account) + provider = self.providers[account_id] + new_cals = provider.list_calendars() + existing = [c for c in self.cache.get("calendars", []) if c.get("accountId") != account_id] + existing.extend(new_cals) + self.cache["calendars"] = existing + + self._save_cache() + emit({"type": "auth_complete", "provider": "google", "account": account}) + self._emit_static() + self._do_sync() + except Exception as e: + emit({"type": "auth_error", "message": f"gcalcli import failed: {e}"}) + + def _resolve_google_client(self): + """Get Google OAuth client_id and client_secret. + Priority: hardcoded constants > gcalcli token > None.""" + if GOOGLE_CLIENT_ID: + return GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET + # Try to extract from gcalcli token + if os.path.isfile(GCALCLI_TOKEN_PATH): + try: + import pickle + with open(GCALCLI_TOKEN_PATH, "rb") as f: + creds = pickle.load(f) + if creds.client_id and creds.client_secret: + return creds.client_id, creds.client_secret + except Exception: + pass + return None, None + + def _auth_google(self, config_client_id="", config_client_secret=""): + # Priority: config-provided > hardcoded > gcalcli fallback + client_id = config_client_id or GOOGLE_CLIENT_ID + client_secret = config_client_secret or GOOGLE_CLIENT_SECRET + if not client_id: + client_id, client_secret = self._resolve_google_client() + if not client_id: + emit({"type": "auth_error", "message": "Google OAuth credentials not configured. Enter your Client ID and Secret in settings, or import from gcalcli."}) + return + + try: + from google_auth_oauthlib.flow import InstalledAppFlow + flow = InstalledAppFlow.from_client_config( + { + "installed": { + "client_id": client_id, + "client_secret": client_secret, + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "redirect_uris": ["http://localhost"], + } + }, + scopes=GOOGLE_SCOPES, + ) + creds = flow.run_local_server(port=0, open_browser=True) + + # Get user info + from googleapiclient.discovery import build + service = build("calendar", "v3", credentials=creds) + cal_list = service.calendarList().list().execute() + primary = next((c for c in cal_list.get("items", []) if c.get("primary")), None) + email = primary["id"] if primary else "unknown" + + account_id = f"google_{email}" + if self._account_exists(account_id): + emit({"type": "auth_error", "message": f"Google account {email} is already connected. Remove it first to re-add."}) + return + # Store tokens with client credentials for refresh + if "google" not in self.tokens: + self.tokens["google"] = {} + self.tokens["google"][account_id] = { + "access_token": creds.token, + "refresh_token": creds.refresh_token, + "client_id": client_id, + "client_secret": client_secret, + "token_expiry": creds.expiry.isoformat() if creds.expiry else None, + } + save_json(TOKENS_PATH, self.tokens) + + # Add account + account = {"id": account_id, "provider": "google", "email": email} + accounts = [a for a in self.cache.get("accounts", []) if a["id"] != account_id] + accounts.append(account) + self.cache["accounts"] = accounts + + # Create provider & discover calendars + self._create_provider(account) + provider = self.providers[account_id] + new_cals = provider.list_calendars() + existing = [c for c in self.cache.get("calendars", []) if c.get("accountId") != account_id] + existing.extend(new_cals) + self.cache["calendars"] = existing + + self._save_cache() + emit({"type": "auth_complete", "provider": "google", "account": account}) + self._emit_static() + self._do_sync() + except Exception as e: + emit({"type": "auth_error", "message": f"Google auth failed: {e}"}) + + def _auth_caldav(self, cmd): + import requests as _requests + from requests.auth import HTTPBasicAuth + + # Strip ALL whitespace/control characters (including embedded \n from paste) + url = re.sub(r'\s', '', cmd.get("url") or "") + username = (cmd.get("user") or "").strip() + password = cmd.get("pass") or "" + + if not url: + emit({"type": "auth_error", "message": "CalDAV URL is required"}) + return + if not username: + emit({"type": "auth_error", "message": "CalDAV username is required"}) + return + + # Normalise URL: ensure it ends with '/' so that relative paths resolve correctly + if not url.endswith("/"): + url += "/" + + # Yandex displays app passwords with spaces for readability (e.g. "abcd efgh …") + # but the server expects them without spaces. Strip silently so users can + # paste directly from the Yandex ID page. + parsed_host = (urlparse(url).hostname or "").lower() + if "yandex" in parsed_host: + password = password.replace(" ", "") + # Yandex login should not include the domain part + if "@" in username: + username = username.split("@")[0] + + try: + import caldav + + host = parsed_host or "caldav" + account_id = f"caldav_{host}_{username}" + if self._account_exists(account_id): + emit({"type": "auth_error", "message": f"CalDAV account {username}@{host} is already connected. Remove it first to re-add."}) + return + + # Pre-flight: verify credentials with a plain PROPFIND before involving + # the caldav library, so we can surface a clear 401 message early. + auth = HTTPBasicAuth(username, password) + try: + resp = _requests.request( + "PROPFIND", url, + auth=auth, + headers={"Depth": "0", "Content-Type": "application/xml"}, + data='', + timeout=15, + verify=True, + ) + if resp.status_code == 401: + is_yandex = "yandex" in host + hint = ( + " Make sure you are using an app password (not your main password). " + "For Yandex: go to id.yandex.ru → Security → App passwords, " + "create a password for 'Mail', paste it WITHOUT spaces, " + "and use your Yandex login without @yandex.ru." + if is_yandex else + " Make sure you are using the correct username and password for this CalDAV server." + ) + emit({"type": "auth_error", "message": f"Authentication failed (401 Unauthorized).{hint}"}) + return + if resp.status_code not in (200, 207, 301, 302): + emit({"type": "auth_error", "message": f"CalDAV server returned unexpected status {resp.status_code} for {url}"}) + return + except _requests.exceptions.SSLError as e: + emit({"type": "auth_error", "message": f"SSL certificate error connecting to {host}: {e}"}) + return + except _requests.exceptions.ConnectionError as e: + emit({"type": "auth_error", "message": f"Cannot connect to {url}: {e}"}) + return + except _requests.exceptions.Timeout: + emit({"type": "auth_error", "message": f"Connection to {url} timed out"}) + return + + # Pass auth only via the auth= kwarg; passing username+password alongside + # auth= can cause double-encoding in some caldav library versions. + client = caldav.DAVClient(url=url, auth=auth, ssl_verify_cert=True) + try: + principal = client.principal() + principal.calendars() # verify full CalDAV access + except Exception as e: + raise Exception(f"CalDAV connection failed: {e}") from e + # Use user-supplied name; fall back to "Provider (username)" + custom_name = (cmd.get("name") or "").strip() + if custom_name: + account_name = custom_name + else: + provider_name = { + "caldav.yandex.ru": "Yandex", + "caldav.icloud.com": "iCloud", + "dav.fastmail.com": "Fastmail", + "caldav.fastmail.com": "Fastmail", + }.get(host, host.split(".")[0].capitalize() if "." in host else host) + account_name = f"{provider_name} ({username})" + + if "caldav" not in self.tokens: + self.tokens["caldav"] = {} + self.tokens["caldav"][account_id] = { + "url": url, + "username": username, + "password": password, + } + save_json(TOKENS_PATH, self.tokens) + + account = {"id": account_id, "provider": "caldav", "name": account_name, "url": url} + accounts = [a for a in self.cache.get("accounts", []) if a["id"] != account_id] + accounts.append(account) + self.cache["accounts"] = accounts + + self._create_provider(account) + provider = self.providers[account_id] + new_cals = provider.list_calendars() + existing = [c for c in self.cache.get("calendars", []) if c.get("accountId") != account_id] + existing.extend(new_cals) + self.cache["calendars"] = existing + + self._save_cache() + emit({"type": "auth_complete", "provider": "caldav", "account": account}) + self._emit_static() + self._do_sync() + except Exception as e: + emit({"type": "auth_error", "message": f"CalDAV auth failed: {e}"}) + + def _remove_account(self, account_id): + with self._lock: + self.cache["events"] = [e for e in self.cache.get("events", []) if not self._event_belongs_to_account(e, account_id)] + self.cache["calendars"] = [c for c in self.cache.get("calendars", []) if c.get("accountId") != account_id] + self.cache["accounts"] = [a for a in self.cache.get("accounts", []) if a["id"] != account_id] + self.providers.pop(account_id, None) + + # Remove tokens + for provider_type in ["google", "caldav"]: + if provider_type in self.tokens: + self.tokens[provider_type].pop(account_id, None) + save_json(TOKENS_PATH, self.tokens) + + with self._lock: + self._save_cache() + self._emit_static() + self._emit_events() + + def _event_belongs_to_account(self, event, account_id): + cal_id = event.get("calendarId", "") + for cal in self.cache.get("calendars", []): + if cal["id"] == cal_id and cal.get("accountId") == account_id: + return True + return False + + # ── CRUD ── + + def _create_event(self, event): + cal_id = event.get("calendarId", "") + provider = self._provider_for_calendar(cal_id) + if not provider: + emit({"type": "cmd_result", "cmd": "create", "success": False, + "message": f"No provider for calendar {cal_id}"}) + return + + emit({"type": "cmd_start", "cmd": "create"}) + new_id = provider.create_event(event) + if new_id: + event["id"] = new_id + with self._lock: + self.cache.setdefault("events", []).append(event) + self._save_cache() + self._emit_events() + emit({"type": "cmd_result", "cmd": "create", "success": True}) + # Sync in background to pick up server-side values (e.g. real meetLink + # after Google processes the conference request asynchronously). + threading.Thread(target=self._do_sync, daemon=True).start() + else: + emit({"type": "cmd_result", "cmd": "create", "success": False, + "message": "Failed to save event — check your connection or credentials"}) + + def _update_event(self, event): + cal_id = event.get("calendarId", "") + provider = self._provider_for_calendar(cal_id) + if not provider: + emit({"type": "cmd_result", "cmd": "update", "success": False, + "message": f"No provider for calendar {cal_id}"}) + return + + emit({"type": "cmd_start", "cmd": "update"}) + if provider.update_event(event): + with self._lock: + events = self.cache.get("events", []) + for i, e in enumerate(events): + if e["id"] == event["id"]: + events[i] = event + break + self._save_cache() + self._emit_events() + emit({"type": "cmd_result", "cmd": "update", "success": True}) + threading.Thread(target=self._do_sync, daemon=True).start() + else: + emit({"type": "cmd_result", "cmd": "update", "success": False, + "message": "Failed to update event — check your connection or credentials"}) + + def _delete_event(self, calendar_id, event_id): + provider = self._provider_for_calendar(calendar_id) + if not provider: + emit({"type": "cmd_result", "cmd": "delete", "success": False, + "message": f"No provider for calendar {calendar_id}"}) + return + + emit({"type": "cmd_start", "cmd": "delete"}) + if provider.delete_event(calendar_id, event_id): + with self._lock: + self.cache["events"] = [e for e in self.cache.get("events", []) if e["id"] != event_id] + self._save_cache() + self._emit_events() + emit({"type": "cmd_result", "cmd": "delete", "success": True}) + else: + emit({"type": "cmd_result", "cmd": "delete", "success": False, + "message": "Failed to delete event — check your connection or credentials"}) + + def _set_calendar_enabled(self, calendar_id, enabled): + with self._lock: + for cal in self.cache.get("calendars", []): + if cal["id"] == calendar_id: + cal["enabled"] = enabled + break + self._save_cache() + self._emit_static() + self._emit_events() + + # ── Sync ── + + def _sync_loop(self): + # Sync immediately on startup so cached data (e.g. meetLinks) is refreshed + # right away rather than waiting a full sync_interval before first fetch. + self._do_sync() + while not self._stop.wait(self.sync_interval * 60): + self._do_sync() + + def _do_sync(self): + emit({"type": "sync_status", "syncing": True}) + now = datetime.now().astimezone() + time_min = (now - timedelta(days=30)).isoformat(timespec="seconds") + time_max = (now + timedelta(days=90)).isoformat(timespec="seconds") + + all_events = [] + + with self._lock: + calendars_snapshot = list(self.cache.get("calendars", [])) + + for cal in calendars_snapshot: + if not cal.get("enabled", True): + continue + provider = self.providers.get(cal.get("accountId")) + if not provider: + continue + try: + events = provider.fetch_events(cal["id"], time_min, time_max) + all_events.extend(events) + except Exception as e: + emit({"type": "error", "message": f"Sync error for {cal['name']}: {e}"}) + + with self._lock: + self.cache["events"] = all_events + self.cache["last_sync"] = iso_now() + self._save_cache() + self._emit_events() + emit({"type": "sync_status", "syncing": False}) + + # ── Notifications ── + + def _notify_loop(self): + while not self._stop.wait(5): # check every 5 seconds for near-minute accuracy + self._check_notifications() + + def _check_notifications(self): + now = datetime.now().astimezone() + past_cutoff = now - timedelta(days=1) + + with self._lock: + events_snapshot = list(self.cache.get("events", [])) + # Prune keys whose start time is more than 1 day in the past so + # the set doesn't grow unbounded over a long session. + def _key_start(key): + parts = key.split(":", 2) + return parts[1] if len(parts) >= 2 else "" + + stale = set() + for key in self._notified: + start_str = _key_start(key) + if not start_str: + stale.add(key) + continue + try: + s = datetime.fromisoformat(start_str) + if s.tzinfo is None: + s = s.astimezone() + if s < past_cutoff: + stale.add(key) + except (ValueError, TypeError): + stale.add(key) + self._notified -= stale + notified_snapshot = set(self._notified) + + to_remind = [] # (event, key) — reminder before start + to_arrive = [] # (event, key) — event starting right now + + for event in events_snapshot: + event_id = event.get("id", "") + start_str = event.get("start", "") + if not start_str or event.get("allDay"): + continue + try: + start = datetime.fromisoformat(start_str) + if start.tzinfo is None: + start = start.astimezone() + except (ValueError, TypeError): + continue + + # ── Arrival: event starts within the next 60 seconds ──────────── + arrive_key = f"{event_id}:{start_str}:arrive" + if arrive_key not in notified_snapshot: + if start <= now < start + timedelta(minutes=1): + to_arrive.append((event, arrive_key)) + + # ── Reminder: N minutes before start ───────────────────────────── + reminder = event.get("reminder", 0) + if reminder > 0: + remind_key = f"{event_id}:{start_str}:reminder" + if remind_key not in notified_snapshot: + notify_time = start - timedelta(minutes=reminder) + if notify_time <= now < start: + to_remind.append((event, remind_key)) + + for event, key in to_remind: + self._send_notification(event, arrive=False) + with self._lock: + self._notified.add(key) + self._save_notified() + emit({"type": "notify", "event": event}) + + for event, key in to_arrive: + self._send_notification(event, arrive=True) + with self._lock: + self._notified.add(key) + self._save_notified() + emit({"type": "notify_arrive", "event": event}) + + def _send_notification(self, event, arrive=False): + """Send a D-Bus desktop notification for *event*. + + arrive=True → event is starting right now: critical urgency, attention sound. + arrive=False → advance reminder N min before start: normal urgency, gentle sound. + + Sound plays for BOTH types when sound_on_arrival is enabled, so the user + actually hears *something* regardless of which window fires first. + """ + title = event.get("title", "Calendar Event") + start = event.get("start", "") + meet_link = (event.get("meetLink") or "").strip() + location = (event.get("location") or "").strip() + + try: + time_str = datetime.fromisoformat(start).strftime("%H:%M") + except (ValueError, TypeError): + time_str = start or "?" + + if arrive: + body = "Starting now" + urgency = "critical" + else: + reminder = event.get("reminder", 0) + body = f"In {reminder} min — starting at {time_str}" if reminder else f"Starting at {time_str}" + urgency = "normal" + + # D-Bus sound hint — the notification daemon (dunst, mako, swaync …) plays + # the named sound from the current XDG theme. This is the correct way to + # request sounds per the freedesktop Notification spec; no separate process needed. + sound_id = ("complete" if self.sound_on_arrival else "message-new-instant") if arrive else "message-new-instant" + custom_sound = self.arrival_sound_path if (arrive and self.sound_on_arrival) else "" + # Build notify-send command. Action buttons require libnotify >= 1.8 / + # dunst / mako with action support. When present, notify-send BLOCKS + # and writes the clicked action key to stdout — so we must run it in a + # separate thread and react to the output there. + cmd = ["notify-send", "-a", "Ambxst Calendar", "-i", "x-office-calendar", + "-u", urgency, + "--hint", f"string:sound-name:{sound_id}", + title, body] + # Also pass sound-file hint so daemons that support it use the exact file + if custom_sound and os.path.exists(custom_sound): + cmd += ["--hint", f"string:sound-file:{custom_sound}"] + + # Map action key → URL to open. Only validated https:// URLs are included. + action_map = {} + if meet_link and meet_link.startswith("https://") and meet_link != "request": + cmd += ["--action", "meet=Join Meet"] + action_map["meet"] = meet_link + if location: + if location.startswith("http://") or location.startswith("https://"): + cmd += ["--action", "location=Open Location"] + action_map["location"] = location + else: + # Plain text address — map to a Google Maps search + import urllib.parse + maps_url = "https://maps.google.com/maps?q=" + urllib.parse.quote(location) + cmd += ["--action", "location=Open Location"] + action_map["location"] = maps_url + + def _run_and_handle(cmd, action_map): + """Block until the user dismisses the notification, then open any URL.""" + try: + result = subprocess.run( + cmd, capture_output=True, text=True, + timeout=300, # 5 minutes — plenty of time to act on a reminder + ) + clicked = result.stdout.strip() + url = action_map.get(clicked) + if url: + subprocess.run(["xdg-open", url], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=10) + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + pass + except Exception: + pass + + # Always run in a daemon thread so the notification loop is never blocked. + t = threading.Thread(target=_run_and_handle, args=(cmd, action_map), daemon=True) + t.start() + + # Always play a sound. For arrivals, soundOnArrival controls whether + # the sound is the attention-grabbing variant (urgent=True) or the same + # gentle tone used for reminders. Reminders are always gentle. + if arrive: + custom = self.arrival_sound_path if self.sound_on_arrival else "" + self._play_sound(custom, urgent=self.sound_on_arrival) + else: + self._play_sound("", urgent=False) + + # ── Helpers ── + + def _provider_for_calendar(self, calendar_id): + with self._lock: + calendars = list(self.cache.get("calendars", [])) + for cal in calendars: + if cal["id"] == calendar_id: + return self.providers.get(cal.get("accountId")) + return None + + def _save_cache(self): + save_json(CACHE_PATH, self.cache) + + def _save_notified(self): + """Persist _notified set so process restarts don't re-fire today's alerts. + Called under self._lock — must not acquire it again. + """ + save_json(NOTIFIED_PATH, list(self._notified)) + + def _emit_static(self): + with self._lock: + accounts = list(self.cache.get("accounts", [])) + calendars = list(self.cache.get("calendars", [])) + emit({"type": "static", "accounts": accounts, "calendars": calendars}) + + def _emit_events(self): + with self._lock: + enabled_cals = {c["id"] for c in self.cache.get("calendars", []) if c.get("enabled", True)} + events = [e for e in self.cache.get("events", []) if e.get("calendarId") in enabled_cals] + emit({"type": "events", "data": events}) + + def stop(self): + self._stop.set() + + # ── Sound ──────────────────────────────────────────────────────── + + # Soft: gentle reminder a few minutes before. + _REMINDER_CANBERRA_IDS = ("message-new-instant", "complete", "message", "bell") + _REMINDER_FALLBACK_SOUNDS = [ + BUNDLED_SOUND, + "/usr/share/sounds/freedesktop/stereo/message-new-instant.oga", + "/usr/share/sounds/freedesktop/stereo/complete.oga", + "/usr/share/sounds/freedesktop/stereo/message.oga", + "/usr/share/sounds/freedesktop/stereo/dialog-information.oga", + "/usr/share/sounds/sound-icons/prompt.wav", + "/usr/share/sounds/alsa/Front_Center.wav", + ] + # Urgent: event is starting right now — use theme's alarm/warning sounds first. + _ARRIVAL_CANBERRA_IDS = ("complete", "message-new-instant", "bell") + _ARRIVAL_FALLBACK_SOUNDS = [ + "/usr/share/sounds/freedesktop/stereo/complete.oga", + BUNDLED_SOUND, + "/usr/share/sounds/freedesktop/stereo/complete.oga", + "/usr/share/sounds/sound-icons/prompt.wav", + "/usr/share/sounds/alsa/Front_Center.wav", + ] + + def _play_sound(self, path=None, urgent=False): + """Play a notification sound asynchronously. + + If *path* is provided and exists, play it directly. + Otherwise try named-sound players (canberra, theme-aware) then + file-based fallbacks. *urgent=True* picks a more attention-grabbing + sound suitable for "event starting now" notifications. + """ + custom = path.strip() if path else "" + canberra_ids = self._ARRIVAL_CANBERRA_IDS if urgent else self._REMINDER_CANBERRA_IDS + fallback_sounds = self._ARRIVAL_FALLBACK_SOUNDS if urgent else self._REMINDER_FALLBACK_SOUNDS + + def _try_play(): + # ── 1. Custom file supplied by user ───────────────────────── + if custom and os.path.exists(custom): + for player in ("paplay", "pw-play", "aplay", "ffplay"): + try: + r = subprocess.run( + [player, custom] if player != "ffplay" + else ["ffplay", "-nodisp", "-autoexit", custom], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=15, + ) + if r.returncode == 0: + return + except (FileNotFoundError, subprocess.TimeoutExpired, OSError): + continue + + # ── 2. canberra-gtk-play — uses the current GTK/desktop sound theme, + # no file path required; most reliable on modern Linux desktops. + for sound_id in canberra_ids: + try: + env = dict(os.environ) + # canberra needs a display; prefer Wayland, fall back to X11 + if "DISPLAY" not in env and "WAYLAND_DISPLAY" not in env: + env.setdefault("DISPLAY", ":0") + r = subprocess.run( + ["canberra-gtk-play", f"--id={sound_id}"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=10, env=env, + ) + if r.returncode == 0: + return + except (FileNotFoundError, subprocess.TimeoutExpired, OSError): + break # canberra not installed — fall through to file-based + + # ── 3. File-based fallbacks ────────────────────────────────── + sound_file = next( + (p for p in fallback_sounds if os.path.exists(p)), None + ) + if not sound_file: + return + for player in ("paplay", "pw-play", "aplay"): + try: + r = subprocess.run( + [player, sound_file], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=15, + ) + if r.returncode == 0: + return + except (FileNotFoundError, subprocess.TimeoutExpired, OSError): + continue + + threading.Thread(target=_try_play, daemon=True).start() + + +# ── Main ────────────────────────────────────────────────────────── + +def main(): + # argv: sync_interval default_reminder sound_on_arrival arrival_sound_path blink_on_arrival + def _int_arg(idx, default, lo=None, hi=None): + try: + v = int(sys.argv[idx]) if len(sys.argv) > idx else default + if lo is not None: v = max(lo, v) + if hi is not None: v = min(hi, v) + return v + except (ValueError, OverflowError): + return default + + sync_interval = _int_arg(1, 15, lo=1, hi=1440) + default_reminder = _int_arg(2, 15, lo=0, hi=1440) + sound_on_arrival = (sys.argv[3].lower() not in ("0", "false")) if len(sys.argv) > 3 else True + arrival_sound_path = sys.argv[4] if len(sys.argv) > 4 else "" + blink_on_arrival = (sys.argv[5].lower() not in ("0", "false")) if len(sys.argv) > 5 else True + + service = CalendarService( + sync_interval=sync_interval, + default_reminder=default_reminder, + sound_on_arrival=sound_on_arrival, + arrival_sound_path=arrival_sound_path, + blink_on_arrival=blink_on_arrival, + ) + + def handle_signal(signum, frame): + service.stop() + sys.exit(0) + + signal.signal(signal.SIGTERM, handle_signal) + signal.signal(signal.SIGINT, handle_signal) + + service.run() + + +if __name__ == "__main__": + main() From 6a152ab64319db930e1e7f889e2e8027579babed Mon Sep 17 00:00:00 2001 From: flathead Date: Thu, 2 Apr 2026 00:14:12 +0300 Subject: [PATCH 2/3] i18n: wrap all remaining strings in CalendarPanel and EventPopup Add _t() helper to EventPopup.qml and wrap all user-visible strings with translation keys. Wrap all remaining ~30 hardcoded strings in CalendarPanel.qml (section headers, form labels, buttons, hints). All strings have English fallbacks for use without i18n module. --- .../dashboard/controls/CalendarPanel.qml | 90 +++++++++---------- .../dashboard/widgets/calendar/EventPopup.qml | 55 +++++++----- 2 files changed, 77 insertions(+), 68 deletions(-) diff --git a/modules/widgets/dashboard/controls/CalendarPanel.qml b/modules/widgets/dashboard/controls/CalendarPanel.qml index a6560d0a..0e396997 100644 --- a/modules/widgets/dashboard/controls/CalendarPanel.qml +++ b/modules/widgets/dashboard/controls/CalendarPanel.qml @@ -164,7 +164,7 @@ Item { id: titlebar width: root.contentWidth anchors.horizontalCenter: parent.horizontalCenter - title: root.currentSection === "" ? "Calendar" : (root.currentSection === "accounts" ? "Accounts" : "Settings") + title: root.currentSection === "" ? root._t("calendar.panel.title", "Calendar") : (root.currentSection === "accounts" ? root._t("calendar.panel.section_accounts", "Accounts") : root._t("calendar.panel.section_settings", "Settings")) statusText: CalendarService.syncing ? "Syncing..." : "" statusColor: Colors.primary @@ -173,14 +173,14 @@ Item { if (root.currentSection !== "") { acts.push({ icon: Icons.arrowLeft, - tooltip: "Back", + tooltip: root._t("calendar.panel.back", "Back"), onClicked: function() { root.currentSection = ""; } }); } if (CalendarService.hasAccounts) { acts.push({ icon: Icons.sync, - tooltip: "Sync now", + tooltip: root._t("calendar.panel.sync_now", "Sync now"), loading: CalendarService.syncing, onClicked: function() { CalendarService.sync(); } }); @@ -208,12 +208,12 @@ Item { spacing: 8 SectionButton { - text: "Accounts" + text: root._t("calendar.panel.section_accounts", "Accounts") sectionId: "accounts" } SectionButton { - text: "Settings" + text: root._t("calendar.panel.section_settings", "Settings") sectionId: "settings" } } @@ -289,7 +289,7 @@ Item { Text { id: removeText anchors.centerIn: parent - text: "Remove" + text: root._t("calendar.panel.remove", "Remove") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-3) color: Colors.red @@ -314,7 +314,7 @@ Item { spacing: 4 Text { - text: "CALENDARS" + text: root._t("calendar.panel.section_calendars", "CALENDARS") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-3) color: Colors.outline @@ -394,14 +394,14 @@ Item { spacing: 8 Text { - text: "gcalcli token found" + text: root._t("calendar.panel.gcalcli_found", "gcalcli token found") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-2) color: Colors.outline } Text { - text: "An existing Google Calendar token from gcalcli was detected. You can import it to connect your account instantly." + text: root._t("calendar.panel.gcalcli_detected", "An existing Google Calendar token from gcalcli was detected. You can import it to connect your account instantly.") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-2) color: Colors.overBackground @@ -417,7 +417,7 @@ Item { Text { anchors.centerIn: parent - text: "Import from gcalcli" + text: root._t("calendar.panel.import_gcalcli", "Import from gcalcli") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-1) font.weight: Font.DemiBold @@ -449,7 +449,7 @@ Item { Text { anchors.centerIn: parent - text: "+ Google OAuth" + text: root._t("calendar.panel.add_google", "+ Google OAuth") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-1) color: Colors.primary.hslLightness > 0.5 ? "black" : "white" @@ -472,7 +472,7 @@ Item { Text { anchors.centerIn: parent - text: "+ CalDAV" + text: root._t("calendar.panel.add_caldav", "+ CalDAV") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-1) color: Colors.primary.hslLightness > 0.5 ? "black" : "white" @@ -490,7 +490,7 @@ Item { Text { visible: !CalendarService.gcalcliFound - text: "Enter your Google OAuth Client ID and Secret above, then click Connect. If you have gcalcli installed and authenticated, its token will be detected automatically." + text: root._t("calendar.panel.oauth_hint", "Enter your Google OAuth Client ID and Secret above, then click Connect. If you have gcalcli installed and authenticated, its token will be detected automatically.") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-3) color: Colors.outline @@ -517,7 +517,7 @@ Item { spacing: 8 Text { - text: "CalDAV Server" + text: root._t("calendar.panel.caldav_server", "CalDAV Server") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-2) font.weight: Font.Medium @@ -531,7 +531,7 @@ Item { leftPadding: 8; rightPadding: 8 topPadding: 0; bottomPadding: 0 verticalAlignment: TextInput.AlignVCenter - placeholderText: "Account name (optional)" + placeholderText: root._t("calendar.panel.caldav_name", "Account name (optional)") placeholderTextColor: Colors.outline text: root.caldavName onTextChanged: root.caldavName = text @@ -595,7 +595,7 @@ Item { leftPadding: 8; rightPadding: 8 topPadding: 0; bottomPadding: 0 verticalAlignment: TextInput.AlignVCenter - placeholderText: "Username" + placeholderText: root._t("calendar.panel.username", "Username") placeholderTextColor: Colors.outline text: root.caldavUser onTextChanged: root.caldavUser = text @@ -627,7 +627,7 @@ Item { leftPadding: 8; rightPadding: 8 topPadding: 0; bottomPadding: 0 verticalAlignment: TextInput.AlignVCenter - placeholderText: "Password" + placeholderText: root._t("calendar.panel.password", "Password") placeholderTextColor: Colors.outline text: root.caldavPass onTextChanged: root.caldavPass = text @@ -661,7 +661,7 @@ Item { Text { anchors.centerIn: parent - text: "Connect" + text: root._t("calendar.panel.connect", "Connect") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-1) font.weight: Font.DemiBold @@ -694,7 +694,7 @@ Item { // Google OAuth credentials Text { - text: "GOOGLE OAUTH" + text: root._t("calendar.panel.section_google", "GOOGLE OAUTH") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-3) color: Colors.outline @@ -707,7 +707,7 @@ Item { leftPadding: 8; rightPadding: 8 topPadding: 0; bottomPadding: 0 verticalAlignment: TextInput.AlignVCenter - placeholderText: "Google Client ID" + placeholderText: root._t("calendar.panel.google_client_id", "Google Client ID") placeholderTextColor: Colors.outline text: root.pendingGoogleClientId onTextChanged: root.pendingGoogleClientId = text @@ -739,7 +739,7 @@ Item { leftPadding: 8; rightPadding: 8 topPadding: 0; bottomPadding: 0 verticalAlignment: TextInput.AlignVCenter - placeholderText: "Google Client Secret" + placeholderText: root._t("calendar.panel.google_client_secret", "Google Client Secret") placeholderTextColor: Colors.outline text: root.pendingGoogleClientSecret onTextChanged: root.pendingGoogleClientSecret = text @@ -766,7 +766,7 @@ Item { } Text { - text: "Create credentials at console.cloud.google.com (Calendar API, Desktop app type)." + text: root._t("calendar.panel.google_credentials_hint", "Create credentials at console.cloud.google.com (Calendar API, Desktop app type).") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-3) color: Colors.outline @@ -781,7 +781,7 @@ Item { spacing: 8 Text { - text: "Sync interval" + text: root._t("calendar.panel.sync_interval", "Sync interval") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-1) color: Colors.overBackground @@ -792,10 +792,10 @@ Item { id: syncIntervalCombo implicitHeight: 36 model: [ - { text: "5 min", value: 5 }, - { text: "15 min", value: 15 }, - { text: "30 min", value: 30 }, - { text: "60 min", value: 60 } + { text: root._t("calendar.form.reminder_minutes", "%1 min", 5), value: 5 }, + { text: root._t("calendar.form.reminder_minutes", "%1 min", 15), value: 15 }, + { text: root._t("calendar.form.reminder_minutes", "%1 min", 30), value: 30 }, + { text: root._t("calendar.form.reminder_minutes", "%1 min", 60), value: 60 } ] textRole: "text" valueRole: "value" @@ -875,7 +875,7 @@ Item { Layout.fillWidth: true Text { - text: "Notifications" + text: root._t("calendar.panel.notifications", "Notifications") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-1) color: Colors.overBackground @@ -906,7 +906,7 @@ Item { Layout.fillWidth: true Text { - text: "Bar indicator" + text: root._t("calendar.panel.bar_indicator", "Bar indicator") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-1) color: Colors.overBackground @@ -937,7 +937,7 @@ Item { Layout.fillWidth: true Text { - text: "Show next upcoming event" + text: root._t("calendar.panel.bar_show_next", "Show next upcoming event") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-1) color: root.pendingBarIndicator ? Colors.overBackground : Colors.outline @@ -1004,7 +1004,7 @@ Item { spacing: 8 Text { - text: "Default reminder" + text: root._t("calendar.panel.default_reminder", "Default reminder") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-1) color: Colors.overBackground @@ -1015,12 +1015,12 @@ Item { id: defaultReminderCombo implicitHeight: 36 model: [ - { text: "None", value: 0 }, - { text: "5 min", value: 5 }, - { text: "10 min", value: 10 }, - { text: "15 min", value: 15 }, - { text: "30 min", value: 30 }, - { text: "1 hour", value: 60 } + { text: root._t("calendar.form.reminder_none", "None"), value: 0 }, + { text: root._t("calendar.form.reminder_minutes", "%1 min", 5), value: 5 }, + { text: root._t("calendar.form.reminder_minutes", "%1 min", 10), value: 10 }, + { text: root._t("calendar.form.reminder_minutes", "%1 min", 15), value: 15 }, + { text: root._t("calendar.form.reminder_minutes", "%1 min", 30), value: 30 }, + { text: root._t("calendar.form.reminder_1hour", "1 hour"), value: 60 } ] textRole: "text" valueRole: "value" @@ -1097,7 +1097,7 @@ Item { // Arrival attention settings Text { - text: "ARRIVAL ATTENTION" + text: root._t("calendar.panel.section_arrival", "ARRIVAL ATTENTION") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-3) color: Colors.outline @@ -1108,7 +1108,7 @@ Item { Layout.fillWidth: true Text { - text: "Sound on arrival" + text: root._t("calendar.panel.sound_on_arrival", "Sound on arrival") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-1) color: root.pendingNotifications ? Colors.overBackground : Colors.outline @@ -1141,7 +1141,7 @@ Item { spacing: 4 Text { - text: "Custom sound file" + text: root._t("calendar.panel.custom_sound", "Custom sound file") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-3) color: Colors.outline @@ -1154,7 +1154,7 @@ Item { leftPadding: 8; rightPadding: 8 topPadding: 0; bottomPadding: 0 verticalAlignment: TextInput.AlignVCenter - placeholderText: "Leave empty to use system default" + placeholderText: root._t("calendar.panel.sound_hint_default", "Leave empty to use system default") placeholderTextColor: Colors.outline text: root.pendingArrivalSoundPath onTextChanged: root.pendingArrivalSoundPath = text @@ -1180,7 +1180,7 @@ Item { } Text { - text: "Supports .oga, .ogg, .wav, .mp3" + text: root._t("calendar.panel.sound_hint_formats", "Supports .oga, .ogg, .wav, .mp3") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-4) color: Colors.outline @@ -1191,7 +1191,7 @@ Item { Layout.fillWidth: true Text { - text: "Blink icon on arrival" + text: root._t("calendar.panel.blink_on_arrival", "Blink icon on arrival") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-1) color: root.pendingBarIndicator ? Colors.overBackground : Colors.outline @@ -1232,7 +1232,7 @@ Item { Text { anchors.centerIn: parent - text: "Save" + text: root._t("calendar.panel.save", "Save") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-1) font.weight: Font.DemiBold @@ -1256,7 +1256,7 @@ Item { Text { anchors.centerIn: parent - text: "Reset" + text: root._t("calendar.panel.reset", "Reset") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-1) color: Colors.outline diff --git a/modules/widgets/dashboard/widgets/calendar/EventPopup.qml b/modules/widgets/dashboard/widgets/calendar/EventPopup.qml index 14165623..ae4e588b 100644 --- a/modules/widgets/dashboard/widgets/calendar/EventPopup.qml +++ b/modules/widgets/dashboard/widgets/calendar/EventPopup.qml @@ -15,6 +15,15 @@ Popup { property int year: 0 property var dayEvents: [] + // i18n helper — works with or without the I18n singleton + function _t(key, fallback) { + let str; + try { str = I18n.t(key); } catch(e) { str = fallback; } + for (let i = 2; i < arguments.length; i++) + str = str.replace("%" + (i - 1), arguments[i]); + return str; + } + // Edit mode state property bool editing: false property bool creating: false @@ -141,7 +150,7 @@ Popup { const d = new Date(root.year, root.month - 1, root.day); const dayName = d.toLocaleDateString(Qt.locale(), "dddd"); const count = root.dayEvents.length; - return dayName + " · " + count + (count === 1 ? " event" : " events"); + return dayName + " · " + count + (count === 1 ? " " + root._t("calendar.form.event_singular", "event") : " " + root._t("calendar.form.events_plural", "events")); } font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-3) @@ -154,7 +163,7 @@ Popup { visible: root.editing anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter - text: root.creating ? "New Event" : "Edit Event" + text: root.creating ? root._t("calendar.form.new_event", "New Event") : root._t("calendar.form.edit_event", "Edit Event") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(1) font.weight: Font.DemiBold @@ -174,7 +183,7 @@ Popup { Text { id: addText anchors.centerIn: parent - text: "+ Add" + text: root._t("calendar.form.add_event", "+ Add") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-2) font.weight: Font.DemiBold @@ -250,7 +259,7 @@ Popup { Layout.fillWidth: true Layout.topMargin: 8 Layout.bottomMargin: 8 - text: CalendarService.hasAccounts ? "No events" : "No calendar connected" + text: CalendarService.hasAccounts ? root._t("calendar.form.no_events", "No events") : root._t("calendar.form.no_calendar", "No calendar connected") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-2) color: Colors.outline @@ -271,7 +280,7 @@ Popup { spacing: 4 Text { - text: "Title" + text: root._t("calendar.form.label_title", "Title") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-3) color: Colors.outline @@ -285,7 +294,7 @@ Popup { topPadding: 0; bottomPadding: 0 verticalAlignment: TextInput.AlignVCenter text: root.editingEvent ? root.editingEvent.title : "" - placeholderText: "Event title" + placeholderText: root._t("calendar.form.placeholder_title", "Event title") placeholderTextColor: Colors.outline font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-1) @@ -320,7 +329,7 @@ Popup { spacing: 4 Text { - text: "Start" + text: root._t("calendar.form.label_start", "Start") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-3) color: Colors.outline @@ -338,7 +347,7 @@ Popup { const s = root.editingEvent.start || ""; return s.includes("T") ? s.split("T")[1].substring(0, 5) : ""; } - placeholderText: "HH:MM" + placeholderText: root._t("calendar.form.time_placeholder", "HH:MM") placeholderTextColor: Colors.outline font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-1) @@ -372,7 +381,7 @@ Popup { spacing: 4 Text { - text: "End" + text: root._t("calendar.form.label_end", "End") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-3) color: Colors.outline @@ -390,7 +399,7 @@ Popup { const e = root.editingEvent.end || ""; return e.includes("T") ? e.split("T")[1].substring(0, 5) : ""; } - placeholderText: "HH:MM" + placeholderText: root._t("calendar.form.time_placeholder", "HH:MM") placeholderTextColor: Colors.outline font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-1) @@ -426,7 +435,7 @@ Popup { spacing: 4 Text { - text: "Calendar" + text: root._t("calendar.form.label_calendar", "Calendar") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-3) color: Colors.outline @@ -530,7 +539,7 @@ Popup { spacing: 4 Text { - text: "Reminder" + text: root._t("calendar.form.label_reminder", "Reminder") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-3) color: Colors.outline @@ -541,13 +550,13 @@ Popup { Layout.fillWidth: true implicitHeight: 36 model: [ - { text: "None", value: 0 }, - { text: "5 min", value: 5 }, - { text: "10 min", value: 10 }, - { text: "15 min", value: 15 }, - { text: "30 min", value: 30 }, - { text: "1 hour", value: 60 }, - { text: "1 day", value: 1440 }, + { text: root._t("calendar.form.reminder_none", "None"), value: 0 }, + { text: root._t("calendar.form.reminder_minutes", "%1 min", 5), value: 5 }, + { text: root._t("calendar.form.reminder_minutes", "%1 min", 10), value: 10 }, + { text: root._t("calendar.form.reminder_minutes", "%1 min", 15), value: 15 }, + { text: root._t("calendar.form.reminder_minutes", "%1 min", 30), value: 30 }, + { text: root._t("calendar.form.reminder_1hour", "1 hour"), value: 60 }, + { text: root._t("calendar.form.reminder_1day", "1 day"), value: 1440 }, ] textRole: "text" valueRole: "value" @@ -633,7 +642,7 @@ Popup { spacing: 4 Text { - text: "Description" + text: root._t("calendar.form.label_description", "Description") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-3) color: Colors.outline @@ -647,7 +656,7 @@ Popup { topPadding: 0; bottomPadding: 0 verticalAlignment: TextInput.AlignVCenter text: root.editingEvent ? (root.editingEvent.description || "") : "" - placeholderText: "Optional..." + placeholderText: root._t("calendar.form.placeholder_description", "Optional...") placeholderTextColor: Colors.outline font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-1) @@ -686,7 +695,7 @@ Popup { Text { anchors.centerIn: parent - text: "Save" + text: root._t("calendar.form.save", "Save") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-1) font.weight: Font.DemiBold @@ -711,7 +720,7 @@ Popup { Text { anchors.centerIn: parent - text: "Delete" + text: root._t("calendar.form.delete", "Delete") font.family: Config.defaultFont font.pixelSize: Styling.fontSize(-1) color: Colors.red From 9252d26a87a2b945ebdd2af825576c6beef43bfb Mon Sep 17 00:00:00 2001 From: flathead Date: Thu, 2 Apr 2026 00:17:50 +0300 Subject: [PATCH 3/3] i18n: translate Calendar label in settings sidebar --- modules/widgets/dashboard/controls/SettingsTab.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/widgets/dashboard/controls/SettingsTab.qml b/modules/widgets/dashboard/controls/SettingsTab.qml index 9c631ff5..a1b6687c 100644 --- a/modules/widgets/dashboard/controls/SettingsTab.qml +++ b/modules/widgets/dashboard/controls/SettingsTab.qml @@ -256,7 +256,7 @@ Rectangle { }, { icon: Icons.calendarBlank, - label: "Calendar", + label: I18n.t("settings.calendar"), section: 9, isIcon: true },