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:
2026-11-10 10:58:16 +00:00
co-authored by Claude Opus 5.5
parent ec84133417
commit f07e153f09
22 changed files with 753 additions and 3 deletions
+1
View File
@@ -36,6 +36,7 @@ set(SOURCES
set(RESOURCES
resources/qml.qrc
resources/icons.qrc
resources/splash.qrc
)
add_executable(ButtonTask ${SOURCES} ${RESOURCES})
+14
View File
@@ -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
+228
View File
@@ -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 }
}
}
}
+20
View File
@@ -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 {
+13
View File
@@ -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 }
+86
View File
@@ -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
}
}
}
+155
View File
@@ -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 }
}
}
}
+7
View File
@@ -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: "Сеть"
+3
View File
@@ -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>
+5
View File
@@ -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

+22
View File
@@ -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();
+6
View File
@@ -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);
+26
View File
@@ -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();
}
+3
View File
@@ -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);
+41
View File
@@ -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;
}
+15
View File
@@ -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();
+27
View File
@@ -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():
+10 -1
View File
@@ -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 = {}
+22
View File
@@ -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];
+12 -2
View File
@@ -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);
+37
View File
@@ -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">