diff --git a/Services/BatteryService.qml b/Services/BatteryService.qml new file mode 100644 index 00000000..1d7b5b92 --- /dev/null +++ b/Services/BatteryService.qml @@ -0,0 +1,252 @@ +import QtQuick +import Quickshell +import Quickshell.Io +pragma Singleton +pragma ComponentBehavior: Bound + +Singleton { + id: root + + property bool batteryAvailable: false + property int batteryLevel: 0 + property string batteryStatus: "Unknown" // "Charging", "Discharging", "Full", "Not charging", "Unknown" + property int timeRemaining: 0 // minutes + property bool isCharging: false + property bool isLowBattery: false + property int batteryHealth: 100 // percentage + property string batteryTechnology: "Unknown" + property int cycleCount: 0 + property int batteryCapacity: 0 // mAh + property var powerProfiles: [] + property string activePowerProfile: "balanced" + + // Check if battery is available + Process { + id: batteryAvailabilityChecker + command: ["bash", "-c", "ls /sys/class/power_supply/ | grep -E '^BAT' | head -1"] + running: true + + stdout: SplitParser { + splitMarker: "\n" + onRead: (data) => { + if (data.trim()) { + root.batteryAvailable = true + console.log("Battery found:", data.trim()) + batteryStatusChecker.running = true + } else { + root.batteryAvailable = false + console.log("No battery found - this appears to be a desktop system") + } + } + } + } + + // Battery status checker + Process { + id: batteryStatusChecker + command: ["bash", "-c", "if [ -d /sys/class/power_supply/BAT0 ] || [ -d /sys/class/power_supply/BAT1 ]; then upower -i $(upower -e | grep 'BAT') | grep -E 'state|percentage|time to|energy|technology|cycle-count' || acpi -b 2>/dev/null || echo 'fallback'; else echo 'no-battery'; fi"] + running: false + + stdout: StdioCollector { + onStreamFinished: { + if (text.trim() === "no-battery") { + root.batteryAvailable = false + return + } + + if (text.trim() && text.trim() !== "fallback") { + parseBatteryInfo(text.trim()) + } else { + // Fallback to simple methods + fallbackBatteryChecker.running = true + } + } + } + + onExited: (exitCode) => { + if (exitCode !== 0) { + console.warn("Battery status check failed, trying fallback methods") + fallbackBatteryChecker.running = true + } + } + } + + // Fallback battery checker using /sys files + Process { + id: fallbackBatteryChecker + command: ["bash", "-c", "if [ -f /sys/class/power_supply/BAT0/capacity ]; then BAT=BAT0; elif [ -f /sys/class/power_supply/BAT1/capacity ]; then BAT=BAT1; else echo 'no-battery'; exit 1; fi; echo \"percentage: $(cat /sys/class/power_supply/$BAT/capacity)%\"; echo \"state: $(cat /sys/class/power_supply/$BAT/status 2>/dev/null || echo Unknown)\"; if [ -f /sys/class/power_supply/$BAT/technology ]; then echo \"technology: $(cat /sys/class/power_supply/$BAT/technology)\"; fi; if [ -f /sys/class/power_supply/$BAT/cycle_count ]; then echo \"cycle-count: $(cat /sys/class/power_supply/$BAT/cycle_count)\"; fi"] + running: false + + stdout: StdioCollector { + onStreamFinished: { + if (text.trim() !== "no-battery") { + parseBatteryInfo(text.trim()) + } + } + } + } + + // Power profiles checker (for systems with power-profiles-daemon) + Process { + id: powerProfilesChecker + command: ["bash", "-c", "if command -v powerprofilesctl > /dev/null; then powerprofilesctl list 2>/dev/null; else echo 'not-available'; fi"] + running: false + + stdout: StdioCollector { + onStreamFinished: { + if (text.trim() !== "not-available") { + parsePowerProfiles(text.trim()) + } + } + } + } + + function parseBatteryInfo(batteryText) { + let lines = batteryText.split('\n') + + for (let line of lines) { + line = line.trim().toLowerCase() + + if (line.includes('percentage:') || line.includes('capacity:')) { + let match = line.match(/(\d+)%?/) + if (match) { + root.batteryLevel = parseInt(match[1]) + root.isLowBattery = root.batteryLevel <= 20 + } + } else if (line.includes('state:') || line.includes('status:')) { + if (line.includes('charging')) { + root.batteryStatus = "Charging" + root.isCharging = true + } else if (line.includes('discharging')) { + root.batteryStatus = "Discharging" + root.isCharging = false + } else if (line.includes('full')) { + root.batteryStatus = "Full" + root.isCharging = false + } else if (line.includes('not charging')) { + root.batteryStatus = "Not charging" + root.isCharging = false + } else { + root.batteryStatus = "Unknown" + root.isCharging = false + } + } else if (line.includes('time to')) { + let match = line.match(/(\d+):(\d+)/) + if (match) { + root.timeRemaining = parseInt(match[1]) * 60 + parseInt(match[2]) + } + } else if (line.includes('technology:')) { + let tech = line.split(':')[1]?.trim() || "Unknown" + root.batteryTechnology = tech.charAt(0).toUpperCase() + tech.slice(1) + } else if (line.includes('cycle-count:')) { + let match = line.match(/(\d+)/) + if (match) { + root.cycleCount = parseInt(match[1]) + } + } else if (line.includes('energy-full:') || line.includes('capacity:')) { + let match = line.match(/([\d.]+)\s*wh/i) + if (match) { + root.batteryCapacity = Math.round(parseFloat(match[1]) * 1000) // Convert to mWh + } + } + } + + console.log("Battery status updated:", root.batteryLevel + "%", root.batteryStatus) + } + + function parsePowerProfiles(profileText) { + let lines = profileText.split('\n') + let profiles = [] + + for (let line of lines) { + line = line.trim() + if (line.includes('*')) { + // Active profile + let profileName = line.replace('*', '').trim() + if (profileName.includes(':')) { + profileName = profileName.split(':')[0].trim() + } + root.activePowerProfile = profileName + profiles.push(profileName) + } else if (line && !line.includes(':') && line.length > 0) { + profiles.push(line) + } + } + + root.powerProfiles = profiles + console.log("Power profiles available:", profiles, "Active:", root.activePowerProfile) + } + + function setBatteryProfile(profileName) { + if (!root.powerProfiles.includes(profileName)) { + console.warn("Invalid power profile:", profileName) + return + } + + console.log("Setting power profile to:", profileName) + let profileProcess = Qt.createQmlObject(` + import Quickshell.Io + Process { + command: ["powerprofilesctl", "set", "${profileName}"] + running: true + onExited: (exitCode) => { + if (exitCode === 0) { + console.log("Power profile changed to:", "${profileName}") + root.activePowerProfile = "${profileName}" + } else { + console.warn("Failed to change power profile") + } + } + } + `, root) + } + + function getBatteryIcon() { + if (!root.batteryAvailable) return "power" + + let level = root.batteryLevel + let charging = root.isCharging + + if (charging) { + if (level >= 90) return "battery_charging_full" + if (level >= 60) return "battery_charging_90" + if (level >= 30) return "battery_charging_60" + if (level >= 20) return "battery_charging_30" + return "battery_charging_20" + } else { + if (level >= 90) return "battery_full" + if (level >= 60) return "battery_6_bar" + if (level >= 50) return "battery_5_bar" + if (level >= 40) return "battery_4_bar" + if (level >= 30) return "battery_3_bar" + if (level >= 20) return "battery_2_bar" + if (level >= 10) return "battery_1_bar" + return "battery_alert" + } + } + + function formatTimeRemaining() { + if (root.timeRemaining <= 0) return "Unknown" + + let hours = Math.floor(root.timeRemaining / 60) + let minutes = root.timeRemaining % 60 + + if (hours > 0) { + return hours + "h " + minutes + "m" + } else { + return minutes + "m" + } + } + + // Update battery status every 30 seconds + Timer { + interval: 30000 + running: root.batteryAvailable + repeat: true + triggeredOnStart: false + onTriggered: { + batteryStatusChecker.running = true + powerProfilesChecker.running = true + } + } +} \ No newline at end of file diff --git a/Services/BluetoothService.qml b/Services/BluetoothService.qml index b67299bc..94513135 100644 --- a/Services/BluetoothService.qml +++ b/Services/BluetoothService.qml @@ -10,6 +10,9 @@ Singleton { property bool bluetoothEnabled: false property bool bluetoothAvailable: false property var bluetoothDevices: [] + property var availableDevices: [] + property bool scanning: false + property bool discoverable: false // Real Bluetooth Management Process { @@ -91,6 +94,91 @@ Singleton { } } + function startDiscovery() { + console.log("Starting Bluetooth discovery...") + let discoveryProcess = Qt.createQmlObject(' + import Quickshell.Io + Process { + command: ["bluetoothctl", "scan", "on"] + running: true + onExited: { + root.scanning = true + // Scan for 10 seconds then get discovered devices + discoveryScanTimer.start() + } + } + ', root) + } + + function stopDiscovery() { + console.log("Stopping Bluetooth discovery...") + let stopDiscoveryProcess = Qt.createQmlObject(' + import Quickshell.Io + Process { + command: ["bluetoothctl", "scan", "off"] + running: true + onExited: { + root.scanning = false + } + } + ', root) + } + + function pairDevice(mac) { + console.log("Pairing device:", mac) + let pairProcess = Qt.createQmlObject(' + import Quickshell.Io + Process { + command: ["bluetoothctl", "pair", "' + mac + '"] + running: true + onExited: (exitCode) => { + if (exitCode === 0) { + console.log("Pairing successful") + connectDevice("' + mac + '") + } else { + console.warn("Pairing failed with exit code:", exitCode) + } + availableDeviceScanner.running = true + bluetoothDeviceScanner.running = true + } + } + ', root) + } + + function connectDevice(mac) { + console.log("Connecting to device:", mac) + let connectProcess = Qt.createQmlObject(' + import Quickshell.Io + Process { + command: ["bluetoothctl", "connect", "' + mac + '"] + running: true + onExited: (exitCode) => { + if (exitCode === 0) { + console.log("Connection successful") + } else { + console.warn("Connection failed with exit code:", exitCode) + } + bluetoothDeviceScanner.running = true + } + } + ', root) + } + + function removeDevice(mac) { + console.log("Removing device:", mac) + let removeProcess = Qt.createQmlObject(' + import Quickshell.Io + Process { + command: ["bluetoothctl", "remove", "' + mac + '"] + running: true + onExited: { + bluetoothDeviceScanner.running = true + availableDeviceScanner.running = true + } + } + ', root) + } + function toggleBluetoothDevice(mac) { console.log("Toggling Bluetooth device:", mac) let device = root.bluetoothDevices.find(d => d.mac === mac) @@ -118,4 +206,82 @@ Singleton { } ', root) } + + // Timer for discovery scanning + Timer { + id: discoveryScanTimer + interval: 8000 // 8 seconds + repeat: false + onTriggered: { + availableDeviceScanner.running = true + } + } + + // Scan for available/discoverable devices + Process { + id: availableDeviceScanner + command: ["bash", "-c", "timeout 5 bluetoothctl devices | grep -v 'Device.*/' | while read -r line; do if [[ $line =~ Device\ ([0-9A-F:]+)\ (.+) ]]; then mac=\"${BASH_REMATCH[1]}\"; name=\"${BASH_REMATCH[2]}\"; if [[ ! $name =~ ^/org/bluez ]] && [[ ! $name =~ hci0 ]]; then info=$(timeout 3 bluetoothctl info $mac 2>/dev/null); paired=$(echo \"$info\" | grep 'Paired:' | grep -q 'yes' && echo 'true' || echo 'false'); connected=$(echo \"$info\" | grep 'Connected:' | grep -q 'yes' && echo 'true' || echo 'false'); rssi=$(echo \"$info\" | grep 'RSSI:' | awk '{print $2}' | head -n1); echo \"$mac|$name|$paired|$connected|${rssi:-}\"; fi; fi; done"] + running: false + + stdout: StdioCollector { + onStreamFinished: { + if (text.trim()) { + let devices = [] + let lines = text.trim().split('\n') + + for (let line of lines) { + if (line.trim()) { + let parts = line.split('|') + if (parts.length >= 4) { + let mac = parts[0].trim() + let name = parts[1].trim() + let paired = parts[2].trim() === 'true' + let connected = parts[3].trim() === 'true' + let rssi = parts[4] ? parseInt(parts[4]) : 0 + + // Skip if name is still a technical path + if (name.startsWith('/org/bluez') || name.includes('hci0')) { + continue + } + + // Determine device type from name + let type = "bluetooth" + let nameLower = name.toLowerCase() + if (nameLower.includes("headphone") || nameLower.includes("airpod") || nameLower.includes("headset") || nameLower.includes("arctis") || nameLower.includes("audio")) type = "headset" + else if (nameLower.includes("mouse")) type = "mouse" + else if (nameLower.includes("keyboard")) type = "keyboard" + else if (nameLower.includes("phone") || nameLower.includes("iphone") || nameLower.includes("samsung") || nameLower.includes("android")) type = "phone" + else if (nameLower.includes("watch")) type = "watch" + else if (nameLower.includes("speaker")) type = "speaker" + else if (nameLower.includes("tv") || nameLower.includes("display")) type = "tv" + + // Signal strength assessment + let signalStrength = "unknown" + if (rssi !== 0) { + if (rssi >= -50) signalStrength = "excellent" + else if (rssi >= -60) signalStrength = "good" + else if (rssi >= -70) signalStrength = "fair" + else signalStrength = "weak" + } + + devices.push({ + mac: mac, + name: name, + type: type, + paired: paired, + connected: connected, + rssi: rssi, + signalStrength: signalStrength, + canPair: !paired + }) + } + } + } + + root.availableDevices = devices + console.log("Found", devices.length, "available Bluetooth devices") + } + } + } + } } \ No newline at end of file diff --git a/Services/WeatherService.qml b/Services/WeatherService.qml index 42047e0c..774f4a53 100644 --- a/Services/WeatherService.qml +++ b/Services/WeatherService.qml @@ -21,6 +21,62 @@ Singleton { pressure: 0 }) + // Weather icon mapping (based on wttr.in weather codes) + property var weatherIcons: ({ + "113": "clear_day", + "116": "partly_cloudy_day", + "119": "cloud", + "122": "cloud", + "143": "foggy", + "176": "rainy", + "179": "rainy", + "182": "rainy", + "185": "rainy", + "200": "thunderstorm", + "227": "cloudy_snowing", + "230": "snowing_heavy", + "248": "foggy", + "260": "foggy", + "263": "rainy", + "266": "rainy", + "281": "rainy", + "284": "rainy", + "293": "rainy", + "296": "rainy", + "299": "rainy", + "302": "weather_hail", + "305": "rainy", + "308": "weather_hail", + "311": "rainy", + "314": "rainy", + "317": "rainy", + "320": "cloudy_snowing", + "323": "cloudy_snowing", + "326": "cloudy_snowing", + "329": "snowing_heavy", + "332": "snowing_heavy", + "335": "snowing_heavy", + "338": "snowing_heavy", + "350": "rainy", + "353": "rainy", + "356": "weather_hail", + "359": "weather_hail", + "362": "rainy", + "365": "weather_hail", + "368": "cloudy_snowing", + "371": "snowing_heavy", + "374": "weather_hail", + "377": "weather_hail", + "386": "thunderstorm", + "389": "thunderstorm", + "392": "snowing_heavy", + "395": "snowing_heavy" + }) + + function getWeatherIcon(code) { + return weatherIcons[code] || "cloud" + } + Process { id: weatherFetcher command: ["bash", "-c", "curl -s 'wttr.in/?format=j1' | jq '{current: .current_condition[0], location: .nearest_area[0], astronomy: .weather[0].astronomy[0]}'"] diff --git a/Services/qmldir b/Services/qmldir index 57ac2e08..6b37b1bf 100644 --- a/Services/qmldir +++ b/Services/qmldir @@ -6,4 +6,5 @@ singleton NetworkService 1.0 NetworkService.qml singleton WifiService 1.0 WifiService.qml singleton AudioService 1.0 AudioService.qml singleton BluetoothService 1.0 BluetoothService.qml -singleton BrightnessService 1.0 BrightnessService.qml \ No newline at end of file +singleton BrightnessService 1.0 BrightnessService.qml +singleton BatteryService 1.0 BatteryService.qml \ No newline at end of file diff --git a/Widgets/BatteryControlPopup.qml b/Widgets/BatteryControlPopup.qml new file mode 100644 index 00000000..94bdc0dc --- /dev/null +++ b/Widgets/BatteryControlPopup.qml @@ -0,0 +1,367 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import Quickshell.Widgets +import Quickshell.Wayland +import "../Common" +import "../Services" + +PanelWindow { + id: batteryControlPopup + + visible: root.batteryPopupVisible && BatteryService.batteryAvailable + + implicitWidth: 400 + implicitHeight: 300 + + WlrLayershell.layer: WlrLayershell.Overlay + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + color: "transparent" + + anchors { + top: true + left: true + right: true + bottom: true + } + + Rectangle { + width: Math.min(380, parent.width - Theme.spacingL * 2) + height: Math.min(450, parent.height - Theme.barHeight - Theme.spacingS * 2) + x: Math.max(Theme.spacingL, parent.width - width - Theme.spacingL) + y: Theme.barHeight + Theme.spacingS + color: Theme.surfaceContainer + radius: Theme.cornerRadiusLarge + border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12) + border.width: 1 + + opacity: root.batteryPopupVisible ? 1.0 : 0.0 + scale: root.batteryPopupVisible ? 1.0 : 0.85 + + Behavior on opacity { + NumberAnimation { + duration: Theme.mediumDuration + easing.type: Theme.emphasizedEasing + } + } + + Behavior on scale { + NumberAnimation { + duration: Theme.mediumDuration + easing.type: Theme.emphasizedEasing + } + } + + ScrollView { + anchors.fill: parent + anchors.margins: Theme.spacingL + clip: true + + Column { + width: parent.width + spacing: Theme.spacingL + + // Header + Row { + width: parent.width + + Text { + text: "Battery Information" + font.pixelSize: Theme.fontSizeLarge + color: Theme.surfaceText + font.weight: Font.Medium + anchors.verticalCenter: parent.verticalCenter + } + + Item { width: parent.width - 200; height: 1 } + + Rectangle { + width: 32 + height: 32 + radius: 16 + color: closeBatteryArea.containsMouse ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12) : "transparent" + + Text { + anchors.centerIn: parent + text: "close" + font.family: Theme.iconFont + font.pixelSize: Theme.iconSize - 4 + color: closeBatteryArea.containsMouse ? Theme.error : Theme.surfaceText + } + + MouseArea { + id: closeBatteryArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.batteryPopupVisible = false + } + } + } + } + + // Battery status card + Rectangle { + width: parent.width + height: 120 + radius: Theme.cornerRadius + color: Qt.rgba(Theme.surfaceContainer.r, Theme.surfaceContainer.g, Theme.surfaceContainer.b, 0.5) + border.color: BatteryService.isCharging ? Theme.primary : (BatteryService.isLowBattery ? Theme.error : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)) + border.width: BatteryService.isCharging || BatteryService.isLowBattery ? 2 : 1 + + Row { + anchors.fill: parent + anchors.margins: Theme.spacingL + spacing: Theme.spacingL + + // Large battery icon + Text { + text: BatteryService.getBatteryIcon() + font.family: Theme.iconFont + font.pixelSize: 48 + color: { + if (BatteryService.isLowBattery && !BatteryService.isCharging) return Theme.error + if (BatteryService.isCharging) return Theme.primary + return Theme.surfaceText + } + anchors.verticalCenter: parent.verticalCenter + } + + Column { + spacing: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + + Text { + text: BatteryService.batteryLevel + "%" + font.pixelSize: Theme.fontSizeXLarge + color: { + if (BatteryService.isLowBattery && !BatteryService.isCharging) return Theme.error + if (BatteryService.isCharging) return Theme.primary + return Theme.surfaceText + } + font.weight: Font.Bold + } + + Text { + text: BatteryService.batteryStatus + font.pixelSize: Theme.fontSizeLarge + color: { + if (BatteryService.isLowBattery && !BatteryService.isCharging) return Theme.error + if (BatteryService.isCharging) return Theme.primary + return Theme.surfaceText + } + font.weight: Font.Medium + } + + Text { + text: { + let time = BatteryService.formatTimeRemaining() + if (time !== "Unknown") { + return BatteryService.isCharging ? "Time until full: " + time : "Time remaining: " + time + } + return "" + } + font.pixelSize: Theme.fontSizeMedium + color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7) + visible: text.length > 0 + } + } + } + } + + // Battery details + Column { + width: parent.width + spacing: Theme.spacingM + + Text { + text: "Battery Details" + font.pixelSize: Theme.fontSizeLarge + color: Theme.surfaceText + font.weight: Font.Medium + } + + Grid { + width: parent.width + columns: 2 + columnSpacing: Theme.spacingL + rowSpacing: Theme.spacingM + + // Technology + Column { + spacing: 2 + + Text { + text: "Technology" + font.pixelSize: Theme.fontSizeSmall + color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7) + font.weight: Font.Medium + } + + Text { + text: BatteryService.batteryTechnology + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + } + } + + // Cycle count + Column { + spacing: 2 + + Text { + text: "Cycle Count" + font.pixelSize: Theme.fontSizeSmall + color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7) + font.weight: Font.Medium + } + + Text { + text: BatteryService.cycleCount > 0 ? BatteryService.cycleCount.toString() : "Unknown" + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + } + } + + // Health + Column { + spacing: 2 + + Text { + text: "Health" + font.pixelSize: Theme.fontSizeSmall + color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7) + font.weight: Font.Medium + } + + Text { + text: BatteryService.batteryHealth + "%" + font.pixelSize: Theme.fontSizeMedium + color: BatteryService.batteryHealth < 80 ? Theme.error : Theme.surfaceText + } + } + + // Capacity + Column { + spacing: 2 + + Text { + text: "Capacity" + font.pixelSize: Theme.fontSizeSmall + color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7) + font.weight: Font.Medium + } + + Text { + text: BatteryService.batteryCapacity > 0 ? BatteryService.batteryCapacity + " mWh" : "Unknown" + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + } + } + } + } + + // Power profiles (if available) + Column { + width: parent.width + spacing: Theme.spacingM + visible: BatteryService.powerProfiles.length > 0 + + Text { + text: "Power Profile" + font.pixelSize: Theme.fontSizeLarge + color: Theme.surfaceText + font.weight: Font.Medium + } + + Column { + width: parent.width + spacing: Theme.spacingS + + Repeater { + model: BatteryService.powerProfiles + + Rectangle { + width: parent.width + height: 50 + radius: Theme.cornerRadius + color: profileArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : + (modelData === BatteryService.activePowerProfile ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.16) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08)) + border.color: modelData === BatteryService.activePowerProfile ? Theme.primary : "transparent" + border.width: 2 + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingL + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM + + Text { + text: { + switch (modelData) { + case "power-saver": return "battery_saver" + case "balanced": return "battery_std" + case "performance": return "flash_on" + default: return "settings" + } + } + font.family: Theme.iconFont + font.pixelSize: Theme.iconSize + color: modelData === BatteryService.activePowerProfile ? Theme.primary : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + Column { + spacing: 2 + anchors.verticalCenter: parent.verticalCenter + + Text { + text: { + switch (modelData) { + case "power-saver": return "Power Saver" + case "balanced": return "Balanced" + case "performance": return "Performance" + default: return modelData.charAt(0).toUpperCase() + modelData.slice(1) + } + } + font.pixelSize: Theme.fontSizeMedium + color: modelData === BatteryService.activePowerProfile ? Theme.primary : Theme.surfaceText + font.weight: modelData === BatteryService.activePowerProfile ? Font.Medium : Font.Normal + } + + Text { + text: { + switch (modelData) { + case "power-saver": return "Extend battery life" + case "balanced": return "Balance power and performance" + case "performance": return "Prioritize performance" + default: return "Custom power profile" + } + } + font.pixelSize: Theme.fontSizeSmall + color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7) + } + } + } + + MouseArea { + id: profileArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: { + BatteryService.setBatteryProfile(modelData) + } + } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/Widgets/BatteryWidget.qml b/Widgets/BatteryWidget.qml new file mode 100644 index 00000000..538897b2 --- /dev/null +++ b/Widgets/BatteryWidget.qml @@ -0,0 +1,129 @@ +import QtQuick +import QtQuick.Controls +import "../Common" +import "../Services" + +Rectangle { + id: batteryWidget + + property bool batteryPopupVisible: false + + width: Theme.barHeight - Theme.spacingS + height: Theme.barHeight - Theme.spacingS + radius: Theme.cornerRadiusSmall + color: batteryArea.containsMouse ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : "transparent" + visible: BatteryService.batteryAvailable + + Row { + anchors.centerIn: parent + spacing: Theme.spacingXS + + // Battery icon + Text { + text: BatteryService.getBatteryIcon() + font.family: Theme.iconFont + font.pixelSize: Theme.iconSize + color: { + if (!BatteryService.batteryAvailable) return Theme.surfaceText + if (BatteryService.isLowBattery && !BatteryService.isCharging) return Theme.error + if (BatteryService.isCharging) return Theme.primary + return Theme.surfaceText + } + anchors.verticalCenter: parent.verticalCenter + + // Subtle animation for charging + RotationAnimation on rotation { + running: BatteryService.isCharging + loops: Animation.Infinite + from: 0 + to: 360 + duration: 8000 + easing.type: Easing.Linear + } + } + + // Battery percentage + Text { + text: BatteryService.batteryLevel + "%" + font.pixelSize: Theme.fontSizeSmall + color: { + if (!BatteryService.batteryAvailable) return Theme.surfaceText + if (BatteryService.isLowBattery && !BatteryService.isCharging) return Theme.error + if (BatteryService.isCharging) return Theme.primary + return Theme.surfaceText + } + font.weight: Font.Medium + anchors.verticalCenter: parent.verticalCenter + visible: BatteryService.batteryAvailable + } + } + + MouseArea { + id: batteryArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: { + batteryPopupVisible = !batteryPopupVisible + root.batteryPopupVisible = batteryPopupVisible + } + } + + // Tooltip on hover + Rectangle { + id: batteryTooltip + width: Math.max(120, tooltipText.contentWidth + Theme.spacingM * 2) + height: tooltipText.contentHeight + Theme.spacingS * 2 + radius: Theme.cornerRadius + color: Theme.surfaceContainer + border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2) + border.width: 1 + visible: batteryArea.containsMouse && !batteryPopupVisible && BatteryService.batteryAvailable + + anchors.bottom: parent.top + anchors.bottomMargin: Theme.spacingS + anchors.horizontalCenter: parent.horizontalCenter + + opacity: batteryArea.containsMouse ? 1.0 : 0.0 + + Behavior on opacity { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + + Column { + anchors.centerIn: parent + spacing: 2 + + Text { + id: tooltipText + text: { + if (!BatteryService.batteryAvailable) return "No battery" + + let status = BatteryService.batteryStatus + let level = BatteryService.batteryLevel + "%" + let time = BatteryService.formatTimeRemaining() + + if (time !== "Unknown") { + return status + " • " + level + " • " + time + } else { + return status + " • " + level + } + } + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + horizontalAlignment: Text.AlignHCenter + } + } + } + + Behavior on color { + ColorAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } +} \ No newline at end of file diff --git a/Widgets/CalendarPopup.qml b/Widgets/CalendarPopup.qml index 93adf3b4..0adbcde9 100644 --- a/Widgets/CalendarPopup.qml +++ b/Widgets/CalendarPopup.qml @@ -6,6 +6,7 @@ import Quickshell.Widgets import Quickshell.Wayland import Quickshell.Services.Mpris import "../Common" +import "../Services" PanelWindow { id: calendarPopup @@ -285,7 +286,7 @@ PanelWindow { anchors.verticalCenter: parent.verticalCenter Text { - text: root.weatherIcons[root.weather.wCode] || "clear_day" + text: WeatherService.getWeatherIcon(root.weather.wCode) font.family: Theme.iconFont font.pixelSize: Theme.iconSize + 4 color: Theme.primary diff --git a/Widgets/ControlCenterPopup.qml b/Widgets/ControlCenterPopup.qml index cac24a68..a4c290a2 100644 --- a/Widgets/ControlCenterPopup.qml +++ b/Widgets/ControlCenterPopup.qml @@ -1217,6 +1217,221 @@ PanelWindow { } } } + + // Available devices for pairing (when enabled) + Column { + width: parent.width + spacing: Theme.spacingM + visible: root.bluetoothEnabled + + Row { + width: parent.width + spacing: Theme.spacingM + + Text { + text: "Available Devices" + font.pixelSize: Theme.fontSizeLarge + color: Theme.surfaceText + font.weight: Font.Medium + anchors.verticalCenter: parent.verticalCenter + } + + Item { width: 1; height: 1 } + + Rectangle { + width: Math.max(100, scanText.contentWidth + Theme.spacingM * 2) + height: 32 + radius: Theme.cornerRadius + color: scanArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) + border.color: Theme.primary + border.width: 1 + + Row { + anchors.centerIn: parent + spacing: Theme.spacingXS + + Text { + text: BluetoothService.scanning ? "search" : "bluetooth_searching" + font.family: Theme.iconFont + font.pixelSize: Theme.iconSize - 4 + color: Theme.primary + anchors.verticalCenter: parent.verticalCenter + + RotationAnimation on rotation { + running: BluetoothService.scanning + loops: Animation.Infinite + from: 0 + to: 360 + duration: 2000 + } + } + + Text { + id: scanText + text: BluetoothService.scanning ? "Scanning..." : "Scan" + font.pixelSize: Theme.fontSizeMedium + color: Theme.primary + font.weight: Font.Medium + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: scanArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: !BluetoothService.scanning + + onClicked: { + BluetoothService.startDiscovery() + } + } + } + } + + // Available devices list + Repeater { + model: BluetoothService.availableDevices + + Rectangle { + width: parent.width + height: 70 + radius: Theme.cornerRadius + color: availableDeviceArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : + (modelData.paired ? Qt.rgba(Theme.secondary.r, Theme.secondary.g, Theme.secondary.b, 0.12) : Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08)) + border.color: modelData.paired ? Theme.secondary : (modelData.canPair ? Theme.primary : "transparent") + border.width: 1 + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM + + Text { + text: { + switch (modelData.type) { + case "headset": return "headset" + case "mouse": return "mouse" + case "keyboard": return "keyboard" + case "phone": return "smartphone" + case "watch": return "watch" + case "speaker": return "speaker" + case "tv": return "tv" + default: return "bluetooth" + } + } + font.family: Theme.iconFont + font.pixelSize: Theme.iconSize + color: modelData.paired ? Theme.secondary : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + Column { + spacing: 2 + anchors.verticalCenter: parent.verticalCenter + + Text { + text: modelData.name + font.pixelSize: Theme.fontSizeMedium + color: modelData.paired ? Theme.secondary : Theme.surfaceText + font.weight: modelData.paired ? Font.Medium : Font.Normal + } + + Row { + spacing: Theme.spacingXS + + Text { + text: { + if (modelData.paired && modelData.connected) return "Connected" + if (modelData.paired) return "Paired" + return "Signal: " + modelData.signalStrength + } + font.pixelSize: Theme.fontSizeSmall + color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7) + } + + Text { + text: modelData.rssi !== 0 ? "• " + modelData.rssi + " dBm" : "" + font.pixelSize: Theme.fontSizeSmall + color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5) + visible: modelData.rssi !== 0 + } + } + } + } + + // Action button on the right + Rectangle { + width: 80 + height: 28 + radius: Theme.cornerRadiusSmall + anchors.right: parent.right + anchors.rightMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + color: actionButtonArea.containsMouse ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) : "transparent" + border.color: Theme.primary + border.width: 1 + visible: modelData.canPair || modelData.paired + + Text { + anchors.centerIn: parent + text: modelData.paired ? (modelData.connected ? "Disconnect" : "Connect") : "Pair" + font.pixelSize: Theme.fontSizeSmall + color: Theme.primary + font.weight: Font.Medium + } + + MouseArea { + id: actionButtonArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: { + if (modelData.paired) { + if (modelData.connected) { + BluetoothService.toggleBluetoothDevice(modelData.mac) + } else { + BluetoothService.connectDevice(modelData.mac) + } + } else { + BluetoothService.pairDevice(modelData.mac) + } + } + } + } + + MouseArea { + id: availableDeviceArea + anchors.fill: parent + anchors.rightMargin: 90 // Don't overlap with action button + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: { + if (modelData.paired) { + BluetoothService.toggleBluetoothDevice(modelData.mac) + } else { + BluetoothService.pairDevice(modelData.mac) + } + } + } + } + } + + // No devices message + Text { + text: "No devices found. Put your device in pairing mode and click Scan." + font.pixelSize: Theme.fontSizeMedium + color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7) + visible: BluetoothService.availableDevices.length === 0 && !BluetoothService.scanning + wrapMode: Text.WordWrap + width: parent.width + horizontalAlignment: Text.AlignHCenter + } + } } } diff --git a/Widgets/InputDialog.qml b/Widgets/InputDialog.qml new file mode 100644 index 00000000..0616c9d9 --- /dev/null +++ b/Widgets/InputDialog.qml @@ -0,0 +1,341 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import Quickshell.Widgets +import Quickshell.Wayland +import "../Common" + +PanelWindow { + id: inputDialog + + property bool dialogVisible: false + property string dialogTitle: "Input Required" + property string dialogSubtitle: "Please enter the required information" + property string inputPlaceholder: "Enter text" + property string inputValue: "" + property bool isPassword: false + property string confirmButtonText: "Confirm" + property string cancelButtonText: "Cancel" + + signal confirmed(string value) + signal cancelled() + + visible: dialogVisible + anchors { + top: true + left: true + right: true + bottom: true + } + + WlrLayershell.layer: WlrLayershell.Overlay + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: dialogVisible ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + + color: "transparent" + + onVisibleChanged: { + if (visible) { + textInput.forceActiveFocus() + textInput.text = inputValue + } + } + + function showDialog(title, subtitle, placeholder, isPass, confirmText, cancelText) { + dialogTitle = title || "Input Required" + dialogSubtitle = subtitle || "Please enter the required information" + inputPlaceholder = placeholder || "Enter text" + isPassword = isPass || false + confirmButtonText = confirmText || "Confirm" + cancelButtonText = cancelText || "Cancel" + inputValue = "" + dialogVisible = true + } + + function hideDialog() { + dialogVisible = false + inputValue = "" + } + + Rectangle { + anchors.fill: parent + color: Qt.rgba(0, 0, 0, 0.5) + opacity: dialogVisible ? 1.0 : 0.0 + + Behavior on opacity { + NumberAnimation { + duration: Theme.mediumDuration + easing.type: Theme.standardEasing + } + } + + MouseArea { + anchors.fill: parent + onClicked: { + inputDialog.cancelled() + hideDialog() + } + } + } + + Rectangle { + width: Math.min(400, parent.width - Theme.spacingL * 2) + height: Math.min(250, parent.height - Theme.spacingL * 2) + anchors.centerIn: parent + color: Theme.surfaceContainer + radius: Theme.cornerRadiusLarge + border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12) + border.width: 1 + + opacity: dialogVisible ? 1.0 : 0.0 + scale: dialogVisible ? 1.0 : 0.9 + + Behavior on opacity { + NumberAnimation { + duration: Theme.mediumDuration + easing.type: Theme.emphasizedEasing + } + } + + Behavior on scale { + NumberAnimation { + duration: Theme.mediumDuration + easing.type: Theme.emphasizedEasing + } + } + + Column { + anchors.fill: parent + anchors.margins: Theme.spacingL + spacing: Theme.spacingL + + // Header + Row { + width: parent.width + + Column { + width: parent.width - 40 + spacing: Theme.spacingXS + + Text { + text: dialogTitle + font.pixelSize: Theme.fontSizeLarge + color: Theme.surfaceText + font.weight: Font.Medium + } + + Text { + text: dialogSubtitle + font.pixelSize: Theme.fontSizeMedium + color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7) + width: parent.width + elide: Text.ElideRight + wrapMode: Text.WordWrap + maximumLineCount: 2 + } + } + + Rectangle { + width: 32 + height: 32 + radius: 16 + color: closeDialogArea.containsMouse ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12) : "transparent" + + Text { + anchors.centerIn: parent + text: "close" + font.family: Theme.iconFont + font.pixelSize: Theme.iconSize - 4 + color: closeDialogArea.containsMouse ? Theme.error : Theme.surfaceText + } + + MouseArea { + id: closeDialogArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + inputDialog.cancelled() + hideDialog() + } + } + } + } + + // Text input + Rectangle { + width: parent.width + height: 50 + radius: Theme.cornerRadius + color: Qt.rgba(Theme.surfaceVariant.r, Theme.surfaceVariant.g, Theme.surfaceVariant.b, 0.08) + border.color: textInput.activeFocus ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12) + border.width: textInput.activeFocus ? 2 : 1 + + TextInput { + id: textInput + anchors.fill: parent + anchors.margins: Theme.spacingM + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + echoMode: isPassword && !showPasswordCheckbox.checked ? TextInput.Password : TextInput.Normal + verticalAlignment: TextInput.AlignVCenter + cursorVisible: activeFocus + selectByMouse: true + + Text { + anchors.fill: parent + text: inputPlaceholder + font: parent.font + color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.5) + verticalAlignment: Text.AlignVCenter + visible: parent.text.length === 0 + } + + onTextChanged: { + inputValue = text + } + + onAccepted: { + inputDialog.confirmed(inputValue) + hideDialog() + } + + Component.onCompleted: { + if (dialogVisible) { + forceActiveFocus() + } + } + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.IBeamCursor + onClicked: { + textInput.forceActiveFocus() + } + } + } + + // Show password checkbox (only visible for password inputs) + Row { + spacing: Theme.spacingS + visible: isPassword + + Rectangle { + id: showPasswordCheckbox + property bool checked: false + + width: 20 + height: 20 + radius: 4 + color: checked ? Theme.primary : "transparent" + border.color: checked ? Theme.primary : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.5) + border.width: 2 + + Text { + anchors.centerIn: parent + text: "check" + font.family: Theme.iconFont + font.pixelSize: 12 + color: Theme.background + visible: parent.checked + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + showPasswordCheckbox.checked = !showPasswordCheckbox.checked + } + } + } + + Text { + text: "Show password" + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + } + + // Buttons + Item { + width: parent.width + height: 40 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingM + + Rectangle { + width: Math.max(70, cancelText.contentWidth + Theme.spacingM * 2) + height: 36 + radius: Theme.cornerRadius + color: cancelArea.containsMouse ? Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.08) : "transparent" + border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2) + border.width: 1 + + Text { + id: cancelText + anchors.centerIn: parent + text: cancelButtonText + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + font.weight: Font.Medium + } + + MouseArea { + id: cancelArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + inputDialog.cancelled() + hideDialog() + } + } + } + + Rectangle { + width: Math.max(80, confirmText.contentWidth + Theme.spacingM * 2) + height: 36 + radius: Theme.cornerRadius + color: confirmArea.containsMouse ? Qt.darker(Theme.primary, 1.1) : Theme.primary + enabled: inputValue.length > 0 + opacity: enabled ? 1.0 : 0.5 + + Text { + id: confirmText + anchors.centerIn: parent + text: confirmButtonText + font.pixelSize: Theme.fontSizeMedium + color: Theme.background + font.weight: Font.Medium + } + + MouseArea { + id: confirmArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: parent.enabled + onClicked: { + inputDialog.confirmed(inputValue) + hideDialog() + } + } + + Behavior on color { + ColorAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/Widgets/NotificationHistoryPopup.qml b/Widgets/NotificationHistoryPopup.qml index f4874100..4eb61819 100644 --- a/Widgets/NotificationHistoryPopup.qml +++ b/Widgets/NotificationHistoryPopup.qml @@ -159,69 +159,88 @@ PanelWindow { anchors.margins: Theme.spacingM spacing: Theme.spacingM - // Notification icon using reference pattern + // Notification icon based on EXAMPLE NotificationAppIcon pattern Rectangle { - width: 32 - height: 32 - radius: Theme.cornerRadius - color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12) + width: 48 + height: 48 + radius: width / 2 // Fully rounded like EXAMPLE + color: Theme.primaryContainer anchors.verticalCenter: parent.verticalCenter - // Fallback material icon when no app icon + // Material icon fallback (when no app icon) Loader { active: !model.appIcon || model.appIcon === "" anchors.fill: parent sourceComponent: Text { anchors.centerIn: parent - text: model.appName ? model.appName.charAt(0).toUpperCase() : "notifications" - font.family: model.appName ? "Roboto" : Theme.iconFont - font.pixelSize: model.appName ? Theme.fontSizeMedium : 16 - color: Theme.primary - font.weight: Font.Medium + text: "notifications" + font.family: Theme.iconFont + font.pixelSize: 20 + color: Theme.primaryText horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } } - // App icon when no notification image + // App icon (when no notification image) Loader { active: model.appIcon && model.appIcon !== "" && (!model.image || model.image === "") - anchors.fill: parent - anchors.margins: 3 + anchors.centerIn: parent sourceComponent: IconImage { - anchors.fill: parent - anchors.margins: 4 + width: 32 + height: 32 asynchronous: true source: { if (!model.appIcon) return "" - // Skip file:// URLs as they're usually screenshots/images, not icons - if (model.appIcon.startsWith("file://")) return "" + // Handle file:// URLs directly + if (model.appIcon.startsWith("file://") || model.appIcon.startsWith("/")) { + return model.appIcon + } + // Otherwise treat as icon name return Quickshell.iconPath(model.appIcon, "image-missing") } } } - // Notification image with rounded corners + // Notification image (like Discord user avatar) - PRIORITY Loader { active: model.image && model.image !== "" anchors.fill: parent sourceComponent: Item { anchors.fill: parent + Image { id: historyNotifImage anchors.fill: parent + readonly property int size: parent.width + source: model.image || "" fillMode: Image.PreserveAspectCrop cache: false antialiasing: true asynchronous: true + smooth: true + + // Proper sizing like EXAMPLE + width: size + height: size + sourceSize.width: size + sourceSize.height: size layer.enabled: true layer.effect: OpacityMask { maskSource: Rectangle { - width: historyNotifImage.width - height: historyNotifImage.height - radius: Theme.cornerRadius + width: historyNotifImage.size + height: historyNotifImage.size + radius: historyNotifImage.size / 2 // Fully rounded + } + } + + onStatusChanged: { + if (status === Image.Error) { + console.warn("Failed to load notification image:", source) + } else if (status === Image.Ready) { + console.log("Notification image loaded:", source, "size:", sourceSize.width + "x" + sourceSize.height) } } } @@ -231,12 +250,17 @@ PanelWindow { active: model.appIcon && model.appIcon !== "" anchors.bottom: parent.bottom anchors.right: parent.right - anchors.margins: 2 sourceComponent: IconImage { - width: 12 - height: 12 + width: 16 + height: 16 asynchronous: true - source: model.appIcon ? Quickshell.iconPath(model.appIcon, "image-missing") : "" + source: { + if (!model.appIcon) return "" + if (model.appIcon.startsWith("file://") || model.appIcon.startsWith("/")) { + return model.appIcon + } + return Quickshell.iconPath(model.appIcon, "image-missing") + } } } } @@ -286,6 +310,11 @@ PanelWindow { cursorShape: Qt.PointingHandCursor onClicked: { + // Try to handle notification click if it has actions + if (model && root.handleNotificationClick) { + root.handleNotificationClick(model) + } + // Remove from history after handling notificationHistory.remove(index) } } @@ -300,10 +329,9 @@ PanelWindow { } // Empty state - properly centered - Rectangle { + Item { anchors.fill: parent visible: notificationHistory.count === 0 - color: "transparent" Column { anchors.centerIn: parent diff --git a/Widgets/NotificationPopup.qml b/Widgets/NotificationPopup.qml index c229d1ce..cb9715ce 100644 --- a/Widgets/NotificationPopup.qml +++ b/Widgets/NotificationPopup.qml @@ -5,6 +5,7 @@ import Quickshell import Quickshell.Widgets import Quickshell.Wayland import "../Common" +import "../Common/Utilities.js" as Utils PanelWindow { id: notificationPopup @@ -36,8 +37,7 @@ PanelWindow { color: Theme.surfaceContainer radius: Theme.cornerRadiusLarge - border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2) - border.width: 1 + border.width: 0 // Remove border completely opacity: root.showNotificationPopup ? 1.0 : 0.0 @@ -47,25 +47,59 @@ PanelWindow { MouseArea { anchors.fill: parent - onClicked: Utils.hideNotificationPopup() + anchors.rightMargin: 36 // Don't overlap with close button + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + + onClicked: { + console.log("Popup clicked!") + if (root.activeNotification) { + root.handleNotificationClick(root.activeNotification) + // Remove notification from history entirely + for (let i = 0; i < notificationHistory.count; i++) { + if (notificationHistory.get(i).id === root.activeNotification.id) { + notificationHistory.remove(i) + break + } + } + } + // Always hide popup after click + Utils.hideNotificationPopup() + } } - // Close button with cursor pointer - Text { + // Close button with hover styling + Rectangle { + width: 28 + height: 28 + radius: 14 anchors.right: parent.right anchors.top: parent.top anchors.margins: 8 - text: "×" - font.pixelSize: 16 - color: Theme.surfaceText + color: closeButtonArea.containsMouse ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12) : "transparent" + + Text { + anchors.centerIn: parent + text: "close" + font.family: Theme.iconFont + font.pixelSize: 16 + color: closeButtonArea.containsMouse ? Theme.error : Theme.surfaceText + } MouseArea { + id: closeButtonArea anchors.fill: parent - anchors.margins: -4 hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: Utils.hideNotificationPopup() } + + Behavior on color { + ColorAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } } // Content layout @@ -75,81 +109,89 @@ PanelWindow { anchors.rightMargin: 32 spacing: 12 - // Notification icon using reference pattern + // Notification icon based on EXAMPLE NotificationAppIcon pattern Rectangle { - width: 40 - height: 40 - radius: 8 - color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1) + width: 48 + height: 48 + radius: width / 2 // Fully rounded like EXAMPLE + color: Theme.primaryContainer anchors.verticalCenter: parent.verticalCenter - // Fallback material icon when no app icon + // Material icon fallback (when no app icon) Loader { - active: !root.activeNotification || root.activeNotification.appIcon === "" + active: !root.activeNotification || !root.activeNotification.appIcon || root.activeNotification.appIcon === "" anchors.fill: parent sourceComponent: Text { anchors.centerIn: parent text: "notifications" font.family: Theme.iconFont font.pixelSize: 20 - color: Theme.primary + color: Theme.primaryText horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } } - // App icon when no notification image + // App icon (when no notification image) Loader { - active: root.activeNotification && root.activeNotification.appIcon !== "" && (root.activeNotification.image === "" || !root.activeNotification.image) - anchors.fill: parent - anchors.margins: 4 + active: root.activeNotification && root.activeNotification.appIcon !== "" && (!root.activeNotification.image || root.activeNotification.image === "") + anchors.centerIn: parent sourceComponent: IconImage { - anchors.fill: parent + width: 32 + height: 32 asynchronous: true source: { - if (!root.activeNotification) return "" - let iconPath = root.activeNotification.appIcon - // Skip file:// URLs as they're usually screenshots/images, not icons - if (iconPath && iconPath.startsWith("file://")) return "" - return iconPath ? Quickshell.iconPath(iconPath, "image-missing") : "" + if (!root.activeNotification || !root.activeNotification.appIcon) return "" + let appIcon = root.activeNotification.appIcon + // Handle file:// URLs directly + if (appIcon.startsWith("file://") || appIcon.startsWith("/")) { + return appIcon + } + // Otherwise treat as icon name + return Quickshell.iconPath(appIcon, "image-missing") } } } - // Notification image with rounded corners + // Notification image (like Discord user avatar) - PRIORITY Loader { active: root.activeNotification && root.activeNotification.image !== "" anchors.fill: parent sourceComponent: Item { anchors.fill: parent - clip: true - Rectangle { + Image { + id: notifImage anchors.fill: parent - radius: 8 - color: "transparent" - clip: true + readonly property int size: parent.width - Image { - id: notifImage - anchors.fill: parent - source: root.activeNotification ? root.activeNotification.image : "" - fillMode: Image.PreserveAspectCrop - cache: false - antialiasing: true - asynchronous: true - smooth: true - - // Ensure minimum size and proper scaling - sourceSize.width: 64 - sourceSize.height: 64 - - onStatusChanged: { - if (status === Image.Error) { - console.warn("Failed to load notification image:", source) - } else if (status === Image.Ready) { - console.log("Notification image loaded:", source, "size:", sourceSize) - } + source: root.activeNotification ? root.activeNotification.image : "" + fillMode: Image.PreserveAspectCrop + cache: false + antialiasing: true + asynchronous: true + smooth: true + + // Proper sizing like EXAMPLE + width: size + height: size + sourceSize.width: size + sourceSize.height: size + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: notifImage.size + height: notifImage.size + radius: notifImage.size / 2 // Fully rounded + } + } + + onStatusChanged: { + if (status === Image.Error) { + console.warn("Failed to load notification image:", source) + } else if (status === Image.Ready) { + console.log("Notification image loaded:", source, "size:", sourceSize.width + "x" + sourceSize.height) } } } @@ -159,12 +201,18 @@ PanelWindow { active: root.activeNotification && root.activeNotification.appIcon !== "" anchors.bottom: parent.bottom anchors.right: parent.right - anchors.margins: 2 sourceComponent: IconImage { width: 16 height: 16 asynchronous: true - source: root.activeNotification ? Quickshell.iconPath(root.activeNotification.appIcon, "image-missing") : "" + source: { + if (!root.activeNotification || !root.activeNotification.appIcon) return "" + let appIcon = root.activeNotification.appIcon + if (appIcon.startsWith("file://") || appIcon.startsWith("/")) { + return appIcon + } + return Quickshell.iconPath(appIcon, "image-missing") + } } } } @@ -173,7 +221,7 @@ PanelWindow { // Text content Column { - width: parent.width - 52 + width: parent.width - 68 anchors.verticalCenter: parent.verticalCenter spacing: 4 diff --git a/Widgets/TopBar.qml b/Widgets/TopBar.qml index b6426c83..0c73f4f2 100644 --- a/Widgets/TopBar.qml +++ b/Widgets/TopBar.qml @@ -384,7 +384,7 @@ PanelWindow { // Weather icon when no media but weather available Text { - text: root.weatherIcons[root.weather.wCode] || "clear_day" + text: WeatherService.getWeatherIcon(root.weather.wCode) font.family: Theme.iconFont font.pixelSize: Theme.iconSize - 2 color: Theme.surfaceText @@ -702,6 +702,11 @@ PanelWindow { } } + // Battery Widget + BatteryWidget { + anchors.verticalCenter: parent.verticalCenter + } + // Control Center Indicators Rectangle { width: Math.max(80, controlIndicators.implicitWidth + Theme.spacingS * 2) diff --git a/Widgets/qmldir b/Widgets/qmldir index 8bd028b1..d41a3623 100644 --- a/Widgets/qmldir +++ b/Widgets/qmldir @@ -15,4 +15,7 @@ ControlCenterPopup 1.0 ControlCenterPopup.qml WifiPasswordDialog 1.0 WifiPasswordDialog.qml AppLauncher 1.0 AppLauncher.qml ClipboardHistory 1.0 ClipboardHistory.qml -CustomSlider 1.0 CustomSlider.qml \ No newline at end of file +CustomSlider 1.0 CustomSlider.qml +InputDialog 1.0 InputDialog.qml +BatteryWidget 1.0 BatteryWidget.qml +BatteryControlPopup 1.0 BatteryControlPopup.qml \ No newline at end of file diff --git a/shell.qml b/shell.qml index 09e98f86..b9b7c6f6 100644 --- a/shell.qml +++ b/shell.qml @@ -33,6 +33,7 @@ ShellRoot { property MprisPlayer activePlayer: MprisController.activePlayer property bool hasActiveMedia: activePlayer && (activePlayer.trackTitle || activePlayer.trackArtist) property bool controlCenterVisible: false + property bool batteryPopupVisible: false // Network properties from NetworkService property string networkStatus: NetworkService.networkStatus @@ -67,6 +68,65 @@ ShellRoot { property string wifiConnectionStatus: "" property bool wifiAutoRefreshEnabled: false + // Notification action handling - ALWAYS invoke action if exists + function handleNotificationClick(notifObj) { + console.log("Handling notification click for:", notifObj.appName) + + // ALWAYS try to invoke the action first (this is what real notifications do) + if (notifObj.notification && notifObj.actions && notifObj.actions.length > 0) { + // Look for "default" action first, then fallback to first action + let defaultAction = notifObj.actions.find(action => action.identifier === "default") || notifObj.actions[0] + if (defaultAction) { + console.log("Invoking notification action:", defaultAction.text, "identifier:", defaultAction.identifier) + attemptInvokeAction(notifObj.id, defaultAction.identifier) + return + } + } + + // If no action exists, check for URLs in notification text + let notificationText = (notifObj.summary || "") + " " + (notifObj.body || "") + let urlRegex = /(https?:\/\/[^\s]+)/g + let urls = notificationText.match(urlRegex) + + if (urls && urls.length > 0) { + console.log("Opening URL from notification:", urls[0]) + Qt.openUrlExternally(urls[0]) + return + } + + console.log("No action or URL found, notification will just dismiss") + } + + // Helper function to invoke notification actions (based on EXAMPLE) + function attemptInvokeAction(notifId, actionIdentifier) { + console.log("Attempting to invoke action:", actionIdentifier, "for notification:", notifId) + + // Find the notification in the server's tracked notifications + let trackedNotifications = notificationServer.trackedNotifications.values + let serverNotification = trackedNotifications.find(notif => notif.id === notifId) + + if (serverNotification) { + let action = serverNotification.actions.find(action => action.identifier === actionIdentifier) + if (action) { + console.log("Invoking action:", action.text) + action.invoke() + } else { + console.warn("Action not found:", actionIdentifier) + } + } else { + console.warn("Notification not found in server:", notifId, "Available IDs:", trackedNotifications.map(n => n.id)) + // Try to find by any available action + if (trackedNotifications.length > 0) { + let latestNotif = trackedNotifications[trackedNotifications.length - 1] + let action = latestNotif.actions.find(action => action.identifier === actionIdentifier) + if (action) { + console.log("Using latest notification for action") + action.invoke() + } + } + } + } + // Screen size breakpoints for responsive design property real screenWidth: Screen.width property bool isSmallScreen: screenWidth < 1200 @@ -79,57 +139,6 @@ ShellRoot { // Weather configuration property bool useFahrenheit: true // Default to Fahrenheit - // Weather icon mapping (based on wttr.in weather codes) - property var weatherIcons: ({ - "113": "clear_day", - "116": "partly_cloudy_day", - "119": "cloud", - "122": "cloud", - "143": "foggy", - "176": "rainy", - "179": "rainy", - "182": "rainy", - "185": "rainy", - "200": "thunderstorm", - "227": "cloudy_snowing", - "230": "snowing_heavy", - "248": "foggy", - "260": "foggy", - "263": "rainy", - "266": "rainy", - "281": "rainy", - "284": "rainy", - "293": "rainy", - "296": "rainy", - "299": "rainy", - "302": "weather_hail", - "305": "rainy", - "308": "weather_hail", - "311": "rainy", - "314": "rainy", - "317": "rainy", - "320": "cloudy_snowing", - "323": "cloudy_snowing", - "326": "cloudy_snowing", - "329": "snowing_heavy", - "332": "snowing_heavy", - "335": "snowing_heavy", - "338": "snowing_heavy", - "350": "rainy", - "353": "rainy", - "356": "weather_hail", - "359": "weather_hail", - "362": "rainy", - "365": "weather_hail", - "368": "cloudy_snowing", - "371": "snowing_heavy", - "374": "weather_hail", - "377": "weather_hail", - "386": "thunderstorm", - "389": "thunderstorm", - "392": "snowing_heavy", - "395": "snowing_heavy" - }) // WiFi Auto-refresh Timer Timer { @@ -172,7 +181,10 @@ ShellRoot { console.log("New notification from:", notification.appName || "Unknown", "Summary:", notification.summary || "No summary") - // Create notification object with correct properties + // CRITICAL: Mark notification as tracked so it stays in server list for actions + notification.tracked = true + + // Create notification object with correct properties (based on EXAMPLE) var notifObj = { "id": notification.id, "appName": notification.appName || "App", @@ -181,7 +193,13 @@ ShellRoot { "timestamp": new Date(), "appIcon": notification.appIcon || notification.icon || "", "icon": notification.icon || "", - "image": notification.image || "" + "image": notification.image || "", + "actions": notification.actions ? notification.actions.map(action => ({ + "identifier": action.identifier, + "text": action.text + })) : [], + "urgency": notification.urgency ? notification.urgency.toString() : "normal", + "notification": notification // Keep reference for action handling } // Add to history (prepend to show newest first) @@ -237,6 +255,10 @@ ShellRoot { NotificationHistoryPopup {} ControlCenterPopup {} WifiPasswordDialog {} + InputDialog { + id: globalInputDialog + } + BatteryControlPopup {} // Application and clipboard components AppLauncher {