diff --git a/qml/components/navigation/ListHeader.qml b/qml/components/navigation/ListHeader.qml index 4cf9b45b..25ec515d 100644 --- a/qml/components/navigation/ListHeader.qml +++ b/qml/components/navigation/ListHeader.qml @@ -6,7 +6,7 @@ import ".." Rectangle { id: topFilterBar width: parent ? parent.width : Screen.width - height: showSearchBox ? units.gu(11) : units.gu(6) // Restored height to give proper space + height: (showSearchBox ? units.gu(5) : 0) + (filterModel.length > 0 ? units.gu(6) : 0) color: "transparent" // Helper property to check if dark mode is active @@ -207,6 +207,7 @@ Rectangle { // Filter buttons row below search Item { + visible: topFilterBar.filterModel.length > 0 width: parent.width height: units.gu(6) // Adjusted back diff --git a/qml/components/richtext/HtmlEditorToolbar.qml b/qml/components/richtext/HtmlEditorToolbar.qml index 9599c4f2..0cc2731d 100644 --- a/qml/components/richtext/HtmlEditorToolbar.qml +++ b/qml/components/richtext/HtmlEditorToolbar.qml @@ -68,6 +68,7 @@ Rectangle { width: units.gu(5) height: units.gu(4) color: (editor && editor.listening) ? LomiriColors.red : (darkMode ? "#555555" : "#F0F0F0") + visible: editor ? editor.isVoiceInputEnabled : false Icon { anchors.centerIn: parent diff --git a/qml/components/richtext/ReadMorePage.qml b/qml/components/richtext/ReadMorePage.qml index 383cd133..a95c9606 100644 --- a/qml/components/richtext/ReadMorePage.qml +++ b/qml/components/richtext/ReadMorePage.qml @@ -1,6 +1,8 @@ import QtQuick 2.7 import Lomiri.Components 1.3 +import QtQuick.LocalStorage 2.7 as Sql import "../../../models/global.js" as Global +import "../system" Page { id: readmepage @@ -23,6 +25,110 @@ Page { property string _lastKnownHolder: "" property bool _parentSaveCommitted: false + property bool listening: false + property bool processing: false + property bool ignoreNextResult: false + property string textBeforeRecording: "" + property bool isVoiceInputEnabled: true + property string _partialRecognizedText: "" + property string _currentVoiceStatus: "" + + function checkVoiceInputEnabled() { + try { + var db = Sql.LocalStorage.openDatabaseSync("myDatabase", "1.0", "My Database", 1000000); + var result = true; + db.transaction(function (tx) { + tx.executeSql('CREATE TABLE IF NOT EXISTS app_settings (key TEXT PRIMARY KEY, value TEXT)'); + var rs = tx.executeSql('SELECT value FROM app_settings WHERE key = "voice_input_enabled"'); + if (rs.rows.length > 0) { + result = rs.rows.item(0).value === "true"; + } + }); + isVoiceInputEnabled = result; + } catch (e) { + console.warn("Error reading voice_input_enabled:", e); + } + } + + Connections { + target: mainView.backend_bridge + onMessageReceived: { + if (!readmepage.listening && !readmepage.processing) return; + + if (data.event === "voice_recognition_partial") { + var partialText = data.payload + if (partialText) { + readmepage._currentVoiceStatus = i18n.dtr("ubtms", "Listening..."); + + var prefix = ""; + if (readmepage.textBeforeRecording.length > 0) { + var lastChar = readmepage.textBeforeRecording.charAt(readmepage.textBeforeRecording.length - 1); + if (lastChar !== '\n' && lastChar !== '\r') { + prefix = "\n"; + } + } + simpleEditor.text = readmepage.textBeforeRecording + prefix + partialText; + cursorTimer.start(); + } + } else if (data.event === "voice_recognition_status") { + var statusText = data.payload; + if (statusText) { + readmepage._currentVoiceStatus = statusText; + } + } else if (data.event === "voice_recognition_result") { + if (readmepage.ignoreNextResult) { + readmepage.ignoreNextResult = false; + readmepage.listening = false; + readmepage.processing = false; + readmepage._currentVoiceStatus = ""; + readmepage.textBeforeRecording = simpleEditor.text; + cursorTimer.start(); + return; + } + + readmepage.listening = false + readmepage.processing = false + var recognizedText = data.payload + readmepage._currentVoiceStatus = ""; + + if (recognizedText) { + var prefix = ""; + if (readmepage.textBeforeRecording.length > 0) { + var lastChar = readmepage.textBeforeRecording.charAt(readmepage.textBeforeRecording.length - 1); + if (lastChar !== '\n' && lastChar !== '\r') { + prefix = "\n"; + } + } + simpleEditor.text = readmepage.textBeforeRecording + prefix + recognizedText; + readmepage.textBeforeRecording = simpleEditor.text; + cursorTimer.start(); + } + } else if (data.event === "voice_recognition_error") { + readmepage.listening = false + readmepage.processing = false + readmepage._currentVoiceStatus = ""; + readmepage.textBeforeRecording = simpleEditor.text; + cursorTimer.start() + } + } + } + + Timer { + id: cursorTimer + interval: 100 + repeat: false + onTriggered: { + if (simpleEditor) { + simpleEditor.cursorPosition = simpleEditor.length; + if (simpleEditor.flickableItem) { + simpleEditor.flickableItem.contentY = Math.max(0, simpleEditor.flickableItem.contentHeight - simpleEditor.flickableItem.height); + } else if (simpleEditor.flickable) { + simpleEditor.flickable.contentY = Math.max(0, simpleEditor.flickable.contentHeight - simpleEditor.flickable.height); + } + } + } + } + header: PageHeader { id: header title: i18n.dtr("ubtms","Description") @@ -35,20 +141,45 @@ Page { dividerColor: LomiriColors.slate } + trailingActionBar.numberOfSlots: 3 trailingActionBar.actions: [ Action { - visible: !isReadOnly && useRichText - iconName: editor.toolbarExpanded ? "view-collapse" : "view-expand" - text: editor.toolbarExpanded ? i18n.dtr("ubtms", "Hide Toolbar") : i18n.dtr("ubtms", "Show Toolbar") + visible: !isReadOnly + iconName: "tick" onTriggered: { - editor.toolbarExpanded = !editor.toolbarExpanded + saveAndClose() } }, Action { - visible: !isReadOnly - iconName: "tick" + visible: !isReadOnly && readmepage.isVoiceInputEnabled && !useRichText + iconName: "microphone" + text: readmepage.listening ? i18n.dtr("ubtms", "Stop Recording") : i18n.dtr("ubtms", "Start Recording") onTriggered: { - saveAndClose() + if (readmepage.listening) { + readmepage.listening = false + readmepage.processing = true + readmepage._currentVoiceStatus = i18n.dtr("ubtms", "Processing..."); + + backend_bridge.call("backend.stop_voice_recognition", []) + return; + } + if (readmepage.processing) return; + + readmepage.textBeforeRecording = simpleEditor.text + readmepage._partialRecognizedText = ""; + readmepage._currentVoiceStatus = i18n.dtr("ubtms", "Starting..."); + + readmepage.listening = true + readmepage.processing = false + backend_bridge.call("backend.run_voice_recognition", []) + } + }, + Action { + visible: !isReadOnly && useRichText && (!readmepage.listening && !readmepage.processing) + iconName: editor.toolbarExpanded ? "view-collapse" : "view-expand" + text: editor.toolbarExpanded ? i18n.dtr("ubtms", "Hide Toolbar") : i18n.dtr("ubtms", "Show Toolbar") + onTriggered: { + editor.toolbarExpanded = !editor.toolbarExpanded } } ] @@ -109,13 +240,28 @@ Page { } function saveAndClose() { + // Auto-stop voice recognition if it's still running + if (readmepage.listening || readmepage.processing) { + readmepage.ignoreNextResult = true; + readmepage.listening = false; + readmepage.processing = false; + readmepage._currentVoiceStatus = ""; + backend_bridge.call("backend.stop_voice_recognition", []); + } + if (useRichText) { + // Finalize voice span in RichTextEditor if needed + if (editor.editor && editor.editor.listening || editor.editor && editor.editor.processing) { + editor.editor.stopAndFinalizeVoice(); + } editor.getText(function (content) { if (commitContent(content)) { closePage(); } }); } else { + // For plain text, textBeforeRecording is already updated + readmepage.textBeforeRecording = simpleEditor.text; if (commitContent(simpleEditor.text)) { closePage(); } @@ -133,8 +279,8 @@ Page { id: editor visible: useRichText text: Global.description_temporary_holder - readOnly: isReadOnly - showToolbar: !isReadOnly + readOnly: isReadOnly || readmepage.listening || readmepage.processing + showToolbar: !isReadOnly && !readmepage.listening && !readmepage.processing anchors.fill: parent onContentChanged: { @@ -165,7 +311,7 @@ Page { id: simpleEditor visible: !useRichText text: Global.description_temporary_holder - readOnly: isReadOnly + readOnly: isReadOnly || readmepage.listening || readmepage.processing textFormat: Text.PlainText font.pixelSize: units.gu(2) wrapMode: TextArea.Wrap @@ -222,6 +368,27 @@ Page { } } + VoiceTimerWidget { + id: voiceTimerWidget + parent: readmepage + + isListening: readmepage.listening + isProcessing: readmepage.processing + partialText: "" // Don't show partial text in the widget anymore + voiceStatus: readmepage._currentVoiceStatus + + onStopClicked: { + readmepage.ignoreNextResult = true; + readmepage.listening = false + readmepage.processing = false + readmepage.textBeforeRecording = simpleEditor.text; + + readmepage._currentVoiceStatus = ""; + + backend_bridge.call("backend.stop_voice_recognition", []) + } + } + // Handle page visibility changes to ensure content is saved onVisibleChanged: { if (!visible && !isReadOnly && !_parentSaveCommitted) { @@ -249,9 +416,15 @@ Page { } } } + + if (!visible && (listening || processing)) { + console.log("[ReadMorePage] visibility changed: Stopping voice recognition...") + mainView.backend_bridge.call("backend.stop_voice_recognition", []) + } } Component.onCompleted: { + checkVoiceInputEnabled(); // Initialize tracking to avoid false external-change detection _lastKnownHolder = Global.description_temporary_holder || ""; @@ -281,5 +454,10 @@ Page { parentDraftHandler.saveDraft(); } } + + if (listening || processing) { + console.log("[ReadMorePage] destruction: Stopping voice recognition...") + mainView.backend_bridge.call("backend.stop_voice_recognition", []) + } } } diff --git a/qml/components/richtext/RichTextEditor.qml b/qml/components/richtext/RichTextEditor.qml index f7cc1938..94f13adb 100644 --- a/qml/components/richtext/RichTextEditor.qml +++ b/qml/components/richtext/RichTextEditor.qml @@ -19,6 +19,8 @@ import QtQuick 2.7 import QtQuick.Window 2.2 import Lomiri.Components 1.3 import QtWebEngine 1.5 +import QtQuick.LocalStorage 2.7 as Sql +import "../system" import "js/html-sanitizer.js" as HtmlSanitizer Item { @@ -73,8 +75,36 @@ Item { /** Whether the editor is processing voice input */ property bool processing: false + /** Ignore the final result from the backend if stop was explicitly clicked */ + property bool ignoreNextVoiceResult: false + /** Partial voice recognition text (for UI feedback) */ property string _partialVoiceText: "" + property string _currentVoiceStatus: "" + + /** Whether voice input is enabled globally */ + property bool isVoiceInputEnabled: true + + function checkVoiceInputEnabled() { + try { + var db = Sql.LocalStorage.openDatabaseSync("myDatabase", "1.0", "My Database", 1000000); + var result = true; + db.transaction(function (tx) { + tx.executeSql('CREATE TABLE IF NOT EXISTS app_settings (key TEXT PRIMARY KEY, value TEXT)'); + var rs = tx.executeSql('SELECT value FROM app_settings WHERE key = "voice_input_enabled"'); + if (rs.rows.length > 0) { + result = rs.rows.item(0).value === "true"; + } + }); + isVoiceInputEnabled = result; + } catch (e) { + console.warn("Error reading voice_input_enabled:", e); + } + } + + Component.onCompleted: { + checkVoiceInputEnabled() + } Connections { target: mainView.backend_bridge @@ -85,42 +115,124 @@ Item { var partialText = data.payload if (partialText) { - var jsCode = "var marker = document.getElementById('voice-partial-marker'); " + - "if (marker) { " + - " marker.innerText = " + JSON.stringify(partialText + " (Listening...)") + "; " + - " window.editor.moveCursorToEnd(); " + - "}"; - wv.runJavaScript(jsCode); + editor._currentVoiceStatus = "Listening..."; + var script = " + var el = document.getElementById('voice-live-transcription'); + if (el) { + el.innerText = ' ' + " + JSON.stringify(partialText) + "; + } + "; + wv.runJavaScript(script); } } else if (data.event === "voice_recognition_result") { + if (editor.ignoreNextVoiceResult) { + editor.ignoreNextVoiceResult = false; + editor.listening = false; + editor.processing = false; + editor._currentVoiceStatus = ""; + + var finalScript = " + var el = document.getElementById('voice-live-transcription'); + if (el) { + var txt = el.innerText.trim(); + var parent = el.parentNode; + if (txt) { + var textNode = document.createTextNode(txt); + parent.replaceChild(textNode, el); + } else { + parent.parentNode.removeChild(parent); + } + } + document.body.contentEditable = " + (!readOnly).toString() + "; + "; + wv.runJavaScript(finalScript); + editor.syncContent(); + return; + } + editor.listening = false editor.processing = false var recognizedText = data.payload console.log("[RichTextEditor] Received recognition result: " + recognizedText) - // Replace the partial span with the final text - var jsCode = "var marker = document.getElementById('voice-partial-marker'); " + - "if (marker) { " + - " marker.outerHTML = " + JSON.stringify(recognizedText ? (recognizedText + " ") : "") + "; " + - "} else if (" + JSON.stringify(recognizedText) + ") { " + - " window.editor.focus(); window.editor.insertHTML(" + JSON.stringify(recognizedText + " ") + "); " + - "}"; + editor._currentVoiceStatus = ""; + + var jsCode = " + var el = document.getElementById('voice-live-transcription'); + var finalTxt = " + JSON.stringify(recognizedText) + "; + if (el) { + var parent = el.parentNode; + if (finalTxt && finalTxt.trim()) { + var textNode = document.createTextNode(finalTxt); + parent.replaceChild(textNode, el); + } else { + parent.parentNode.removeChild(parent); + } + } + document.body.contentEditable = " + (!readOnly).toString() + "; + "; wv.runJavaScript(jsCode); // Force a sync to update the 'text' property and emit contentChanged editor.syncContent(); + } else if (data.event === "voice_recognition_status") { + if (!listening && !processing) return; + var statusText = data.payload; + if (statusText) { + editor._currentVoiceStatus = statusText; + } } else if (data.event === "voice_recognition_error") { editor.listening = false editor.processing = false + editor._partialVoiceText = ""; + editor._currentVoiceStatus = ""; console.log("[RichTextEditor] Voice recognition error: " + data.payload) - // Remove the partial span on error - var jsCode = "var marker = document.getElementById('voice-partial-marker'); " + - "if (marker) { " + - " marker.remove(); " + - "}"; - wv.runJavaScript(jsCode); + wv.runJavaScript("document.body.contentEditable = " + (!readOnly).toString() + ";"); + } + } + } + + Component.onDestruction: { + if (listening || processing) { + console.log("[RichTextEditor] destruction: Stopping voice recognition...") + stopAndFinalizeVoice(); + } + } + + /** + * Stop voice recognition and finalize any in-progress transcription. + * Replaces the live transcription span with its text content so it + * persists correctly in the saved HTML. Call this before saving. + */ + function stopAndFinalizeVoice(callback) { + ignoreNextVoiceResult = true; + listening = false; + processing = false; + _currentVoiceStatus = ""; + + wv.runJavaScript(" + var el = document.getElementById('voice-live-transcription'); + if (el) { + var txt = el.innerText.trim(); + var parent = el.parentNode; + if (txt) { + var textNode = document.createTextNode(txt); + parent.replaceChild(textNode, el); + } else { + parent.parentNode.removeChild(parent); + } } + document.body.contentEditable = " + (!readOnly).toString() + "; + "); + + mainView.backend_bridge.call("backend.stop_voice_recognition", []); + + // Sync content so the finalized text is captured + syncContent(); + + if (callback && typeof callback === 'function') { + callback(); } } @@ -130,6 +242,7 @@ Item { console.log("[RichTextEditor] Stopping voice recognition...") listening = false processing = true + editor._currentVoiceStatus = "Processing..." mainView.backend_bridge.call("backend.stop_voice_recognition", []) } else { if (processing) return; @@ -137,25 +250,35 @@ Item { listening = true processing = false _partialVoiceText = "" + editor._currentVoiceStatus = "Starting..." - // Move cursor to the end and insert a new line if there's already text, - // then insert a temporary span for live partial text - var jsCode = "try { " + - " window.editor.focus(); " + - " window.editor.moveCursorToEnd(); " + - - " var currentHTML = window.editor.getHTML(); " + - " var hasContent = currentHTML.replace(/<[^>]*>/g, '').trim().length > 0; " + - " var marker = 'Listening...'; " + - - " if (hasContent) { " + - " window.editor.insertHTML('