diff --git a/habitica/.gitignore b/habitica/.gitignore new file mode 100644 index 000000000..c48d51a7e --- /dev/null +++ b/habitica/.gitignore @@ -0,0 +1,2 @@ +settings.json +cache/ diff --git a/habitica/BarWidget.qml b/habitica/BarWidget.qml new file mode 100644 index 000000000..df1becbc2 --- /dev/null +++ b/habitica/BarWidget.qml @@ -0,0 +1,171 @@ +import QtQuick +import QtQuick.Layouts +import Quickshell +import qs.Commons +import qs.Services.UI +import qs.Widgets + +NIconButton { + id: root + + property var pluginApi: null + property ShellScreen screen + property string widgetId: "" + property string section: "" + property int sectionWidgetIndex: -1 + property int sectionWidgetsCount: 0 + + readonly property var main: pluginApi?.mainInstance + readonly property bool configured: main?.isConfigured ?? false + readonly property bool hasError: main?.hasError ?? false + readonly property bool showNotificationBadge: pluginApi?.pluginSettings?.showNotificationBadge ?? true + readonly property bool colorizationEnabled: pluginApi?.pluginSettings?.colorizationEnabled ?? false + readonly property string colorizationIcon: pluginApi?.pluginSettings?.colorizationIcon ?? "Primary" + readonly property string colorizationBadge: pluginApi?.pluginSettings?.colorizationBadge ?? "Error" + readonly property string colorizationBadgeText: pluginApi?.pluginSettings?.colorizationBadgeText ?? "Primary" + + function getThemeColor(key) { + switch (key) { + case "Primary": return Color.mPrimary + case "Secondary": return Color.mSecondary + case "Tertiary": return Color.mTertiary + case "Error": return Color.mError + default: return Color.mOnSurface + } + } + + icon: "sword" + tooltipText: buildTooltip() + tooltipDirection: BarService.getTooltipDirection(screen?.name) + baseSize: Style.getCapsuleHeightForScreen(screen?.name) + applyUiScale: false + customRadius: Style.radiusL + colorBg: Style.capsuleColor + colorFg: { + if (hasError) return Color.mError + if (!configured) return Color.mOnSurfaceVariant + if (colorizationEnabled && colorizationIcon !== "None") return getThemeColor(colorizationIcon) + return Color.mOnSurface + } + colorBgHover: Color.mHover + colorFgHover: Color.mOnHover + colorBorder: "transparent" + colorBorderHover: "transparent" + + border.color: Style.capsuleBorderColor + border.width: Style.capsuleBorderWidth + + NPopupContextMenu { + id: contextMenu + + model: [ + { + "label": pluginApi?.tr("menu.openPanel"), + "action": "open-panel", + "icon": "sword", + "enabled": root.configured + }, + { + "label": pluginApi?.tr("menu.refresh"), + "action": "refresh", + "icon": "refresh", + "enabled": root.configured && !(root.main?.isLoading ?? false) + }, + { + "label": pluginApi?.tr("menu.openSettings"), + "action": "open-settings", + "icon": "settings" + }, + { + "label": pluginApi?.tr("menu.openHabitica"), + "action": "open-habitica", + "icon": "external-link" + } + ] + + onTriggered: action => { + contextMenu.close() + PanelService.closeContextMenu(screen) + + if (action === "open-panel") { + pluginApi.openPanel(root.screen, root) + } else if (action === "refresh") { + if (root.main && root.configured) { + root.main.refresh() + ToastService.showNotice(pluginApi?.tr("toast.refreshing")) + } + } else if (action === "open-settings") { + if (pluginApi?.manifest) { + BarService.openPluginSettings(screen, pluginApi.manifest) + } + } else if (action === "open-habitica") { + Qt.openUrlExternally("https://habitica.com") + } + } + } + + Rectangle { + id: badge + visible: root.showNotificationBadge && root.configured && (root.main?.pendingCount || 0) > 0 + anchors.right: parent.right + anchors.top: parent.top + anchors.rightMargin: 2 * Style.uiScaleRatio + anchors.topMargin: 2 * Style.uiScaleRatio + z: 2 + height: 14 * Style.uiScaleRatio + width: Math.max(height, badgeText.implicitWidth + 6 * Style.uiScaleRatio) + radius: height / 2 + color: root.colorizationEnabled && root.colorizationBadge !== "None" ? root.getThemeColor(root.colorizationBadge) : Color.mError + border.color: Color.mSurface + border.width: Style.uiScaleRatio + + NText { + id: badgeText + anchors.centerIn: parent + text: { + var count = root.main?.pendingCount || 0 + return count > 99 ? "99+" : count.toString() + } + pointSize: Style.fontSizeXS * 0.8 + font.weight: Font.Bold + color: root.colorizationEnabled && root.colorizationBadgeText !== "None" ? root.getThemeColor(root.colorizationBadgeText) : Color.mOnError + } + } + + onClicked: { + if (!root.configured) { + ToastService.showNotice(pluginApi?.tr("toast.configure")) + return + } + pluginApi.openPanel(root.screen, this) + } + + onRightClicked: { + PanelService.showContextMenu(contextMenu, root, screen) + } + + function buildTooltip() { + if (!configured) return pluginApi?.tr("tooltip.configure") + if (hasError) return pluginApi?.tr("tooltip.error") + (main?.errorMessage || pluginApi?.tr("tooltip.unknownError")) + if (main?.isLoading) return pluginApi?.tr("tooltip.loading") + + var tooltip = "Habitica - " + (main?.displayName() || pluginApi?.tr("tooltip.player")) + tooltip += "\n" + (main?.levelText() || pluginApi?.tr("tooltip.levelFallback")) + var hpFallback = pluginApi?.tr("tooltip.hpFallback") + var xpFallback = pluginApi?.tr("tooltip.xpFallback") + var goldFallback = pluginApi?.tr("tooltip.goldFallback") + tooltip += "\n" + (main?.hpText() || hpFallback) + " - " + (main?.xpText() || xpFallback) + " - " + (main?.goldText() || goldFallback) + tooltip += "\n" + (main?.dueDailiesCount || 0) + pluginApi?.tr("tooltip.dailies") + (main?.dueTodosCount || 0) + pluginApi?.tr("tooltip.todos") + + if (main?.lastFetchTimestamp) { + var age = Math.floor(Date.now() / 1000) - main.lastFetchTimestamp + var minutes = Math.floor(age / 60) + if (minutes < 1) tooltip += "\n" + pluginApi?.tr("tooltip.updatedNow") + else if (minutes < 60) tooltip += "\n" + pluginApi?.tr("tooltip.updatedMinutesPrefix") + minutes + pluginApi?.tr("tooltip.updatedMinutesSuffix") + else tooltip += "\n" + pluginApi?.tr("tooltip.updatedMinutesPrefix") + Math.floor(minutes / 60) + pluginApi?.tr("tooltip.updatedHoursSuffix") + } + + tooltip += "\n\n" + pluginApi?.tr("tooltip.actionsHint") + return tooltip + } +} diff --git a/habitica/HabiticaAvatar.qml b/habitica/HabiticaAvatar.qml new file mode 100644 index 000000000..95ab7b75a --- /dev/null +++ b/habitica/HabiticaAvatar.qml @@ -0,0 +1,195 @@ +import QtQuick +import qs.Commons + +Item { + id: root + + property var user: ({}) + property var stats: user?.stats || ({}) + property var layers: [] + + readonly property string assetBaseUrl: "https://habitica-assets.s3.amazonaws.com/mobileApp/images/" + readonly property bool hasAvatarData: !!user?.preferences + readonly property string currentMount: user?.items?.currentMount || "" + readonly property string currentPet: user?.items?.currentPet || "" + readonly property bool hasCurrentMount: currentMount !== "" + readonly property bool hasCurrentPet: currentPet !== "" + readonly property real sourceWidth: 141 + readonly property real sourceHeight: 147 + readonly property real scaleRatio: Math.min(width / sourceWidth, height / sourceHeight) + readonly property real characterPaddingTop: hasCurrentMount ? 0 : 24 + readonly property real mountOffsetY: hasCurrentMount ? 18 : 0 + readonly property real avatarLayerOffsetY: hasCurrentMount && currentMount.indexOf("Kangaroo") !== -1 ? 24 : 0 + readonly property string avatarSignature: JSON.stringify({ + preferences: user?.preferences || ({}), + currentMount: currentMount, + currentPet: currentPet, + gear: user?.items?.gear || ({}) + }) + + function pref(path, fallback) { + var current = user?.preferences || ({}) + for (var i = 0; i < path.length; i++) { + if (current === undefined || current === null || current[path[i]] === undefined) return fallback + current = current[path[i]] + } + return current + } + + function spriteSource(sprite) { + return sprite ? assetBaseUrl + sprite + ".png" : "" + } + + function addSprite(list, sprite) { + if (sprite && sprite !== "undefined" && sprite !== "null" && list.indexOf(sprite) === -1) list.push(sprite) + } + + function addGearSprite(list, sprite) { + if (!sprite || sprite.match(/_base_0$/)) return + addSprite(list, sprite) + } + + function gear(slot) { + var items = user?.items || ({}) + var allGear = items.gear || ({}) + var set = pref(["costume"], false) ? "costume" : "equipped" + var current = allGear[set] || allGear.equipped || ({}) + return current[slot] || "" + } + + function hair(slot) { + var value = pref(["hair", slot], 0) + if (!value) return "" + return "hair_" + slot + "_" + value + "_" + pref(["hair", "color"], "brown") + } + + function mountBody() { + return hasCurrentMount ? "Mount_Body_" + currentMount : "" + } + + function mountHead() { + return hasCurrentMount ? "Mount_Head_" + currentMount : "" + } + + function pet() { + return hasCurrentPet ? "Pet-" + currentPet : "" + } + + function characterLayers() { + var list = [] + var size = pref(["size"], "slim") + var chair = pref(["chair"], "none") + var armor = gear("armor") + var flower = pref(["hair", "flower"], 0) + + if (chair && chair !== "none") addSprite(list, "chair_" + chair) + addGearSprite(list, gear("back")) + addSprite(list, "skin_" + pref(["skin"], "ddc994") + (pref(["sleep"], false) ? "_sleep" : "")) + addSprite(list, size + "_shirt_" + pref(["shirt"], "blue")) + addSprite(list, "head_0") + if (armor) addSprite(list, size + "_" + armor) + addGearSprite(list, gear("back_collar")) + addSprite(list, hair("bangs")) + addSprite(list, hair("base")) + addSprite(list, hair("mustache")) + addSprite(list, hair("beard")) + addGearSprite(list, gear("body")) + addGearSprite(list, gear("eyewear")) + addGearSprite(list, gear("head")) + addGearSprite(list, gear("headAccessory")) + if (flower) addSprite(list, "hair_flower_" + flower) + addGearSprite(list, gear("shield")) + addGearSprite(list, gear("weapon")) + return list + } + + Item { + id: avatarViewport + anchors.fill: parent + visible: root.hasAvatarData + + Item { + id: avatarCanvas + width: root.sourceWidth + height: root.sourceHeight + x: (root.width - width * root.scaleRatio) / 2 + y: (root.height - height * root.scaleRatio) / 2 + scale: root.scaleRatio + transformOrigin: Item.TopLeft + + Image { + x: 0 + y: 0 + z: 0 + width: root.sourceWidth + height: root.sourceHeight + source: root.spriteSource("background_" + root.pref(["background"], "violet")) + asynchronous: true + cache: true + smooth: false + } + } + + Item { + id: characterSprites + parent: avatarCanvas + x: 24 + y: root.characterPaddingTop + width: 90 + height: 90 + + Image { + x: 0 + y: root.mountOffsetY + z: 1 + source: root.spriteSource(root.mountBody()) + asynchronous: true + cache: true + smooth: false + visible: root.hasCurrentMount + } + + Repeater { + model: root.layers + + Image { + x: 0 + y: root.avatarLayerOffsetY + z: 2 + source: root.spriteSource(modelData) + asynchronous: true + cache: true + smooth: false + } + } + + Image { + x: 0 + y: root.mountOffsetY + z: 3 + source: root.spriteSource(root.mountHead()) + asynchronous: true + cache: true + smooth: false + visible: root.hasCurrentMount + } + + } + + Image { + parent: avatarCanvas + x: 0 + y: root.sourceHeight - implicitHeight + z: 4 + source: root.spriteSource(root.pet()) + asynchronous: true + cache: true + smooth: false + visible: root.hasCurrentPet + } + } + + onAvatarSignatureChanged: layers = characterLayers() + + Component.onCompleted: layers = characterLayers() +} diff --git a/habitica/LICENSE b/habitica/LICENSE new file mode 100644 index 000000000..9ed8d246c --- /dev/null +++ b/habitica/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 vasqs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/habitica/Main.qml b/habitica/Main.qml new file mode 100644 index 000000000..05f1145bc --- /dev/null +++ b/habitica/Main.qml @@ -0,0 +1,461 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Commons +import qs.Services.UI + +Item { + id: root + + property var pluginApi: null + + readonly property bool debugMode: Quickshell.env("NOCTALIA_DEBUG") === "1" + readonly property string apiBaseUrl: "https://habitica.com/api/v3" + readonly property string userId: pluginApi?.pluginSettings?.habiticaUserId || "" + readonly property string apiToken: pluginApi?.pluginSettings?.habiticaApiToken || "" + readonly property bool isConfigured: userId.trim() !== "" && apiToken.trim() !== "" + readonly property int refreshInterval: Math.max(60, pluginApi?.pluginSettings?.refreshInterval || 300) + readonly property int maxDailies: pluginApi?.pluginSettings?.maxDailies || 8 + readonly property int maxTodos: pluginApi?.pluginSettings?.maxTodos || 8 + readonly property int maxHabits: pluginApi?.pluginSettings?.maxHabits || 8 + readonly property bool showHabits: pluginApi?.pluginSettings?.showHabits ?? false + readonly property bool showChecklistItems: pluginApi?.pluginSettings?.showChecklistItems ?? false + readonly property bool enableTagFilter: pluginApi?.pluginSettings?.enableTagFilter ?? false + readonly property string selectedTagId: pluginApi?.pluginSettings?.selectedTagId || "" + + readonly property string cacheDir: pluginApi?.pluginDir ? pluginApi.pluginDir + "/cache" : "" + readonly property string cachePath: cacheDir + "/habitica.json" + + property var user: ({}) + property var stats: ({}) + property var dailies: [] + property var todos: [] + property var habits: [] + property var tags: [] + property bool isLoading: false + property bool isScoring: false + property bool hasError: false + property string errorMessage: "" + property int lastFetchTimestamp: 0 + property string _currentRequest: "" + property var _currentCommand: ["true"] + property string _nextKind: "" + property string _nextMethod: "GET" + property string _nextPath: "" + property var _nextBody: null + property string _scoringTaskId: "" + + readonly property var visibleDailies: limitTasks(filterTasks(dailies, "daily"), maxDailies) + readonly property var visibleTodos: limitTasks(filterTasks(todos, "todo"), maxTodos) + readonly property var visibleHabits: showHabits ? limitTasks(filterTasks(habits, "habit"), maxHabits) : [] + readonly property int dueDailiesCount: filterTasks(dailies, "daily").length + readonly property int dueTodosCount: filterTasks(todos, "todo").length + readonly property int pendingCount: dueDailiesCount + dueTodosCount + readonly property string userFieldsBase: "stats,profile,preferences,notifications" + readonly property string userFieldsWithAvatar: "stats,profile,preferences,items,notifications" + readonly property int avatarRefreshInterval: 3600 + property int avatarLastFetchTimestamp: 0 + + function logDebug(msg) { + if (debugMode) Logger.d("Habitica", msg) + } + + function nowSeconds() { + return Math.floor(Date.now() / 1000) + } + + function isDueDaily(task) { + if (!task || task.type !== "daily" || task.completed) return false + if (!task.nextDue || task.nextDue.length === 0) return true + + var today = new Date() + today.setHours(0, 0, 0, 0) + for (var i = 0; i < task.nextDue.length; i++) { + var due = new Date(task.nextDue[i]) + due.setHours(0, 0, 0, 0) + if (due <= today) return true + } + return false + } + + function filterTasks(list, type) { + if (!list || list.length === 0) return [] + + var tagId = enableTagFilter ? selectedTagId : "" + return list.filter(function(task) { + if (!task) return false + if (tagId && (!task.tags || task.tags.indexOf(tagId) === -1)) return false + if (type === "daily") return isDueDaily(task) + if (type === "todo") return task.type === "todo" && !task.completed + if (type === "habit") return task.type === "habit" + return true + }) + } + + function limitTasks(list, limit) { + if (!list) return [] + return list.slice(0, Math.max(1, limit)) + } + + function displayName() { + return user?.profile?.name || user?.auth?.local?.username || "Habitica" + } + + function levelText() { + var lvl = stats?.lvl || 0 + return lvl > 0 ? "Level " + lvl : "Level --" + } + + function hpText() { + var hp = Math.max(0, Math.round(stats?.hp || 0)) + return hp + " HP" + } + + function goldText() { + var gp = Math.floor(stats?.gp || 0) + return gp + " gold" + } + + function xpText() { + var exp = Math.floor(stats?.exp || 0) + var next = Math.floor(stats?.toNextLevel || 0) + return next > 0 ? exp + " / " + next + " XP" : exp + " XP" + } + + function taskSummary(task) { + if (!task) return "" + var parts = [] + if (task.priority) parts.push("difficulty " + task.priority) + if (task.streak) parts.push("streak " + task.streak) + if (task.value !== undefined) parts.push("value " + Math.round(task.value * 10) / 10) + return parts.join(" - ") + } + + function sanitizeResponse(text) { + if (!text || text.trim() === "") return ({ success: false, message: "Empty response" }) + try { + return JSON.parse(text) + } catch (e) { + return ({ success: false, message: "Invalid JSON response" }) + } + } + + function authHeaders() { + var clientId = userId.trim() || "unknown-user" + return [ + "-H", "x-api-user: " + userId.trim(), + "-H", "x-api-key: " + apiToken.trim(), + "-H", "x-client: " + clientId + "-noctalia-habitica", + "-H", "Accept: application/json" + ] + } + + function requestCommand(method, path, body) { + var args = [ + "curl", "-sS", "--max-time", "30", + "-X", method + ].concat(authHeaders()) + + if (body !== undefined && body !== null) { + args = args.concat(["-H", "Content-Type: application/json", "--data", JSON.stringify(body)]) + } + + args.push(apiBaseUrl + path) + return args + } + + function shouldFetchAvatarData() { + return !user?.items || avatarLastFetchTimestamp <= 0 || nowSeconds() - avatarLastFetchTimestamp > avatarRefreshInterval + } + + function userRequestPath() { + var fields = shouldFetchAvatarData() ? userFieldsWithAvatar : userFieldsBase + return "/user?userFields=" + fields + } + + function runRequest(kind, method, path, body) { + if (!isConfigured) { + root.isLoading = false + root.hasError = true + root.errorMessage = "Configure your Habitica User ID and API Token in settings." + return + } + + if (apiProcess.running) return + _currentRequest = kind + _currentCommand = requestCommand(method, path, body) + apiProcess.running = true + } + + function scheduleRequest(kind, method, path, body) { + _nextKind = kind + _nextMethod = method + _nextPath = path + _nextBody = body === undefined ? null : body + nextRequestTimer.restart() + } + + function fetchAll(force) { + if (!isConfigured) { + root.hasError = false + root.errorMessage = "" + return + } + + if (!force && lastFetchTimestamp > 0 && nowSeconds() - lastFetchTimestamp < refreshInterval) { + logDebug("Skipping refresh; cache is fresh") + return + } + + if (apiProcess.running || isLoading || isScoring) return + + root.isLoading = true + root.hasError = false + root.errorMessage = "" + runRequest("user", "GET", userRequestPath()) + } + + function refresh() { + fetchAll(true) + } + + function continueFetch() { + if (_currentRequest === "user") { + scheduleRequest("dailies", "GET", "/tasks/user?type=dailys") + } else if (_currentRequest === "dailies") { + scheduleRequest("todos", "GET", "/tasks/user?type=todos") + } else if (_currentRequest === "todos" && showHabits) { + scheduleRequest("habits", "GET", "/tasks/user?type=habits") + } else if ((_currentRequest === "todos" || _currentRequest === "habits") && enableTagFilter) { + scheduleRequest("tags", "GET", "/tags") + } else { + finishFetch() + } + } + + function finishFetch() { + root.lastFetchTimestamp = nowSeconds() + root.isLoading = false + saveToCache() + } + + function handleApiResponse(kind, text, exitCode) { + if (exitCode !== 0) { + root.isLoading = false + root.isScoring = false + root.hasError = true + root.errorMessage = "Habitica request failed. Check your network connection." + return + } + + var response = sanitizeResponse(text) + if (!response.success) { + root.isLoading = false + root.isScoring = false + root.hasError = true + root.errorMessage = response.message || response.error || "Habitica request failed." + return + } + + if (kind === "user") { + var previousUser = root.user || ({}) + var nextUser = response.data || ({}) + if (!nextUser.items && previousUser.items) nextUser.items = previousUser.items + root.user = nextUser + root.stats = root.user.stats || ({}) + if (response.data?.items) root.avatarLastFetchTimestamp = nowSeconds() + continueFetch() + return + } + + if (kind === "dailies") { + root.dailies = response.data || [] + continueFetch() + return + } + + if (kind === "todos") { + root.todos = response.data || [] + continueFetch() + return + } + + if (kind === "habits") { + root.habits = response.data || [] + continueFetch() + return + } + + if (kind === "tags") { + root.tags = response.data || [] + finishFetch() + return + } + + if (kind === "score") { + root.isScoring = false + root._scoringTaskId = "" + if (response.data) { + root.stats = response.data + } + ToastService.showNotice("Habitica task scored") + root.isLoading = true + root.hasError = false + root.errorMessage = "" + scheduleRequest("user", "GET", userRequestPath()) + } + } + + function scoreTask(taskId, direction) { + if (!taskId || isScoring || apiProcess.running) return + root.isScoring = true + root._scoringTaskId = taskId + runRequest("score", "POST", "/tasks/" + encodeURIComponent(taskId) + "/score/" + direction) + } + + function completeTask(task) { + if (!task) return + scoreTask(task.id || task._id, "up") + } + + function scoreHabit(task, direction) { + if (!task) return + scoreTask(task.id || task._id, direction) + } + + function scoreChecklistItem(task, item) { + if (!task || !item || isScoring || apiProcess.running) return + root.isScoring = true + root._scoringTaskId = task.id || task._id + runRequest("score", "POST", "/tasks/" + encodeURIComponent(task.id || task._id) + "/checklist/" + encodeURIComponent(item.id) + "/score") + } + + function loadFromCache() { + try { + var content = cacheFile.text() + if (!content || content.trim() === "") return + var cached = JSON.parse(content) + root.user = cached.user || ({}) + root.stats = cached.stats || root.user.stats || ({}) + root.dailies = cached.dailies || [] + root.todos = cached.todos || [] + root.habits = cached.habits || [] + root.tags = cached.tags || [] + root.lastFetchTimestamp = cached.timestamp || 0 + root.avatarLastFetchTimestamp = cached.avatarTimestamp || 0 + logDebug("Loaded Habitica cache") + } catch (e) { + Logger.w("Habitica", "Failed to load cache: " + e) + } + } + + function saveToCache() { + if (!cacheDir || !cachePath) return + try { + cacheFile.setText(JSON.stringify({ + user: root.user, + stats: root.stats, + dailies: root.dailies, + todos: root.todos, + habits: root.habits, + tags: root.tags, + timestamp: root.lastFetchTimestamp, + avatarTimestamp: root.avatarLastFetchTimestamp + }, null, 2)) + } catch (e) { + Logger.w("Habitica", "Failed to save cache: " + e) + } + } + + FileView { + id: cacheFile + path: root.cachePath + watchChanges: false + + onLoaded: { + root.loadFromCache() + root.fetchAll(false) + } + + onLoadFailed: function(error) { + root.fetchAll(true) + } + } + + Process { + id: mkdirProcess + command: root.cacheDir ? ["mkdir", "-p", root.cacheDir] : ["true"] + + onExited: { + if (root.cachePath) { + cacheFile.reload() + } else { + root.fetchAll(true) + } + } + } + + Process { + id: apiProcess + command: root._currentCommand + stdout: StdioCollector {} + stderr: StdioCollector {} + + onExited: function(exitCode, exitStatus) { + var stdout = String(apiProcess.stdout.text || "") + var stderr = String(apiProcess.stderr.text || "").trim() + if (stderr.length > 0) root.logDebug(stderr) + root.handleApiResponse(root._currentRequest, stdout, exitCode) + } + } + + Timer { + id: nextRequestTimer + interval: 1 + repeat: false + onTriggered: { + if (apiProcess.running) { + nextRequestTimer.restart() + return + } + root.runRequest(root._nextKind, root._nextMethod, root._nextPath, root._nextBody) + } + } + + Timer { + id: refreshTimer + interval: root.refreshInterval * 1000 + repeat: true + running: root.isConfigured + onTriggered: root.fetchAll(false) + } + + onIsConfiguredChanged: { + if (isConfigured) { + mkdirProcess.running = true + } + } + + Component.onCompleted: { + if (cacheDir) { + mkdirProcess.running = true + } else { + fetchAll(true) + } + } + + IpcHandler { + target: "plugin:habitica" + + function refresh() { + root.refresh() + } + + function toggle() { + if (!root.pluginApi) return + root.pluginApi.withCurrentScreen(screen => { + root.pluginApi.togglePanel(screen) + }) + } + } +} diff --git a/habitica/Panel.qml b/habitica/Panel.qml new file mode 100644 index 000000000..17576b0aa --- /dev/null +++ b/habitica/Panel.qml @@ -0,0 +1,720 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import qs.Commons +import qs.Services.UI +import qs.Widgets + +Item { + id: root + + property var pluginApi: null + readonly property var geometryPlaceholder: panelContainer + readonly property bool allowAttach: true + property real contentPreferredWidth: 500 * Style.uiScaleRatio + property real contentPreferredHeight: 680 * Style.uiScaleRatio + + anchors.fill: parent + + readonly property var main: pluginApi?.mainInstance + readonly property bool configured: main?.isConfigured ?? false + readonly property bool showHabits: pluginApi?.pluginSettings?.showHabits ?? false + readonly property bool showChecklistItems: pluginApi?.pluginSettings?.showChecklistItems ?? false + + function closePanel() { + if (pluginApi) pluginApi.withCurrentScreen(s => pluginApi.closePanel(s)) + } + + function relativeUpdateText() { + if (!main?.lastFetchTimestamp) return "Not synced yet" + var age = Math.floor(Date.now() / 1000) - main.lastFetchTimestamp + var minutes = Math.floor(age / 60) + if (minutes < 1) return "Updated just now" + if (minutes < 60) return "Updated " + minutes + "m ago" + var hours = Math.floor(minutes / 60) + return "Updated " + hours + "h ago" + } + + function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)) + } + + function statRatio(value, maxValue) { + var max = Number(maxValue || 0) + if (max <= 0) return 0 + return clamp(Number(value || 0) / max, 0, 1) + } + + function hpRatio() { + return statRatio(main?.stats?.hp || 0, main?.stats?.maxHealth || 50) + } + + function xpRatio() { + return statRatio(main?.stats?.exp || 0, main?.stats?.toNextLevel || 1) + } + + function mpRatio() { + return statRatio(main?.stats?.mp || 0, main?.stats?.maxMP || 1) + } + + function mpText() { + if (!main?.stats || main.stats.mp === undefined) return "-- MP" + return Math.max(0, Math.round(main.stats.mp)) + " MP" + } + + function classText() { + var habitClass = main?.stats?.class || "" + if (!habitClass) return "" + return habitClass.charAt(0).toUpperCase() + habitClass.slice(1) + } + + function taskToneColor(task) { + var value = Number(task?.value || 0) + if (task?.type === "todo") return Color.mTertiary + if (value < -10) return Color.mError + if (value < -1) return Color.mTertiary + if (value < 1) return Color.mSecondary + return Color.mPrimary + } + + function taskToneLabel(task) { + var value = Number(task?.value || 0) + if (value < -10) return "high risk" + if (value < -1) return "weak" + if (value < 1) return "neutral" + if (value < 5) return "steady" + return "strong" + } + + Rectangle { + id: panelContainer + anchors.fill: parent + color: Color.mSurface + + ColumnLayout { + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginM + + RowLayout { + id: headerContent + Layout.fillWidth: true + spacing: Style.marginM + + ColumnLayout { + Layout.preferredWidth: 128 * Style.uiScaleRatio + spacing: Style.marginS + + Rectangle { + id: avatarFrame + Layout.preferredWidth: 112 * Style.uiScaleRatio + Layout.preferredHeight: 116 * Style.uiScaleRatio + radius: Style.radiusS + color: Qt.alpha(Color.mPrimary, 0.24) + border.color: Color.mPrimary + border.width: Style.uiScaleRatio * 2 + + HabiticaAvatar { + id: avatarImage + anchors.fill: parent + anchors.margins: 0 + user: root.main?.user || ({}) + stats: root.main?.stats || ({}) + } + + NIcon { + anchors.centerIn: parent + visible: !avatarImage.hasAvatarData + icon: "sword" + pointSize: Style.fontSizeXXL * 1.6 + color: Color.mPrimary + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + NIcon { + icon: "sword" + pointSize: Style.fontSizeL + color: Color.mPrimary + } + + NText { + Layout.fillWidth: true + text: root.configured ? root.main.levelText().replace("Level", "Lv.") + (root.classText() ? " " + root.classText() : "") : "Lv. --" + pointSize: Style.fontSizeS + font.weight: Font.Bold + color: Color.mOnSurface + elide: Text.ElideRight + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginS + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + NText { + Layout.fillWidth: true + text: root.configured ? root.main.displayName() : "Habitica" + pointSize: Style.fontSizeXL + font.weight: Font.Bold + color: Color.mOnSurface + elide: Text.ElideRight + } + + NIconButton { + icon: "refresh" + enabled: root.configured && !root.main?.isLoading && !root.main?.isScoring + tooltipText: pluginApi?.tr("panel.refreshTooltip") + baseSize: Style.baseWidgetSize * 0.85 + onClicked: root.main.refresh() + } + + NIconButton { + icon: "x" + tooltipText: pluginApi?.tr("panel.closeTooltip") + baseSize: Style.baseWidgetSize * 0.85 + onClicked: root.closePanel() + } + } + + StatBar { + Layout.fillWidth: true + label: pluginApi?.tr("panel.health") + value: root.main?.hpText() || "--" + ratio: root.hpRatio() + fillColor: Color.mError + } + + StatBar { + Layout.fillWidth: true + label: pluginApi?.tr("panel.experience") + value: root.main?.xpText() || "--" + ratio: root.xpRatio() + fillColor: Color.mPrimary + } + + StatBar { + Layout.fillWidth: true + label: pluginApi?.tr("panel.mana") + value: root.mpText() + ratio: root.mpRatio() + fillColor: Color.mSecondary + visible: root.main?.stats?.mp !== undefined + } + + RowLayout { + Layout.fillWidth: true + visible: root.configured + spacing: Style.marginL + + CurrencyPill { + icon: "coin" + value: root.main?.goldText().replace(" gold", "") || "--" + colorKey: Color.mTertiary + } + + Item { Layout.fillWidth: true } + + NText { + text: root.relativeUpdateText() + pointSize: Style.fontSizeXS + color: Color.mOnSurfaceVariant + } + } + } + } + + Rectangle { + visible: root.main?.hasError ?? false + Layout.fillWidth: true + implicitHeight: errorText.implicitHeight + Style.marginM * 2 + radius: Style.radiusM + color: Qt.alpha(Color.mError, 0.12) + border.color: Color.mError + border.width: Style.uiScaleRatio + + NText { + id: errorText + anchors.fill: parent + anchors.margins: Style.marginM + text: root.main?.errorMessage || "" + color: Color.mError + pointSize: Style.fontSizeS + wrapMode: Text.Wrap + } + } + + NScrollView { + Layout.fillWidth: true + Layout.fillHeight: true + horizontalPolicy: ScrollBar.AlwaysOff + verticalPolicy: ScrollBar.AsNeeded + reserveScrollbarSpace: false + gradientColor: Color.mSurface + + ColumnLayout { + width: parent.width + spacing: Style.marginM + + EmptyState { + visible: !root.configured + Layout.fillWidth: true + icon: "user-circle" + title: pluginApi?.tr("panel.credentialsRequired") + description: pluginApi?.tr("panel.credentialsDescription") + } + + EmptyState { + visible: root.configured && root.main?.isLoading && (root.main?.visibleDailies.length || 0) === 0 && (root.main?.visibleTodos.length || 0) === 0 + Layout.fillWidth: true + icon: "loader" + title: pluginApi?.tr("panel.loadingTitle") + description: pluginApi?.tr("panel.loadingDescription") + } + + TaskSection { + visible: root.configured + Layout.fillWidth: true + title: pluginApi?.tr("panel.today") + emptyText: pluginApi?.tr("panel.noDailies") + tasks: root.main?.visibleDailies || [] + kind: "daily" + main: root.main + showChecklistItems: root.showChecklistItems + } + + TaskSection { + visible: root.configured + Layout.fillWidth: true + title: pluginApi?.tr("panel.todos") + emptyText: pluginApi?.tr("panel.noTodos") + tasks: root.main?.visibleTodos || [] + kind: "todo" + main: root.main + showChecklistItems: root.showChecklistItems + } + + TaskSection { + visible: root.configured && root.showHabits + Layout.fillWidth: true + title: pluginApi?.tr("panel.habits") + emptyText: pluginApi?.tr("panel.noHabits") + tasks: root.main?.visibleHabits || [] + kind: "habit" + main: root.main + showChecklistItems: false + } + } + } + } + } + + component StatBar: Rectangle { + property string label + property string value: "" + property real ratio: 0 + property color fillColor: Color.mPrimary + + implicitHeight: statLayout.implicitHeight + Style.marginXS * 2 + radius: Style.radiusS + color: "transparent" + + ColumnLayout { + id: statLayout + anchors.fill: parent + anchors.margins: Style.marginXS + spacing: Style.marginXS + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + NText { + Layout.fillWidth: true + text: value + pointSize: Style.fontSizeS + font.weight: Font.Bold + color: Color.mOnSurface + elide: Text.ElideRight + } + + NText { + text: label + pointSize: Style.fontSizeXS + font.weight: Font.Bold + color: Color.mOnSurfaceVariant + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 8 * Style.uiScaleRatio + radius: height / 2 + color: Qt.alpha(Color.mOnSurface, 0.12) + clip: true + + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: parent.width * Math.max(0, Math.min(1, ratio)) + radius: parent.radius + color: fillColor + + Behavior on width { + NumberAnimation { + duration: Style.animationNormal + easing.type: Easing.OutCubic + } + } + } + } + } + } + + component CurrencyPill: RowLayout { + id: currencyRoot + + property string icon: "" + property string value: "" + property color colorKey: Color.mPrimary + + spacing: Style.marginXS + + Rectangle { + Layout.preferredWidth: 22 * Style.uiScaleRatio + Layout.preferredHeight: 22 * Style.uiScaleRatio + radius: width / 2 + color: Qt.alpha(colorKey, 0.18) + + NIcon { + anchors.centerIn: parent + icon: currencyRoot.icon + pointSize: Style.fontSizeS + color: currencyRoot.colorKey + } + } + + NText { + text: value + pointSize: Style.fontSizeS + font.weight: Font.Bold + color: currencyRoot.colorKey + } + } + + component EmptyState: NBox { + id: emptyRoot + + property string icon: "info-circle" + property string title: "" + property string description + + implicitHeight: emptyLayout.implicitHeight + Style.marginL + + ColumnLayout { + id: emptyLayout + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginS + + NIcon { + Layout.alignment: Qt.AlignHCenter + icon: emptyRoot.icon + pointSize: Style.fontSizeXXL + color: Color.mOnSurfaceVariant + } + + NText { + Layout.alignment: Qt.AlignHCenter + text: emptyRoot.title + pointSize: Style.fontSizeM + font.weight: Font.Bold + color: Color.mOnSurface + } + + NText { + Layout.fillWidth: true + text: emptyRoot.description + pointSize: Style.fontSizeS + color: Color.mOnSurfaceVariant + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + } + } + } + + component TaskSection: ColumnLayout { + property string title: "" + property string emptyText: "" + property var tasks: [] + property string kind: "" + property var main: null + property bool showChecklistItems: false + + spacing: Style.marginS + + RowLayout { + Layout.fillWidth: true + + NText { + text: title + pointSize: Style.fontSizeS + font.weight: Font.Bold + color: Color.mSecondary + } + + Item { Layout.fillWidth: true } + + Rectangle { + implicitWidth: countText.implicitWidth + Style.marginS * 2 + implicitHeight: countText.implicitHeight + Style.marginXS * 2 + radius: implicitHeight / 2 + color: Qt.alpha(Color.mSecondary, 0.14) + + NText { + id: countText + anchors.centerIn: parent + text: tasks.length.toString() + pointSize: Style.fontSizeXS + font.weight: Font.Bold + color: Color.mSecondary + } + } + } + + NBox { + visible: tasks.length === 0 + Layout.fillWidth: true + implicitHeight: emptyLabel.implicitHeight + Style.marginL + + NText { + id: emptyLabel + anchors.centerIn: parent + text: emptyText + pointSize: Style.fontSizeS + color: Color.mOnSurfaceVariant + } + } + + Repeater { + model: tasks + + Rectangle { + id: taskCard + Layout.fillWidth: true + implicitHeight: taskContent.implicitHeight + Style.marginM * 2 + radius: Style.radiusM + color: taskMouse.containsMouse ? Qt.alpha(root.taskToneColor(taskCard.task), 0.12) : Color.mSurface + border.color: Qt.alpha(root.taskToneColor(taskCard.task), taskMouse.containsMouse ? 0.42 : 0.22) + border.width: Style.uiScaleRatio + + property var task: modelData + property string taskId: task?.id || task?._id || "" + property color toneColor: root.taskToneColor(task) + + Behavior on color { + ColorAnimation { + duration: Style.animationFast + } + } + + Rectangle { + id: controlRail + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 52 * Style.uiScaleRatio + radius: Style.radiusM + color: Qt.alpha(taskCard.toneColor, 0.82) + + Rectangle { + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + width: parent.radius + color: parent.color + } + + NIconButton { + anchors.centerIn: parent + visible: kind !== "habit" + enabled: main && !main.isScoring + icon: taskCard.task?.completed ? "check-check" : "check" + tooltipText: pluginApi?.tr("panel.complete") + baseSize: Style.baseWidgetSize * 0.78 + colorBg: Qt.alpha(Color.mSurface, 0.78) + colorFg: taskCard.toneColor + colorBgHover: Color.mHover + colorFgHover: Color.mOnHover + onClicked: main.completeTask(taskCard.task) + } + + NIconButton { + anchors.centerIn: parent + visible: kind === "habit" && !(taskCard.task?.up ?? true) + enabled: false + icon: "plus" + tooltipText: pluginApi?.tr("panel.scoreUpDisabled") + baseSize: Style.baseWidgetSize * 0.78 + colorBg: Qt.alpha(Color.mSurface, 0.26) + colorFg: Qt.alpha(Color.mOnSurface, 0.42) + } + + ColumnLayout { + anchors.centerIn: parent + visible: kind === "habit" && (taskCard.task?.up ?? true) + spacing: Style.marginXS + + NIconButton { + visible: taskCard.task?.up ?? true + enabled: main && !main.isScoring + icon: "plus" + tooltipText: pluginApi?.tr("panel.scoreUp") + baseSize: Style.baseWidgetSize * 0.58 + colorBg: Qt.alpha(Color.mSurface, 0.78) + colorFg: taskCard.toneColor + colorBgHover: Color.mHover + colorFgHover: Color.mOnHover + onClicked: main.scoreHabit(taskCard.task, "up") + } + + } + } + + Rectangle { + id: rightControlRail + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + width: kind === "habit" ? 52 * Style.uiScaleRatio : 0 + visible: kind === "habit" + radius: Style.radiusM + color: (taskCard.task?.down ?? false) ? Qt.alpha(taskCard.toneColor, 0.82) : Qt.alpha(Color.mOnSurfaceVariant, 0.18) + + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: parent.radius + color: parent.color + } + + NIconButton { + anchors.centerIn: parent + enabled: main && !main.isScoring && (taskCard.task?.down ?? false) + icon: "minus" + tooltipText: (taskCard.task?.down ?? false) ? pluginApi?.tr("panel.scoreDown") : pluginApi?.tr("panel.scoreDownDisabled") + baseSize: Style.baseWidgetSize * 0.78 + colorBg: Qt.alpha(Color.mSurface, enabled ? 0.78 : 0.16) + colorFg: enabled ? taskCard.toneColor : Qt.alpha(Color.mOnSurface, 0.42) + colorBgHover: Color.mHover + colorFgHover: Color.mOnHover + onClicked: main.scoreHabit(taskCard.task, "down") + } + } + + MouseArea { + id: taskMouse + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + + ColumnLayout { + id: taskContent + anchors.fill: parent + anchors.leftMargin: controlRail.width + Style.marginM + anchors.rightMargin: (rightControlRail.visible ? rightControlRail.width : 0) + Style.marginM + anchors.topMargin: Style.marginM + anchors.bottomMargin: Style.marginM + spacing: Style.marginS + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginS + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.uiScaleRatio * 2 + + NText { + Layout.fillWidth: true + text: taskCard.task?.text || pluginApi?.tr("panel.untitledTask") + pointSize: Style.fontSizeS + font.weight: Font.Medium + color: Color.mOnSurface + wrapMode: Text.Wrap + } + + NText { + Layout.fillWidth: true + visible: (taskCard.task?.notes || "") !== "" || (main?.taskSummary(taskCard.task) || "") !== "" + text: (main?.taskSummary(taskCard.task) || "") || taskCard.task.notes + pointSize: Style.fontSizeXS + color: Color.mOnSurfaceVariant + elide: Text.ElideRight + } + } + + Rectangle { + implicitWidth: toneText.implicitWidth + Style.marginS * 2 + implicitHeight: toneText.implicitHeight + Style.marginXS * 2 + radius: implicitHeight / 2 + color: Qt.alpha(taskCard.toneColor, 0.14) + + NText { + id: toneText + anchors.centerIn: parent + text: root.taskToneLabel(taskCard.task) + pointSize: Style.fontSizeXS + color: taskCard.toneColor + } + } + } + + ColumnLayout { + Layout.fillWidth: true + visible: showChecklistItems && taskCard.task?.checklist && taskCard.task.checklist.length > 0 + spacing: Style.uiScaleRatio * 2 + + Repeater { + model: taskCard.task?.checklist || [] + + RowLayout { + Layout.fillWidth: true + spacing: Style.marginXS + + NIconButton { + enabled: main && !main.isScoring + icon: modelData.completed ? "checkbox" : "square" + tooltipText: pluginApi?.tr("panel.toggleChecklist") + baseSize: Style.baseWidgetSize * 0.55 + onClicked: main.scoreChecklistItem(taskCard.task, modelData) + } + + NText { + Layout.fillWidth: true + text: modelData.text || "" + pointSize: Style.fontSizeXS + color: modelData.completed ? Color.mOnSurfaceVariant : Color.mOnSurface + font.strikeout: modelData.completed + elide: Text.ElideRight + } + } + } + } + } + } + } + } +} diff --git a/habitica/README.md b/habitica/README.md new file mode 100644 index 000000000..d50d7f594 --- /dev/null +++ b/habitica/README.md @@ -0,0 +1,129 @@ +# Habitica for Noctalia + +Habitica for Noctalia is a compact productivity plugin that brings your Habitica account into Noctalia Shell. It shows due work in the bar, exposes a richer panel with stats and tasks, and lets you score tasks without leaving the desktop shell. + +The current UI stays intentionally focused: due dailies and open todos are first-class, while habits, checklist items, tag filtering, and colorization remain opt-in so the default experience stays light. + +## Features + +- Bar widget with pending task count for due dailies and open todos +- Panel with player name, level, health, mana, experience, and gold +- Manual refresh plus throttled background refresh +- Task scoring for dailies, todos, habits, and checklist items +- Optional habit section +- Optional checklist item display +- Optional tag-based filtering +- Avatar rendering from official Habitica sprite assets + +## Repository + +This standalone repository is intended for direct installation and review: + +- https://github.com/Vasqs/noctalia-habitica + +The community registry submission is prepared separately for: + +- https://github.com/noctalia-dev/noctalia-plugins + +## Local Installation + +1. Copy or symlink this repository directory into the plugin location used by your Noctalia installation as `habitica`. +2. Enable the `habitica` plugin in Noctalia. +3. Open the plugin settings and fill in your Habitica credentials. + +If you are preparing a community submission, package from Git-tracked files only. Local state files such as `settings.json` and `cache/` are intentionally ignored so you can keep your local credentials and cached Habitica data without publishing them. + +## Community Submission Packaging + +Include these files in the publishable plugin package: + +- `manifest.json` +- `Main.qml` +- `BarWidget.qml` +- `Panel.qml` +- `Settings.qml` +- `HabiticaAvatar.qml` +- `README.md` +- `LICENSE` +- `i18n/en.json` +- `preview.png` +- `.gitignore` + +Keep these local-only files out of community packages and screenshots: + +- `settings.json` +- `cache/` +- any temporary screenshots, local archives, or credential-bearing notes you created during testing + +## Getting Your Habitica User ID And API Token + +1. Sign in to Habitica. +2. Open `Settings`. +3. Open the `API` section. +4. Copy the `User ID`. +5. Copy the `API Token`. +6. Paste both values into the Noctalia Habitica plugin settings. + +The plugin sends the following headers to Habitica: + +- `x-api-user` +- `x-api-key` +- `x-client` + +The `x-client` header is generated as `-noctalia-habitica`, which matches Habitica's third-party client header expectations. + +## Using It In Noctalia + +After configuration: + +- the bar widget shows how many due dailies and open todos are currently pending; +- the panel shows account stats, due dailies, open todos, and optional habits; +- clicking scoring controls sends the matching Habitica score request and then refreshes the visible data; +- checklist toggles and tag filtering are available through plugin settings. + +The plugin keeps the current refresh optimization: it avoids unnecessary full refreshes while cached data is still fresh, and it fetches avatar-heavy data less often than basic task data. + +## Privacy And Security + +- Your Habitica API token is required to access your account data. +- The plugin does not intentionally log the token. +- Local plugin state can contain sensitive data if you configure credentials or let cached API responses accumulate. +- For publishable copies of the plugin, keep `settings.json` and `cache/` out of version control and out of release archives. You do not need to wipe your local `settings.json` if it is ignored; just do not include ignored files in the package. +- This plugin is intentionally limited to reading account/task data and scoring supported tasks. It does not create, edit, or delete Habitica tasks in this version. + +## Troubleshooting + +- If the plugin shows a configuration error, re-open settings and confirm both `User ID` and `API Token` are filled. +- If requests fail, verify your network connection and confirm the token is still valid in Habitica. +- If the panel opens but no avatar appears, refresh once after login so the plugin can fetch avatar-related user fields. +- If tag filtering hides everything, clear the selected tag ID or paste a valid Habitica tag UUID. +- If you are packaging the plugin for others, build the package from Git-tracked files only so ignored local state such as `settings.json` and `cache/habitica.json` stays on your machine. + +## API Reference + +The plugin uses the public Habitica API documented here: + +- https://habitica.com/apidoc/ + +Endpoints currently used: + +- `GET /api/v3/user?userFields=stats,profile,preferences,notifications` +- `GET /api/v3/user?userFields=stats,profile,preferences,items,notifications` +- `GET /api/v3/tasks/user?type=dailys` +- `GET /api/v3/tasks/user?type=todos` +- `GET /api/v3/tasks/user?type=habits` +- `GET /api/v3/tags` +- `POST /api/v3/tasks/:taskId/score/:direction` +- `POST /api/v3/tasks/:taskId/checklist/:itemId/score` + +## Avatar Assets + +The avatar is rendered locally from official Habitica sprite images hosted by Habitica. The plugin assembles the visible layers from the user's avatar preferences and equipment data instead of shipping copied sprite assets inside the package. + +## Submission Notes + +- Intended community package name: `Habitica for Noctalia` +- Minimum Noctalia version: `3.7.1` +- Current release version: `1.0.0` +- Standalone repository field points at `https://github.com/Vasqs/noctalia-habitica` +- For pull requests to `noctalia-dev/noctalia-plugins`, set the manifest repository field to `https://github.com/noctalia-dev/noctalia-plugins` diff --git a/habitica/Settings.qml b/habitica/Settings.qml new file mode 100644 index 000000000..34db83453 --- /dev/null +++ b/habitica/Settings.qml @@ -0,0 +1,327 @@ +import QtQuick +import QtQuick.Layouts +import qs.Commons +import qs.Services.UI +import qs.Widgets + +ColumnLayout { + id: root + + property var pluginApi: null + + property string editHabiticaUserId: "" + property string editHabiticaApiToken: "" + property int editRefreshInterval: 300 + property int editMaxDailies: 8 + property int editMaxTodos: 8 + property int editMaxHabits: 8 + property bool editShowHabits: false + property bool editShowChecklistItems: false + property bool editEnableTagFilter: false + property string editSelectedTagId: "" + property bool editShowNotificationBadge: true + property bool editColorizationEnabled: false + property string editColorizationIcon: "Primary" + property string editColorizationBadge: "Error" + property string editColorizationBadgeText: "Primary" + + spacing: Style.marginM + + NLabel { + label: pluginApi?.tr("settings.account.label") + description: pluginApi?.tr("settings.account.description") + } + + NTextInput { + Layout.fillWidth: true + label: pluginApi?.tr("settings.userId.label") + description: pluginApi?.tr("settings.userId.description") + placeholderText: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + text: root.editHabiticaUserId + onTextChanged: root.editHabiticaUserId = text + } + + NTextInput { + id: apiTokenInput + Layout.fillWidth: true + label: pluginApi?.tr("settings.apiToken.label") + description: pluginApi?.tr("settings.apiToken.description") + placeholderText: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + text: root.editHabiticaApiToken + onTextChanged: root.editHabiticaApiToken = text + + Component.onCompleted: { + if (apiTokenInput.inputItem) { + apiTokenInput.inputItem.echoMode = TextInput.Password + } + } + } + + NDivider { + Layout.fillWidth: true + } + + NLabel { + label: pluginApi?.tr("settings.basicUi.label") + description: pluginApi?.tr("settings.basicUi.description") + } + + ColumnLayout { + Layout.fillWidth: true + spacing: Style.marginS + + NLabel { + label: pluginApi?.tr("settings.refreshInterval.label") + description: pluginApi?.tr("settings.refreshInterval.descriptionPrefix") + Math.floor(root.editRefreshInterval / 60) + pluginApi?.tr("settings.refreshInterval.descriptionSuffix") + } + + NSlider { + Layout.fillWidth: true + from: 60 + to: 3600 + stepSize: 60 + value: root.editRefreshInterval + onValueChanged: root.editRefreshInterval = value + } + } + + GridLayout { + Layout.fillWidth: true + columns: 2 + columnSpacing: Style.marginM + rowSpacing: Style.marginS + + ColumnLayout { + Layout.fillWidth: true + + NLabel { + label: pluginApi?.tr("settings.dailies.label") + description: pluginApi?.tr("settings.dailies.descriptionPrefix") + root.editMaxDailies + } + + NSlider { + Layout.fillWidth: true + from: 1 + to: 25 + stepSize: 1 + value: root.editMaxDailies + onValueChanged: root.editMaxDailies = value + } + } + + ColumnLayout { + Layout.fillWidth: true + + NLabel { + label: pluginApi?.tr("settings.todos.label") + description: pluginApi?.tr("settings.todos.descriptionPrefix") + root.editMaxTodos + } + + NSlider { + Layout.fillWidth: true + from: 1 + to: 25 + stepSize: 1 + value: root.editMaxTodos + onValueChanged: root.editMaxTodos = value + } + } + } + + NToggle { + Layout.fillWidth: true + label: pluginApi?.tr("settings.notificationBadge.label") + description: pluginApi?.tr("settings.notificationBadge.description") + checked: root.editShowNotificationBadge + onToggled: checked => root.editShowNotificationBadge = checked + } + + NDivider { + Layout.fillWidth: true + } + + NLabel { + label: pluginApi?.tr("settings.advancedUi.label") + description: pluginApi?.tr("settings.advancedUi.description") + } + + NToggle { + Layout.fillWidth: true + label: pluginApi?.tr("settings.showHabits.label") + description: pluginApi?.tr("settings.showHabits.description") + checked: root.editShowHabits + onToggled: checked => root.editShowHabits = checked + } + + ColumnLayout { + Layout.fillWidth: true + visible: root.editShowHabits + + NLabel { + label: pluginApi?.tr("settings.habits.label") + description: pluginApi?.tr("settings.habits.descriptionPrefix") + root.editMaxHabits + } + + NSlider { + Layout.fillWidth: true + from: 1 + to: 25 + stepSize: 1 + value: root.editMaxHabits + onValueChanged: root.editMaxHabits = value + } + } + + NToggle { + Layout.fillWidth: true + label: pluginApi?.tr("settings.checklist.label") + description: pluginApi?.tr("settings.checklist.description") + checked: root.editShowChecklistItems + onToggled: checked => root.editShowChecklistItems = checked + } + + NToggle { + Layout.fillWidth: true + label: pluginApi?.tr("settings.tagFilter.label") + description: pluginApi?.tr("settings.tagFilter.description") + checked: root.editEnableTagFilter + onToggled: checked => root.editEnableTagFilter = checked + } + + NTextInput { + Layout.fillWidth: true + visible: root.editEnableTagFilter + label: pluginApi?.tr("settings.selectedTag.label") + description: pluginApi?.tr("settings.selectedTag.description") + placeholderText: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + text: root.editSelectedTagId + onTextChanged: root.editSelectedTagId = text + } + + NDivider { + Layout.fillWidth: true + } + + NLabel { + label: pluginApi?.tr("settings.colors.label") + } + + NToggle { + Layout.fillWidth: true + label: pluginApi?.tr("settings.colorization.label") + description: pluginApi?.tr("settings.colorization.description") + checked: root.editColorizationEnabled + onToggled: checked => root.editColorizationEnabled = checked + } + + NComboBox { + Layout.fillWidth: true + visible: root.editColorizationEnabled + label: pluginApi?.tr("settings.iconColor.label") + model: colorModel + currentKey: root.editColorizationIcon + onSelected: key => root.editColorizationIcon = key + } + + NComboBox { + Layout.fillWidth: true + visible: root.editColorizationEnabled + label: pluginApi?.tr("settings.badgeColor.label") + model: colorModel + currentKey: root.editColorizationBadge + onSelected: key => root.editColorizationBadge = key + } + + NComboBox { + Layout.fillWidth: true + visible: root.editColorizationEnabled + label: pluginApi?.tr("settings.badgeTextColor.label") + model: colorModel + currentKey: root.editColorizationBadgeText + onSelected: key => root.editColorizationBadgeText = key + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: infoColumn.implicitHeight + Style.marginM * 2 + color: Color.mSurfaceVariant + radius: Style.radiusM + + ColumnLayout { + id: infoColumn + anchors.fill: parent + anchors.margins: Style.marginM + spacing: Style.marginS + + NText { + Layout.fillWidth: true + text: pluginApi?.tr("settings.ipc.title") + pointSize: Style.fontSizeS + font.weight: Font.Bold + color: Color.mOnSurface + } + + NText { + Layout.fillWidth: true + text: pluginApi?.tr("settings.ipc.refresh") + pointSize: Style.fontSizeXS + font.family: Settings.data.ui.fontFixed + color: Color.mOnSurfaceVariant + wrapMode: Text.WrapAnywhere + } + } + } + + property var colorModel: [ + { key: "None", name: "None" }, + { key: "Primary", name: "Primary" }, + { key: "Secondary", name: "Secondary" }, + { key: "Tertiary", name: "Tertiary" }, + { key: "Error", name: "Error" } + ] + + function saveSettings() { + if (!pluginApi) return + + pluginApi.pluginSettings.habiticaUserId = root.editHabiticaUserId.trim() + pluginApi.pluginSettings.habiticaApiToken = root.editHabiticaApiToken.trim() + pluginApi.pluginSettings.refreshInterval = root.editRefreshInterval + pluginApi.pluginSettings.maxDailies = root.editMaxDailies + pluginApi.pluginSettings.maxTodos = root.editMaxTodos + pluginApi.pluginSettings.maxHabits = root.editMaxHabits + pluginApi.pluginSettings.showHabits = root.editShowHabits + pluginApi.pluginSettings.showChecklistItems = root.editShowChecklistItems + pluginApi.pluginSettings.enableTagFilter = root.editEnableTagFilter + pluginApi.pluginSettings.selectedTagId = root.editSelectedTagId.trim() + pluginApi.pluginSettings.showNotificationBadge = root.editShowNotificationBadge + pluginApi.pluginSettings.colorizationEnabled = root.editColorizationEnabled + pluginApi.pluginSettings.colorizationIcon = root.editColorizationIcon + pluginApi.pluginSettings.colorizationBadge = root.editColorizationBadge + pluginApi.pluginSettings.colorizationBadgeText = root.editColorizationBadgeText + + pluginApi.saveSettings() + Logger.i("Habitica", "Settings saved") + ToastService.showNotice(pluginApi?.tr("settings.saved")) + } + + Component.onCompleted: { + var settings = pluginApi?.pluginSettings + var defaults = pluginApi?.manifest?.metadata?.defaultSettings + + root.editHabiticaUserId = settings?.habiticaUserId || defaults?.habiticaUserId || "" + root.editHabiticaApiToken = settings?.habiticaApiToken || defaults?.habiticaApiToken || "" + root.editRefreshInterval = settings?.refreshInterval || defaults?.refreshInterval || 300 + root.editMaxDailies = settings?.maxDailies || defaults?.maxDailies || 8 + root.editMaxTodos = settings?.maxTodos || defaults?.maxTodos || 8 + root.editMaxHabits = settings?.maxHabits || defaults?.maxHabits || 8 + root.editShowHabits = settings?.showHabits ?? defaults?.showHabits ?? false + root.editShowChecklistItems = settings?.showChecklistItems ?? defaults?.showChecklistItems ?? false + root.editEnableTagFilter = settings?.enableTagFilter ?? defaults?.enableTagFilter ?? false + root.editSelectedTagId = settings?.selectedTagId || defaults?.selectedTagId || "" + root.editShowNotificationBadge = settings?.showNotificationBadge ?? defaults?.showNotificationBadge ?? true + root.editColorizationEnabled = settings?.colorizationEnabled ?? defaults?.colorizationEnabled ?? false + root.editColorizationIcon = settings?.colorizationIcon || defaults?.colorizationIcon || "Primary" + root.editColorizationBadge = settings?.colorizationBadge || defaults?.colorizationBadge || "Error" + root.editColorizationBadgeText = settings?.colorizationBadgeText || defaults?.colorizationBadgeText || "Primary" + } +} diff --git a/habitica/i18n/en.json b/habitica/i18n/en.json new file mode 100644 index 000000000..adf66799b --- /dev/null +++ b/habitica/i18n/en.json @@ -0,0 +1,134 @@ +{ + "menu": { + "openPanel": "Open panel", + "refresh": "Refresh", + "openSettings": "Open settings", + "openHabitica": "Open Habitica" + }, + "settings": { + "account": { + "label": "Habitica Account", + "description": "Create or copy credentials from Habitica Settings > API." + }, + "userId": { + "label": "User ID", + "description": "Habitica x-api-user header." + }, + "apiToken": { + "label": "API Token", + "description": "Habitica x-api-key header. The token is saved in Noctalia plugin settings." + }, + "basicUi": { + "label": "Basic UI", + "description": "The default panel stays focused on today's dailies and open todos." + }, + "refreshInterval": { + "label": "Refresh Interval", + "descriptionPrefix": "Sync every ", + "descriptionSuffix": " minute(s)." + }, + "dailies": { + "label": "Dailies", + "descriptionPrefix": "Show up to " + }, + "todos": { + "label": "Todos", + "descriptionPrefix": "Show up to " + }, + "notificationBadge": { + "label": "Show notification badge", + "description": "Badge shows pending due dailies plus open todos." + }, + "advancedUi": { + "label": "Advanced UI", + "description": "Optional features are disabled by default to keep the panel minimal." + }, + "showHabits": { + "label": "Show habits", + "description": "Adds a Habits section with up/down scoring." + }, + "habits": { + "label": "Habits", + "descriptionPrefix": "Show up to " + }, + "checklist": { + "label": "Show checklist items", + "description": "Shows daily/todo checklists and lets you toggle checklist progress." + }, + "tagFilter": { + "label": "Enable tag filtering", + "description": "Fetch tags and filter tasks by a selected tag ID." + }, + "selectedTag": { + "label": "Selected Tag ID", + "description": "Paste a Habitica tag UUID. Leave empty to show all tasks." + }, + "colors": { + "label": "Colors" + }, + "colorization": { + "label": "Enable colorization", + "description": "Apply theme colors to the bar icon and badge." + }, + "iconColor": { + "label": "Icon Color" + }, + "badgeColor": { + "label": "Badge Color" + }, + "badgeTextColor": { + "label": "Badge Text Color" + }, + "ipc": { + "title": "IPC", + "refresh": "Refresh: qs -c noctalia-shell ipc call plugin:habitica refresh" + }, + "saved": "Habitica settings saved" + }, + "panel": { + "health": "Health", + "experience": "Experience", + "mana": "Mana", + "refreshTooltip": "Refresh Habitica", + "closeTooltip": "Close", + "credentialsRequired": "Credentials required", + "credentialsDescription": "Add your Habitica User ID and API Token in this plugin's settings.", + "loadingTitle": "Loading Habitica", + "loadingDescription": "Fetching profile and active tasks.", + "today": "Today", + "noDailies": "No due dailies.", + "todos": "Todos", + "noTodos": "No open todos.", + "habits": "Habits", + "noHabits": "No habits to show.", + "complete": "Complete", + "scoreUp": "Score up", + "scoreUpDisabled": "Score up disabled", + "scoreDown": "Score down", + "scoreDownDisabled": "Score down disabled", + "toggleChecklist": "Toggle checklist item", + "untitledTask": "Untitled task" + }, + "toast": { + "refreshing": "Refreshing Habitica", + "configure": "Configure Habitica credentials in plugin settings" + }, + "tooltip": { + "configure": "Habitica\nConfigure User ID and API Token", + "error": "Habitica\nError: ", + "unknownError": "Unknown error", + "loading": "Habitica\nLoading...", + "player": "Player", + "levelFallback": "Level --", + "hpFallback": "-- HP", + "xpFallback": "-- XP", + "goldFallback": "-- gold", + "dailies": " dailies - ", + "todos": " todos", + "updatedNow": "Updated just now", + "updatedMinutesPrefix": "Updated ", + "updatedMinutesSuffix": "m ago", + "updatedHoursSuffix": "h ago", + "actionsHint": "Right-click for actions" + } +} diff --git a/habitica/manifest.json b/habitica/manifest.json new file mode 100644 index 000000000..ef3c7dd7e --- /dev/null +++ b/habitica/manifest.json @@ -0,0 +1,44 @@ +{ + "id": "habitica", + "name": "Habitica for Noctalia", + "version": "1.0.0", + "minNoctaliaVersion": "3.7.1", + "author": "vasqs", + "license": "MIT", + "repository": "https://github.com/noctalia-dev/noctalia-plugins", + "description": "Habitica dashboard and task scorer for Noctalia Shell with official avatar sprite rendering.", + "tags": [ + "Bar", + "Panel", + "Productivity", + "Network" + ], + "entryPoints": { + "main": "Main.qml", + "barWidget": "BarWidget.qml", + "panel": "Panel.qml", + "settings": "Settings.qml" + }, + "dependencies": { + "plugins": [] + }, + "metadata": { + "defaultSettings": { + "habiticaUserId": "", + "habiticaApiToken": "", + "refreshInterval": 300, + "maxDailies": 8, + "maxTodos": 8, + "maxHabits": 8, + "showHabits": false, + "showChecklistItems": false, + "enableTagFilter": false, + "selectedTagId": "", + "showNotificationBadge": true, + "colorizationEnabled": false, + "colorizationIcon": "Primary", + "colorizationBadge": "Error", + "colorizationBadgeText": "Primary" + } + } +} diff --git a/habitica/preview.png b/habitica/preview.png new file mode 100644 index 000000000..6c22cf9c8 Binary files /dev/null and b/habitica/preview.png differ