feat: add OPDS server reordering with sortOrder field and numeric stepper
Servers are sorted by a persistent sortOrder field (ties broken alphabetically). On-device editing uses a new NumericStepperActivity with side buttons for ±1 and face buttons for ±10. The web UI gets up/down arrow buttons and a POST /api/opds/reorder endpoint. Made-with: Cursor
This commit is contained in:
@@ -4,18 +4,20 @@
|
||||
#include <I18n.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "activities/util/DirectoryPickerActivity.h"
|
||||
#include "activities/util/KeyboardEntryActivity.h"
|
||||
#include "activities/util/NumericStepperActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
// Editable fields: Name, URL, Username, Password, Download Path.
|
||||
// Editable fields: Position, Name, URL, Username, Password, Download Path.
|
||||
// Existing servers also show a Delete option (BASE_ITEMS + 1).
|
||||
constexpr int BASE_ITEMS = 5;
|
||||
constexpr int BASE_ITEMS = 6;
|
||||
} // namespace
|
||||
|
||||
int OpdsSettingsActivity::getMenuItemCount() const {
|
||||
@@ -75,17 +77,38 @@ void OpdsSettingsActivity::loop() {
|
||||
void OpdsSettingsActivity::saveServer() {
|
||||
if (isNewServer) {
|
||||
OPDS_STORE.addServer(editServer);
|
||||
// After the first field is saved, promote to an existing server so
|
||||
// subsequent field edits update in-place rather than creating duplicates.
|
||||
isNewServer = false;
|
||||
serverIndex = static_cast<int>(OPDS_STORE.getCount()) - 1;
|
||||
} else {
|
||||
OPDS_STORE.updateServer(static_cast<size_t>(serverIndex), editServer);
|
||||
}
|
||||
|
||||
// Re-locate our server after add/update may have re-sorted the vector
|
||||
const auto& servers = OPDS_STORE.getServers();
|
||||
for (size_t i = 0; i < servers.size(); i++) {
|
||||
if (servers[i].url == editServer.url && servers[i].name == editServer.name) {
|
||||
serverIndex = static_cast<int>(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void OpdsSettingsActivity::handleSelection() {
|
||||
if (selectedIndex == 0) {
|
||||
// Position (sort order)
|
||||
exitActivity();
|
||||
enterNewActivity(new NumericStepperActivity(
|
||||
renderer, mappedInput, tr(STR_POSITION), editServer.sortOrder, 1, 99,
|
||||
[this](int value) {
|
||||
editServer.sortOrder = value;
|
||||
saveServer();
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
},
|
||||
[this]() {
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
}));
|
||||
} else if (selectedIndex == 1) {
|
||||
// Server Name
|
||||
exitActivity();
|
||||
enterNewActivity(new KeyboardEntryActivity(
|
||||
@@ -100,7 +123,7 @@ void OpdsSettingsActivity::handleSelection() {
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
}));
|
||||
} else if (selectedIndex == 1) {
|
||||
} else if (selectedIndex == 2) {
|
||||
// Server URL
|
||||
exitActivity();
|
||||
enterNewActivity(new KeyboardEntryActivity(
|
||||
@@ -115,7 +138,7 @@ void OpdsSettingsActivity::handleSelection() {
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
}));
|
||||
} else if (selectedIndex == 2) {
|
||||
} else if (selectedIndex == 3) {
|
||||
// Username
|
||||
exitActivity();
|
||||
enterNewActivity(new KeyboardEntryActivity(
|
||||
@@ -130,7 +153,7 @@ void OpdsSettingsActivity::handleSelection() {
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
}));
|
||||
} else if (selectedIndex == 3) {
|
||||
} else if (selectedIndex == 4) {
|
||||
// Password
|
||||
exitActivity();
|
||||
enterNewActivity(new KeyboardEntryActivity(
|
||||
@@ -145,7 +168,7 @@ void OpdsSettingsActivity::handleSelection() {
|
||||
exitActivity();
|
||||
requestUpdate();
|
||||
}));
|
||||
} else if (selectedIndex == 4) {
|
||||
} else if (selectedIndex == 5) {
|
||||
// Download Path
|
||||
exitActivity();
|
||||
enterNewActivity(new DirectoryPickerActivity(
|
||||
@@ -162,7 +185,7 @@ void OpdsSettingsActivity::handleSelection() {
|
||||
requestUpdate();
|
||||
},
|
||||
editServer.downloadPath));
|
||||
} else if (selectedIndex == 5 && !isNewServer) {
|
||||
} else if (selectedIndex == 6 && !isNewServer) {
|
||||
// Delete server
|
||||
OPDS_STORE.removeServer(static_cast<size_t>(serverIndex));
|
||||
onBack();
|
||||
@@ -184,8 +207,8 @@ void OpdsSettingsActivity::render(Activity::RenderLock&&) {
|
||||
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2;
|
||||
const int menuItems = getMenuItemCount();
|
||||
|
||||
const StrId fieldNames[] = {StrId::STR_SERVER_NAME, StrId::STR_OPDS_SERVER_URL, StrId::STR_USERNAME,
|
||||
StrId::STR_PASSWORD, StrId::STR_DOWNLOAD_PATH};
|
||||
const StrId fieldNames[] = {StrId::STR_POSITION, StrId::STR_SERVER_NAME, StrId::STR_OPDS_SERVER_URL,
|
||||
StrId::STR_USERNAME, StrId::STR_PASSWORD, StrId::STR_DOWNLOAD_PATH};
|
||||
|
||||
GUI.drawList(
|
||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, menuItems, static_cast<int>(selectedIndex),
|
||||
@@ -198,14 +221,16 @@ void OpdsSettingsActivity::render(Activity::RenderLock&&) {
|
||||
nullptr, nullptr,
|
||||
[this](int index) {
|
||||
if (index == 0) {
|
||||
return editServer.name.empty() ? std::string(tr(STR_NOT_SET)) : editServer.name;
|
||||
return std::to_string(editServer.sortOrder);
|
||||
} else if (index == 1) {
|
||||
return editServer.url.empty() ? std::string(tr(STR_NOT_SET)) : editServer.url;
|
||||
return editServer.name.empty() ? std::string(tr(STR_NOT_SET)) : editServer.name;
|
||||
} else if (index == 2) {
|
||||
return editServer.username.empty() ? std::string(tr(STR_NOT_SET)) : editServer.username;
|
||||
return editServer.url.empty() ? std::string(tr(STR_NOT_SET)) : editServer.url;
|
||||
} else if (index == 3) {
|
||||
return editServer.password.empty() ? std::string(tr(STR_NOT_SET)) : std::string("******");
|
||||
return editServer.username.empty() ? std::string(tr(STR_NOT_SET)) : editServer.username;
|
||||
} else if (index == 4) {
|
||||
return editServer.password.empty() ? std::string(tr(STR_NOT_SET)) : std::string("******");
|
||||
} else if (index == 5) {
|
||||
return editServer.downloadPath;
|
||||
}
|
||||
return std::string("");
|
||||
|
||||
99
src/activities/util/NumericStepperActivity.cpp
Normal file
99
src/activities/util/NumericStepperActivity.cpp
Normal file
@@ -0,0 +1,99 @@
|
||||
#include "NumericStepperActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
void NumericStepperActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void NumericStepperActivity::onExit() { Activity::onExit(); }
|
||||
|
||||
void NumericStepperActivity::loop() {
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||
if (onCancel) onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||
if (onComplete) onComplete(value);
|
||||
return;
|
||||
}
|
||||
|
||||
// Side buttons: ±1
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Up)) {
|
||||
if (value < maxValue) {
|
||||
value++;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Down)) {
|
||||
if (value > minValue) {
|
||||
value--;
|
||||
requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Front face buttons: ±10
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Right)) {
|
||||
value = std::min(value + 10, maxValue);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasPressed(MappedInputManager::Button::Left)) {
|
||||
value = std::max(value - 10, minValue);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void NumericStepperActivity::render(Activity::RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const int lineHeight12 = renderer.getLineHeight(UI_12_FONT_ID);
|
||||
|
||||
const auto& metrics = UITheme::getInstance().getMetrics();
|
||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, title.c_str());
|
||||
|
||||
char valueStr[16];
|
||||
snprintf(valueStr, sizeof(valueStr), "%d", value);
|
||||
|
||||
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, valueStr);
|
||||
const int startX = (pageWidth - textWidth) / 2;
|
||||
const int valueY = metrics.topPadding + metrics.headerHeight + 60;
|
||||
|
||||
constexpr int highlightPad = 10;
|
||||
renderer.fillRoundedRect(startX - highlightPad, valueY - 4, textWidth + highlightPad * 2, lineHeight12 + 8, 6,
|
||||
Color::LightGray);
|
||||
renderer.drawText(UI_12_FONT_ID, startX, valueY, valueStr, true);
|
||||
|
||||
const int arrowX = pageWidth / 2;
|
||||
const int arrowUpY = valueY - 20;
|
||||
const int arrowDownY = valueY + lineHeight12 + 12;
|
||||
constexpr int arrowSize = 6;
|
||||
for (int row = 0; row < arrowSize; row++) {
|
||||
renderer.drawLine(arrowX - row, arrowUpY + row, arrowX + row, arrowUpY + row);
|
||||
}
|
||||
for (int row = 0; row < arrowSize; row++) {
|
||||
renderer.drawLine(arrowX - row, arrowDownY + arrowSize - 1 - row, arrowX + row, arrowDownY + arrowSize - 1 - row);
|
||||
}
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SAVE), "-10", "+10");
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
GUI.drawSideButtonHints(renderer, "+1", "-1");
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
42
src/activities/util/NumericStepperActivity.h
Normal file
42
src/activities/util/NumericStepperActivity.h
Normal file
@@ -0,0 +1,42 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
#include "activities/Activity.h"
|
||||
|
||||
/**
|
||||
* Reusable numeric stepper for integer value entry.
|
||||
* Side buttons (Up/Down) step by 1, face buttons (Left/Right) step by 10.
|
||||
* Value is clamped within [min, max].
|
||||
*/
|
||||
class NumericStepperActivity final : public Activity {
|
||||
public:
|
||||
using OnCompleteCallback = std::function<void(int)>;
|
||||
using OnCancelCallback = std::function<void()>;
|
||||
|
||||
explicit NumericStepperActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::string title,
|
||||
int initialValue, int minValue, int maxValue, OnCompleteCallback onComplete,
|
||||
OnCancelCallback onCancel)
|
||||
: Activity("NumericStepper", renderer, mappedInput),
|
||||
title(std::move(title)),
|
||||
value(initialValue),
|
||||
minValue(minValue),
|
||||
maxValue(maxValue),
|
||||
onComplete(std::move(onComplete)),
|
||||
onCancel(std::move(onCancel)) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
void render(Activity::RenderLock&&) override;
|
||||
|
||||
private:
|
||||
std::string title;
|
||||
int value;
|
||||
int minValue;
|
||||
int maxValue;
|
||||
OnCompleteCallback onComplete;
|
||||
OnCancelCallback onCancel;
|
||||
};
|
||||
Reference in New Issue
Block a user