Boot splash and periodic announcement banner
Splash: resources/splash/splash.gif built into the binary, played once by SplashOverlay over a black cover present from the first frame (no UI flash), then fades into the UI; hides icons loading. GIF, so no QtMultimedia/gstreamer. Toggle: splash.enabled. Banner (InfoBanner): metro-style band that fades/slides in with a hold pictogram; text that does not fit runs as a seamless ticker for two rounds. Never takes touches; hidden during splash and settings. Config section banner (enabled, text, intervalSec, showSec, position, accentColor), editable in the web UI and on-device settings; shown once after saving. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,7 @@ set(SOURCES
|
||||
set(RESOURCES
|
||||
resources/qml.qrc
|
||||
resources/icons.qrc
|
||||
resources/splash.qrc
|
||||
)
|
||||
|
||||
add_executable(ButtonTask ${SOURCES} ${RESOURCES})
|
||||
|
||||
@@ -21,6 +21,11 @@ companion Flask web configurator. Both share a single JSON config file.
|
||||
- Ethernet configuration via NetworkManager (`nmcli`) from the web UI
|
||||
- OTA software update with automatic rollback on a failed launch (health-check)
|
||||
- Hot reload: app picks up config changes via `QFileSystemWatcher`
|
||||
- Boot splash (`resources/splash/splash.gif`, built into the binary, played once
|
||||
by `qml/SplashOverlay.qml`; covers icons loading). GIF, not mp4: no QtMultimedia
|
||||
- Periodic announcement banner (`qml/InfoBanner.qml`, metro-style fade/slide,
|
||||
ticker scroll for long text, never takes touches); text, period, duration,
|
||||
position and colour in the web UI («Внешний вид») and on-device settings
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -342,6 +347,15 @@ sudo -u buttontask sudo -n /opt/buttontask/scripts/bt-brightness status
|
||||
"errorWidth": 6, // ring width (px) on error
|
||||
"glowRadius": 20 // glow radius (0 disables the glow)
|
||||
},
|
||||
"banner": { // periodic announcement over the buttons
|
||||
"enabled": false,
|
||||
"text": "Для вызова клининга удерживайте кнопку",
|
||||
"intervalSec": 180, // 30..3600, period between shows
|
||||
"showSec": 10, // 3..60, longer if the text has to scroll
|
||||
"position": "bottom", // "bottom" | "top"
|
||||
"accentColor": "#e4002b" // stripe + hold-ring colour
|
||||
},
|
||||
"splash": { "enabled": true }, // boot splash, read at app start
|
||||
"settings": {
|
||||
"darkMode": true,
|
||||
"password": "admin", // shared with web auth
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import QtQuick 2.12
|
||||
|
||||
// Periodic announcement band, metro style ("Для вызова клининга удерживайте
|
||||
// кнопку"). Fades/slides in, text glides in; text that does not fit runs as
|
||||
// a seamless ticker for scrollLoops rounds and stops back on its beginning;
|
||||
// then the band fades out. Never takes touches.
|
||||
// Config section "banner": enabled, text, intervalSec, showSec, position,
|
||||
// accentColor (SettingsContainer clamps the numbers).
|
||||
Item {
|
||||
id: banner
|
||||
|
||||
// Set by Main.qml: splash playing or a settings dialog open.
|
||||
property bool suppressed: false
|
||||
|
||||
readonly property bool atBottom: settingsContainer.bannerPosition !== "top"
|
||||
readonly property color accent: settingsContainer.bannerAccentColor
|
||||
readonly property int slide: atBottom ? 28 : -28
|
||||
|
||||
// Durations computed in show(): an animation group cannot follow
|
||||
// bindings while it runs.
|
||||
property int scrollPause: 0
|
||||
property int scrollMs: 0 // one ticker round
|
||||
property int restMs: 0
|
||||
property real cycle: 0 // text width + gap = one round
|
||||
|
||||
readonly property int scrollLoops: 2
|
||||
readonly property int tickerGap: 80
|
||||
readonly property real tickerSpeed: 70 // px/s
|
||||
|
||||
height: 66
|
||||
visible: band.opacity > 0
|
||||
|
||||
// Preview: show as soon as nothing covers the screen (the settings
|
||||
// dialog may still be closing when this is called).
|
||||
property bool pending: false
|
||||
function preview() {
|
||||
pending = true
|
||||
show()
|
||||
}
|
||||
|
||||
function show() {
|
||||
if (seq.running || banner.suppressed)
|
||||
return
|
||||
pending = false
|
||||
if (settingsContainer.bannerText.length === 0)
|
||||
return
|
||||
var overflow = label.implicitWidth > textBox.width
|
||||
cycle = overflow ? label.implicitWidth + tickerGap : 0
|
||||
scrollPause = overflow ? 1200 : 0
|
||||
scrollMs = overflow ? Math.round(cycle / tickerSpeed * 1000) : 0
|
||||
var fixed = 500 + 450 + scrollPause + scrollMs * scrollLoops + 600
|
||||
restMs = Math.max(overflow ? 1000 : 1500,
|
||||
settingsContainer.bannerShowSec * 1000 - fixed)
|
||||
seq.restart()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: period
|
||||
interval: settingsContainer.bannerIntervalSec * 1000
|
||||
repeat: true
|
||||
running: settingsContainer.bannerEnabled
|
||||
onTriggered: banner.show()
|
||||
}
|
||||
|
||||
// Show once soon after the settings change (saved from the web or the
|
||||
// device), so the result can be seen right away.
|
||||
readonly property string signature: [settingsContainer.bannerEnabled,
|
||||
settingsContainer.bannerText, settingsContainer.bannerShowSec,
|
||||
settingsContainer.bannerPosition, settingsContainer.bannerAccentColor].join("|")
|
||||
property bool ready: false
|
||||
onSignatureChanged: if (ready && settingsContainer.bannerEnabled) previewTimer.restart()
|
||||
Component.onCompleted: ready = true
|
||||
Timer {
|
||||
id: previewTimer
|
||||
interval: 1200
|
||||
onTriggered: banner.preview()
|
||||
}
|
||||
onSuppressedChanged: {
|
||||
if (suppressed && seq.running) {
|
||||
seq.stop()
|
||||
band.opacity = 0
|
||||
} else if (!suppressed && pending) {
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: band
|
||||
anchors.fill: parent
|
||||
opacity: 0
|
||||
radius: 12
|
||||
color: "#e6101426"
|
||||
border.color: "#30ffffff"
|
||||
border.width: 1
|
||||
clip: true
|
||||
transform: Translate { id: shift; y: 0 }
|
||||
|
||||
// Soft sheen across the band.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: parent.radius
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0.0; color: "#18ffffff" }
|
||||
GradientStop { position: 0.5; color: "#00ffffff" }
|
||||
}
|
||||
}
|
||||
|
||||
// Line-colour stripe, like a metro line marker; wipes in.
|
||||
Rectangle {
|
||||
id: stripe
|
||||
property real reveal: 0
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 6
|
||||
height: (parent.height - 18) * reveal
|
||||
radius: 3
|
||||
color: banner.accent
|
||||
}
|
||||
|
||||
// "Hold" pictogram: a ring that fills up, then restarts.
|
||||
Canvas {
|
||||
id: holdIcon
|
||||
property real progress: 0
|
||||
anchors.left: stripe.right
|
||||
anchors.leftMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 40
|
||||
height: 40
|
||||
onProgressChanged: requestPaint()
|
||||
onPaint: {
|
||||
var ctx = getContext("2d")
|
||||
ctx.reset()
|
||||
var c = width / 2, r = width / 2 - 3
|
||||
ctx.lineWidth = 3
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.25)"
|
||||
ctx.beginPath(); ctx.arc(c, c, r, 0, 2 * Math.PI); ctx.stroke()
|
||||
ctx.strokeStyle = settingsContainer.bannerAccentColor
|
||||
ctx.lineCap = "round"
|
||||
ctx.beginPath()
|
||||
ctx.arc(c, c, r, -Math.PI / 2, -Math.PI / 2 + 2 * Math.PI * progress)
|
||||
ctx.stroke()
|
||||
ctx.fillStyle = "#ffffff"
|
||||
ctx.beginPath(); ctx.arc(c, c, 6 + 3 * progress, 0, 2 * Math.PI); ctx.fill()
|
||||
}
|
||||
NumberAnimation on progress {
|
||||
from: 0; to: 1
|
||||
duration: 1600
|
||||
loops: Animation.Infinite
|
||||
running: banner.visible
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: textBox
|
||||
anchors.left: holdIcon.right
|
||||
anchors.leftMargin: 12
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 14
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
clip: true
|
||||
|
||||
// Text + its copy one gap behind: after a full round the copy
|
||||
// sits exactly where the text started, so the loop is seamless.
|
||||
Row {
|
||||
id: ticker
|
||||
height: parent.height
|
||||
opacity: 0
|
||||
spacing: banner.tickerGap
|
||||
|
||||
Text {
|
||||
id: label
|
||||
height: parent.height
|
||||
text: settingsContainer.bannerText
|
||||
color: "#ffffff"
|
||||
font.pixelSize: 21
|
||||
font.weight: Font.DemiBold
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
Text {
|
||||
visible: banner.cycle > 0
|
||||
height: parent.height
|
||||
text: label.text
|
||||
color: label.color
|
||||
font: label.font
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SequentialAnimation {
|
||||
id: seq
|
||||
|
||||
ScriptAction {
|
||||
script: {
|
||||
ticker.x = 28
|
||||
ticker.opacity = 0
|
||||
stripe.reveal = 0
|
||||
}
|
||||
}
|
||||
ParallelAnimation {
|
||||
NumberAnimation { target: band; property: "opacity"; from: 0; to: 1; duration: 500; easing.type: Easing.OutCubic }
|
||||
NumberAnimation { target: shift; property: "y"; from: banner.slide; to: 0; duration: 500; easing.type: Easing.OutCubic }
|
||||
SequentialAnimation {
|
||||
PauseAnimation { duration: 150 }
|
||||
NumberAnimation { target: stripe; property: "reveal"; to: 1; duration: 350; easing.type: Easing.OutCubic }
|
||||
}
|
||||
}
|
||||
ParallelAnimation {
|
||||
NumberAnimation { target: ticker; property: "x"; to: 0; duration: 450; easing.type: Easing.OutCubic }
|
||||
NumberAnimation { target: ticker; property: "opacity"; to: 1; duration: 450 }
|
||||
}
|
||||
PauseAnimation { duration: banner.scrollPause }
|
||||
NumberAnimation {
|
||||
target: ticker; property: "x"
|
||||
from: 0; to: -banner.cycle
|
||||
duration: banner.scrollMs
|
||||
loops: banner.scrollLoops
|
||||
}
|
||||
PauseAnimation { duration: banner.restMs }
|
||||
ParallelAnimation {
|
||||
NumberAnimation { target: band; property: "opacity"; to: 0; duration: 600; easing.type: Easing.InCubic }
|
||||
NumberAnimation { target: shift; property: "y"; to: banner.slide / 2; duration: 600; easing.type: Easing.InCubic }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,25 @@ Window {
|
||||
z: 20
|
||||
}
|
||||
|
||||
// Periodic announcement ("удерживайте кнопку"); config section "banner".
|
||||
InfoBanner {
|
||||
id: infoBanner
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.margins: 12
|
||||
y: settingsContainer.bannerPosition === "top"
|
||||
? 12 : parent.height - height - 12
|
||||
z: 30
|
||||
suppressed: splash.active || root.secureDialogOpen
|
||||
}
|
||||
|
||||
// Boot splash over everything in the window (dialogs live in the overlay
|
||||
// layer, but none can be open this early).
|
||||
SplashOverlay {
|
||||
id: splash
|
||||
z: 900
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: settingsContainer
|
||||
onChanged: root.hideSensorsIfDisabled()
|
||||
@@ -304,6 +323,7 @@ Window {
|
||||
|
||||
SettingsDialog {
|
||||
id: settingsDialog
|
||||
onBannerPreview: infoBanner.preview()
|
||||
}
|
||||
|
||||
InputPanel {
|
||||
|
||||
@@ -12,6 +12,9 @@ Dialog {
|
||||
anchors.centerIn: parent
|
||||
standardButtons: Dialog.Close
|
||||
|
||||
// BannerPage "Показать сейчас": the dialog closes, Main shows the banner.
|
||||
signal bannerPreview()
|
||||
|
||||
readonly property color panelBg: "#161628"
|
||||
readonly property color panelFg: "#ffffff"
|
||||
readonly property color panelMuted: "#cccccc"
|
||||
@@ -49,6 +52,7 @@ Dialog {
|
||||
onOpenLayout: stack.push(layoutPageComp)
|
||||
onOpenBackground: stack.push(backgroundPageComp)
|
||||
onOpenFeedback: stack.push(feedbackPageComp)
|
||||
onOpenBanner: stack.push(bannerPageComp)
|
||||
onOpenNetwork: stack.push(networkPageComp)
|
||||
onOpenUpdate: stack.push(updatePageComp)
|
||||
onChangePassword: changePwdDialog.open()
|
||||
@@ -68,6 +72,15 @@ Dialog {
|
||||
Component { id: feedbackPageComp; FeedbackPage {} }
|
||||
Component { id: networkPageComp; NetworkInfoPage {} }
|
||||
Component { id: updatePageComp; UpdatePage {} }
|
||||
Component {
|
||||
id: bannerPageComp
|
||||
BannerPage {
|
||||
onPreviewRequested: {
|
||||
dlg.close()
|
||||
dlg.bannerPreview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ButtonEditorDialog { id: editorDialog }
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import QtQuick 2.12
|
||||
|
||||
// Boot splash: plays resources/splash/splash.gif once over the UI, then fades
|
||||
// into it. Also hides button icons popping in while they load.
|
||||
// GIF on purpose: AnimatedImage needs only QtGui's gif plugin, while mp4 would
|
||||
// pull in QtMultimedia + gstreamer. The GIF starts and ends on black.
|
||||
//
|
||||
// The root is a plain black cover that exists from the very first frame, so
|
||||
// the UI never flashes before the GIF starts. It goes away only after the
|
||||
// GIF has finished (or right away, with a fade, if the splash is disabled).
|
||||
// Config: splash.enabled (read once at startup).
|
||||
Rectangle {
|
||||
id: splash
|
||||
anchors.fill: parent
|
||||
color: "black"
|
||||
|
||||
readonly property bool active: visible
|
||||
|
||||
function finish() {
|
||||
if (!visible || fadeOut.running)
|
||||
return
|
||||
fadeOut.start()
|
||||
}
|
||||
|
||||
// Swallow touches until the UI is visible. The recovery gesture works
|
||||
// anyway: it listens at window level in C++.
|
||||
MouseArea { anchors.fill: parent }
|
||||
|
||||
Loader {
|
||||
id: loader
|
||||
anchors.fill: parent
|
||||
active: settingsContainer.splashEnabled
|
||||
sourceComponent: AnimatedImage {
|
||||
// Square GIF: cover the screen and crop its thin grey frame.
|
||||
width: Math.max(splash.width, splash.height) * 1.02
|
||||
height: width
|
||||
x: (splash.width - width) / 2
|
||||
y: (splash.height - height) / 2
|
||||
fillMode: Image.PreserveAspectFit
|
||||
source: "qrc:/splash/splash.gif"
|
||||
asynchronous: false
|
||||
cache: false
|
||||
smooth: true
|
||||
playing: true
|
||||
onStatusChanged: {
|
||||
if (status === Image.Error) {
|
||||
console.warn("Splash: cannot load gif, skipping")
|
||||
splash.finish()
|
||||
}
|
||||
}
|
||||
onCurrentFrameChanged: {
|
||||
if (frameCount > 1 && currentFrame === frameCount - 1) {
|
||||
playing = false
|
||||
splash.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Splash disabled: keep the cover a moment so icons can load, then fade.
|
||||
Timer {
|
||||
interval: 600
|
||||
running: !loader.active
|
||||
onTriggered: splash.finish()
|
||||
}
|
||||
|
||||
// Safety net: never keep the UI hidden if the gif stalls.
|
||||
Timer {
|
||||
interval: 10000
|
||||
running: true
|
||||
onTriggered: splash.finish()
|
||||
}
|
||||
|
||||
NumberAnimation {
|
||||
id: fadeOut
|
||||
target: splash
|
||||
property: "opacity"
|
||||
to: 0
|
||||
duration: 450
|
||||
easing.type: Easing.InOutQuad
|
||||
onStopped: {
|
||||
splash.visible = false
|
||||
loader.active = false // frees the decoder and textures
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import QtQuick 2.12
|
||||
import QtQuick.Controls 2.12
|
||||
import QtQuick.Layouts 1.12
|
||||
import "../components"
|
||||
|
||||
// Announcement banner + boot splash (config sections "banner", "splash").
|
||||
Item {
|
||||
id: page
|
||||
|
||||
signal previewRequested()
|
||||
|
||||
function sync() {
|
||||
accentPicker.hex = settingsContainer.bannerAccentColor
|
||||
if (!textField.activeFocus)
|
||||
textField.text = settingsContainer.bannerText
|
||||
}
|
||||
|
||||
Component.onCompleted: sync()
|
||||
|
||||
Connections {
|
||||
target: settingsContainer
|
||||
function onChanged() { page.sync() }
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: "#161628"
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
anchors.fill: parent
|
||||
clip: true
|
||||
contentWidth: availableWidth
|
||||
contentHeight: col.implicitHeight + 16
|
||||
ScrollBar.vertical.policy: ScrollBar.AsNeeded
|
||||
|
||||
ColumnLayout {
|
||||
id: col
|
||||
width: page.width - 16
|
||||
x: 8
|
||||
y: 8
|
||||
spacing: 12
|
||||
|
||||
BackHeader { title: "Объявление и заставка"; onBack: page.StackView.view.pop() }
|
||||
|
||||
CheckBox {
|
||||
text: "Заставка при запуске"
|
||||
checked: settingsContainer.splashEnabled
|
||||
onToggled: btnController.setSplashEnabled(checked)
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
font: parent.font
|
||||
color: "#ffffff"
|
||||
leftPadding: parent.indicator.width + parent.spacing
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
|
||||
CheckBox {
|
||||
text: "Периодически показывать объявление"
|
||||
checked: settingsContainer.bannerEnabled
|
||||
onToggled: btnController.setBannerValue("enabled", checked)
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
font: parent.font
|
||||
color: "#ffffff"
|
||||
leftPadding: parent.indicator.width + parent.spacing
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: "Текст объявления"
|
||||
color: "#ffffff"
|
||||
}
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
TextField {
|
||||
id: textField
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 40
|
||||
maximumLength: 200
|
||||
placeholderText: "Для вызова клининга удерживайте кнопку"
|
||||
onAccepted: applyText.clicked()
|
||||
}
|
||||
Button {
|
||||
id: applyText
|
||||
text: "Сохранить"
|
||||
enabled: textField.text.trim() !== settingsContainer.bannerText
|
||||
onClicked: {
|
||||
btnController.setBannerValue("text", textField.text.trim())
|
||||
textField.focus = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LabeledSlider {
|
||||
label: "Раз в, сек"
|
||||
from: 30; to: 1800; stepSize: 30
|
||||
value: settingsContainer.bannerIntervalSec
|
||||
onMoved: btnController.setBannerValue("intervalSec", value)
|
||||
}
|
||||
|
||||
LabeledSlider {
|
||||
label: "Показ, сек"
|
||||
from: 3; to: 30; stepSize: 1
|
||||
value: settingsContainer.bannerShowSec
|
||||
onMoved: btnController.setBannerValue("showSec", value)
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Text {
|
||||
text: "Положение:"
|
||||
color: "#ffffff"
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Button {
|
||||
text: "Сверху"
|
||||
highlighted: settingsContainer.bannerPosition === "top"
|
||||
onClicked: btnController.setBannerValue("position", "top")
|
||||
}
|
||||
Button {
|
||||
text: "Снизу"
|
||||
highlighted: settingsContainer.bannerPosition === "bottom"
|
||||
onClicked: btnController.setBannerValue("position", "bottom")
|
||||
}
|
||||
}
|
||||
|
||||
ColorPickerField {
|
||||
id: accentPicker
|
||||
label: "Цвет полосы:"
|
||||
onColorChanged: btnController.setBannerValue("accentColor", hex)
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
Layout.fillWidth: true
|
||||
text: "Показать сейчас"
|
||||
enabled: settingsContainer.bannerText.length > 0
|
||||
onClicked: page.previewRequested()
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: "Объявление не перехватывает нажатия. Если текст не помещается, он прокручивается как бегущая строка."
|
||||
color: "#aaaaaa"
|
||||
font.pixelSize: 12
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
Item { Layout.preferredHeight: 8 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ Item {
|
||||
signal openLayout()
|
||||
signal openBackground()
|
||||
signal openFeedback()
|
||||
signal openBanner()
|
||||
signal openNetwork()
|
||||
signal openUpdate()
|
||||
signal changePassword()
|
||||
@@ -99,6 +100,12 @@ Item {
|
||||
text: "Обводка отклика (ОК / Ошибка)"
|
||||
onClicked: page.openFeedback()
|
||||
}
|
||||
SettingsButton {
|
||||
Layout.fillWidth: true
|
||||
text: "Объявление и заставка" +
|
||||
(settingsContainer.bannerEnabled ? " (вкл.)" : "")
|
||||
onClicked: page.openBanner()
|
||||
}
|
||||
SettingsButton {
|
||||
Layout.fillWidth: true
|
||||
text: "Сеть"
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
<file alias="SensorsPage.qml">../qml/SensorsPage.qml</file>
|
||||
<file alias="CalibrationDialog.qml">../qml/CalibrationDialog.qml</file>
|
||||
<file alias="SettingsDialog.qml">../qml/SettingsDialog.qml</file>
|
||||
<file alias="SplashOverlay.qml">../qml/SplashOverlay.qml</file>
|
||||
<file alias="InfoBanner.qml">../qml/InfoBanner.qml</file>
|
||||
<file alias="components/SettingsButton.qml">../qml/components/SettingsButton.qml</file>
|
||||
<file alias="components/BackHeader.qml">../qml/components/BackHeader.qml</file>
|
||||
<file alias="components/LabeledSlider.qml">../qml/components/LabeledSlider.qml</file>
|
||||
@@ -17,6 +19,7 @@
|
||||
<file alias="settings/FeedbackPage.qml">../qml/settings/FeedbackPage.qml</file>
|
||||
<file alias="settings/NetworkInfoPage.qml">../qml/settings/NetworkInfoPage.qml</file>
|
||||
<file alias="settings/UpdatePage.qml">../qml/settings/UpdatePage.qml</file>
|
||||
<file alias="settings/BannerPage.qml">../qml/settings/BannerPage.qml</file>
|
||||
<file alias="settings/ButtonEditorDialog.qml">../qml/settings/ButtonEditorDialog.qml</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<RCC>
|
||||
<qresource prefix="/splash">
|
||||
<file alias="splash.gif">splash/splash.gif</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.4 MiB |
@@ -731,6 +731,28 @@ void ButtonController::setFeedbackGlowRadius(int radius)
|
||||
m_config->setFeedback(f);
|
||||
}
|
||||
|
||||
void ButtonController::setBannerValue(const QString &key, const QVariant &value)
|
||||
{
|
||||
QJsonObject b = m_config->sectionObj("banner");
|
||||
if (key == QLatin1String("enabled"))
|
||||
b.insert(key, value.toBool());
|
||||
else if (key == QLatin1String("intervalSec") || key == QLatin1String("showSec"))
|
||||
b.insert(key, value.toInt());
|
||||
else if (key == QLatin1String("text") || key == QLatin1String("position")
|
||||
|| key == QLatin1String("accentColor"))
|
||||
b.insert(key, value.toString());
|
||||
else
|
||||
return;
|
||||
m_config->setSection("banner", b);
|
||||
}
|
||||
|
||||
void ButtonController::setSplashEnabled(bool enabled)
|
||||
{
|
||||
QJsonObject s = m_config->sectionObj("splash");
|
||||
s.insert("enabled", enabled);
|
||||
m_config->setSection("splash", s);
|
||||
}
|
||||
|
||||
void ButtonController::setDarkMode(bool dark)
|
||||
{
|
||||
QJsonObject s = m_config->settingsObj();
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include <QHash>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QTimer>
|
||||
#include <QVariant>
|
||||
|
||||
class ConfigManager;
|
||||
class ButtonsModel;
|
||||
@@ -73,6 +74,11 @@ public:
|
||||
Q_INVOKABLE void setFeedbackWidths(int okWidth, int errorWidth);
|
||||
Q_INVOKABLE void setFeedbackGlowRadius(int radius);
|
||||
|
||||
// Announcement banner: key is one of enabled/text/intervalSec/showSec/
|
||||
// position/accentColor (see config section "banner").
|
||||
Q_INVOKABLE void setBannerValue(const QString &key, const QVariant &value);
|
||||
Q_INVOKABLE void setSplashEnabled(bool enabled);
|
||||
|
||||
Q_INVOKABLE void setDarkMode(bool dark);
|
||||
Q_INVOKABLE void setPassword(const QString &pwd);
|
||||
|
||||
|
||||
@@ -146,6 +146,20 @@ bool ConfigManager::ensureDefaults()
|
||||
if (!fb.contains("pendingWidth")) fb.insert("pendingWidth", 6);
|
||||
m_root.insert("feedback", fb);
|
||||
|
||||
QJsonObject banner = m_root.value("banner").toObject();
|
||||
if (!banner.contains("enabled")) banner.insert("enabled", false);
|
||||
if (!banner.contains("text"))
|
||||
banner.insert("text", QStringLiteral("Для вызова клининга удерживайте кнопку"));
|
||||
if (!banner.contains("intervalSec")) banner.insert("intervalSec", 180);
|
||||
if (!banner.contains("showSec")) banner.insert("showSec", 10);
|
||||
if (!banner.contains("position")) banner.insert("position", "bottom");
|
||||
if (!banner.contains("accentColor")) banner.insert("accentColor", "#e4002b");
|
||||
m_root.insert("banner", banner);
|
||||
|
||||
QJsonObject splash = m_root.value("splash").toObject();
|
||||
if (!splash.contains("enabled")) splash.insert("enabled", true);
|
||||
m_root.insert("splash", splash);
|
||||
|
||||
QJsonObject st = m_root.value("settings").toObject();
|
||||
if (!st.contains("darkMode")) st.insert("darkMode", true);
|
||||
if (!st.contains("password")) st.insert("password", "admin");
|
||||
@@ -207,6 +221,11 @@ QJsonObject ConfigManager::settingsObj() const
|
||||
return m_root.value("settings").toObject();
|
||||
}
|
||||
|
||||
QJsonObject ConfigManager::sectionObj(const QString &name) const
|
||||
{
|
||||
return m_root.value(name).toObject();
|
||||
}
|
||||
|
||||
QString ConfigManager::iconsDir() const
|
||||
{
|
||||
QString d = settingsObj().value("iconsDir").toString();
|
||||
@@ -304,3 +323,10 @@ void ConfigManager::moveButton(int from, int to)
|
||||
arr.insert(to, v);
|
||||
setButtons(arr);
|
||||
}
|
||||
|
||||
void ConfigManager::setSection(const QString &name, const QJsonObject &obj)
|
||||
{
|
||||
m_root.insert(name, obj);
|
||||
save();
|
||||
emit configChanged();
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ public:
|
||||
QJsonObject backgroundObj() const;
|
||||
QJsonObject feedbackObj() const;
|
||||
QJsonObject settingsObj() const;
|
||||
// Small sections without a dedicated accessor ("banner", "splash").
|
||||
QJsonObject sectionObj(const QString &name) const;
|
||||
|
||||
QString configPath() const { return m_configPath; }
|
||||
QString iconsDir() const;
|
||||
@@ -39,6 +41,7 @@ public:
|
||||
void setBackground(const QJsonObject &obj);
|
||||
void setFeedback(const QJsonObject &obj);
|
||||
void setSettings(const QJsonObject &obj);
|
||||
void setSection(const QString &name, const QJsonObject &obj);
|
||||
|
||||
// Convenience
|
||||
void addButton(const QJsonObject &btn);
|
||||
|
||||
@@ -138,3 +138,44 @@ int SettingsContainer::feedbackPendingWidth() const
|
||||
{
|
||||
return m_config->feedbackObj().value("pendingWidth").toInt(6);
|
||||
}
|
||||
|
||||
bool SettingsContainer::splashEnabled() const
|
||||
{
|
||||
return m_config->sectionObj("splash").value("enabled").toBool(true);
|
||||
}
|
||||
|
||||
bool SettingsContainer::bannerEnabled() const
|
||||
{
|
||||
return m_config->sectionObj("banner").value("enabled").toBool(false);
|
||||
}
|
||||
|
||||
QString SettingsContainer::bannerText() const
|
||||
{
|
||||
return m_config->sectionObj("banner").value("text").toString().simplified();
|
||||
}
|
||||
|
||||
int SettingsContainer::bannerIntervalSec() const
|
||||
{
|
||||
return qBound(30, m_config->sectionObj("banner").value("intervalSec").toInt(180), 3600);
|
||||
}
|
||||
|
||||
int SettingsContainer::bannerShowSec() const
|
||||
{
|
||||
return qBound(3, m_config->sectionObj("banner").value("showSec").toInt(10), 60);
|
||||
}
|
||||
|
||||
QString SettingsContainer::bannerPosition() const
|
||||
{
|
||||
const QString p = m_config->sectionObj("banner").value("position").toString();
|
||||
return p == QLatin1String("top") ? p : QStringLiteral("bottom");
|
||||
}
|
||||
|
||||
QString SettingsContainer::bannerAccentColor() const
|
||||
{
|
||||
QString c = m_config->sectionObj("banner").value("accentColor").toString().trimmed();
|
||||
if (c.isEmpty())
|
||||
return QStringLiteral("#e4002b");
|
||||
if (!c.startsWith(QLatin1Char('#')))
|
||||
c.prepend(QLatin1Char('#'));
|
||||
return c;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,13 @@ class SettingsContainer : public QObject
|
||||
Q_PROPERTY(QString feedbackHoldColor READ feedbackHoldColor NOTIFY changed)
|
||||
Q_PROPERTY(QString feedbackPendingColor READ feedbackPendingColor NOTIFY changed)
|
||||
Q_PROPERTY(int feedbackPendingWidth READ feedbackPendingWidth NOTIFY changed)
|
||||
Q_PROPERTY(bool splashEnabled READ splashEnabled NOTIFY changed)
|
||||
Q_PROPERTY(bool bannerEnabled READ bannerEnabled NOTIFY changed)
|
||||
Q_PROPERTY(QString bannerText READ bannerText NOTIFY changed)
|
||||
Q_PROPERTY(int bannerIntervalSec READ bannerIntervalSec NOTIFY changed)
|
||||
Q_PROPERTY(int bannerShowSec READ bannerShowSec NOTIFY changed)
|
||||
Q_PROPERTY(QString bannerPosition READ bannerPosition NOTIFY changed)
|
||||
Q_PROPERTY(QString bannerAccentColor READ bannerAccentColor NOTIFY changed)
|
||||
|
||||
public:
|
||||
explicit SettingsContainer(ConfigManager *config, QObject *parent = nullptr);
|
||||
@@ -62,6 +69,14 @@ public:
|
||||
QString feedbackPendingColor() const;
|
||||
int feedbackPendingWidth() const;
|
||||
|
||||
bool splashEnabled() const;
|
||||
bool bannerEnabled() const;
|
||||
QString bannerText() const;
|
||||
int bannerIntervalSec() const; // clamped to 30..3600
|
||||
int bannerShowSec() const; // clamped to 3..60
|
||||
QString bannerPosition() const; // "top" | "bottom"
|
||||
QString bannerAccentColor() const;
|
||||
|
||||
signals:
|
||||
void changed();
|
||||
|
||||
|
||||
@@ -305,6 +305,33 @@ def api_set_feedback():
|
||||
return jsonify(fb)
|
||||
|
||||
|
||||
@api_bp.route("/api/banner", methods=["PUT"])
|
||||
@login_required
|
||||
def api_set_banner():
|
||||
"""Announcement banner + boot splash toggle (sections "banner", "splash")."""
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
cfg = load_config()
|
||||
banner = cfg.setdefault("banner", {})
|
||||
if "enabled" in data:
|
||||
banner["enabled"] = bool(data["enabled"])
|
||||
if "text" in data:
|
||||
banner["text"] = " ".join(str(data["text"] or "").split())[:200]
|
||||
for k, lo, hi in (("intervalSec", 30, 3600), ("showSec", 3, 60)):
|
||||
if k in data:
|
||||
try:
|
||||
banner[k] = max(lo, min(hi, int(data[k])))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if data.get("position") in ("top", "bottom"):
|
||||
banner["position"] = data["position"]
|
||||
if isinstance(data.get("accentColor"), str) and data["accentColor"].startswith("#"):
|
||||
banner["accentColor"] = data["accentColor"][:9]
|
||||
if "splashEnabled" in data:
|
||||
cfg.setdefault("splash", {})["enabled"] = bool(data["splashEnabled"])
|
||||
save_config(cfg)
|
||||
return jsonify({"banner": banner, "splash": cfg.get("splash", {})})
|
||||
|
||||
|
||||
@api_bp.route("/api/settings", methods=["PUT"])
|
||||
@login_required
|
||||
def api_set_settings():
|
||||
|
||||
@@ -49,6 +49,15 @@ def _defaults():
|
||||
"pendingColor": "#ffffff",
|
||||
"pendingWidth": 6,
|
||||
},
|
||||
"banner": {
|
||||
"enabled": False,
|
||||
"text": "Для вызова клининга удерживайте кнопку",
|
||||
"intervalSec": 180,
|
||||
"showSec": 10,
|
||||
"position": "bottom",
|
||||
"accentColor": "#e4002b",
|
||||
},
|
||||
"splash": {"enabled": True},
|
||||
"settings": {
|
||||
"darkMode": True,
|
||||
"password": "admin_26",
|
||||
@@ -68,7 +77,7 @@ def ensure_defaults(cfg):
|
||||
if not isinstance(cfg, dict):
|
||||
return base
|
||||
cfg.setdefault("version", base["version"])
|
||||
for section in ("layout", "background", "feedback", "settings"):
|
||||
for section in ("layout", "background", "feedback", "banner", "splash", "settings"):
|
||||
sec = cfg.setdefault(section, {})
|
||||
if not isinstance(sec, dict):
|
||||
sec = {}
|
||||
|
||||
@@ -548,6 +548,28 @@ $("#save-feedback")?.addEventListener("click", async () => {
|
||||
} catch (e) { flash(e.message, true); }
|
||||
});
|
||||
|
||||
// ---------- Banner + splash ----------
|
||||
function syncBannerCount() {
|
||||
const t = $("#banner-text"), c = $("#banner-text-count");
|
||||
if (t && c) c.textContent = t.value.length;
|
||||
}
|
||||
$("#banner-text")?.addEventListener("input", syncBannerCount);
|
||||
syncBannerCount();
|
||||
$("#save-banner")?.addEventListener("click", async () => {
|
||||
try {
|
||||
await api("PUT", "/api/banner", {
|
||||
splashEnabled: $("#splash-enabled").checked,
|
||||
enabled: $("#banner-enabled").checked,
|
||||
text: $("#banner-text").value,
|
||||
intervalSec: parseInt($("#banner-interval").value, 10),
|
||||
showSec: parseInt($("#banner-show").value, 10),
|
||||
position: $("#banner-position").value,
|
||||
accentColor: $("#banner-accent").value,
|
||||
});
|
||||
flash("Объявление сохранено");
|
||||
} catch (e) { flash(e.message, true); }
|
||||
});
|
||||
|
||||
// ---------- Icons ----------
|
||||
$("#upload-icon")?.addEventListener("click", async () => {
|
||||
const file = $("#icon-file").files[0];
|
||||
|
||||
@@ -105,6 +105,16 @@ main {
|
||||
label { display: flex; flex-direction: column; gap: 5px; font-size: 0.88em; color: var(--muted); }
|
||||
label.check { flex-direction: row; align-items: center; gap: 8px; color: var(--text); }
|
||||
label.label-range { gap: 6px; }
|
||||
.banner-text-label { margin-bottom: 14px; }
|
||||
.banner-text-label textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 4.5em;
|
||||
resize: vertical;
|
||||
font: inherit;
|
||||
font-size: 1.1em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.label-caption {
|
||||
display: block;
|
||||
font-size: 0.88em;
|
||||
@@ -117,7 +127,7 @@ label.label-range { gap: 6px; }
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input[type="text"], input[type="number"], input[type="password"], select {
|
||||
input[type="text"], input[type="number"], input[type="password"], select, textarea {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text);
|
||||
@@ -126,7 +136,7 @@ input[type="text"], input[type="number"], input[type="password"], select {
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
input:focus, select:focus { border-color: var(--accent); outline: none; }
|
||||
input:focus, select:focus, textarea:focus { border-color: var(--accent); outline: none; }
|
||||
input[type="color"] {
|
||||
width: 48px; height: 34px; padding: 2px;
|
||||
background: var(--bg-input); border: 1px solid var(--border-strong);
|
||||
|
||||
@@ -214,6 +214,43 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Объявление и заставка</h2>
|
||||
<p class="hint">Объявление периодически плавно появляется поверх кнопок, как табло в метро, и не мешает нажатиям. Длинный текст прокручивается бегущей строкой. После сохранения объявление один раз показывается на экране.</p>
|
||||
<label class="banner-text-label">Текст объявления
|
||||
<textarea id="banner-text" rows="3" maxlength="200" placeholder="Для вызова клининга удерживайте кнопку">{{ config.banner.text }}</textarea>
|
||||
<span class="hint"><span id="banner-text-count">0</span>/200. Переносы строк заменяются пробелами: на экране текст идёт в одну строку.</span>
|
||||
</label>
|
||||
<div class="grid-form">
|
||||
<label class="check">
|
||||
<input type="checkbox" id="splash-enabled" {% if config.splash.enabled %}checked{% endif %}>
|
||||
Заставка при запуске
|
||||
</label>
|
||||
<label class="check">
|
||||
<input type="checkbox" id="banner-enabled" {% if config.banner.enabled %}checked{% endif %}>
|
||||
Показывать объявление
|
||||
</label>
|
||||
<label>Раз в, сек (30–3600)
|
||||
<input type="number" id="banner-interval" min="30" max="3600" step="10" value="{{ config.banner.intervalSec }}">
|
||||
</label>
|
||||
<label>Длительность показа, сек (3–60)
|
||||
<input type="number" id="banner-show" min="3" max="60" value="{{ config.banner.showSec }}">
|
||||
</label>
|
||||
<label>Положение
|
||||
<select id="banner-position">
|
||||
<option value="bottom" {% if config.banner.position != 'top' %}selected{% endif %}>Снизу</option>
|
||||
<option value="top" {% if config.banner.position == 'top' %}selected{% endif %}>Сверху</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Цвет полосы
|
||||
<input type="color" id="banner-accent" value="{{ config.banner.accentColor|default('#e4002b', true) }}">
|
||||
</label>
|
||||
</div>
|
||||
<div class="card-actions compact">
|
||||
<button id="save-banner" class="primary">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Иконки</h2>
|
||||
<div class="row">
|
||||
|
||||
Reference in New Issue
Block a user