feat: Add BookmarkStore and BookmarkListActivity for bookmark management
Introduces persistent bookmark storage with JSON-based file format and a dedicated activity for viewing bookmarks organized by book.
This commit is contained in:
262
src/activities/home/BookmarkListActivity.cpp
Normal file
262
src/activities/home/BookmarkListActivity.cpp
Normal file
@@ -0,0 +1,262 @@
|
||||
#include "BookmarkListActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
|
||||
#include "BookmarkStore.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "ScreenComponents.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
namespace {
|
||||
constexpr int BASE_TAB_BAR_Y = 15;
|
||||
constexpr int BASE_CONTENT_START_Y = 60;
|
||||
constexpr int LINE_HEIGHT = 50; // Taller for bookmark name + location
|
||||
constexpr int BASE_LEFT_MARGIN = 20;
|
||||
constexpr int BASE_RIGHT_MARGIN = 40;
|
||||
constexpr unsigned long ACTION_MENU_MS = 700; // Long press to delete
|
||||
} // namespace
|
||||
|
||||
int BookmarkListActivity::getPageItems() const {
|
||||
const int screenHeight = renderer.getScreenHeight();
|
||||
const int bottomBarHeight = 60;
|
||||
const int bezelTop = renderer.getBezelOffsetTop();
|
||||
const int bezelBottom = renderer.getBezelOffsetBottom();
|
||||
const int availableHeight = screenHeight - (BASE_CONTENT_START_Y + bezelTop) - bottomBarHeight - bezelBottom;
|
||||
int items = availableHeight / LINE_HEIGHT;
|
||||
if (items < 1) items = 1;
|
||||
return items;
|
||||
}
|
||||
|
||||
int BookmarkListActivity::getTotalPages() const {
|
||||
const int itemCount = static_cast<int>(bookmarks.size());
|
||||
const int pageItems = getPageItems();
|
||||
if (itemCount == 0) return 1;
|
||||
return (itemCount + pageItems - 1) / pageItems;
|
||||
}
|
||||
|
||||
int BookmarkListActivity::getCurrentPage() const {
|
||||
const int pageItems = getPageItems();
|
||||
return selectorIndex / pageItems + 1;
|
||||
}
|
||||
|
||||
void BookmarkListActivity::loadBookmarks() {
|
||||
bookmarks = BookmarkStore::getBookmarks(bookPath);
|
||||
}
|
||||
|
||||
void BookmarkListActivity::taskTrampoline(void* param) {
|
||||
auto* self = static_cast<BookmarkListActivity*>(param);
|
||||
self->displayTaskLoop();
|
||||
}
|
||||
|
||||
void BookmarkListActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
renderingMutex = xSemaphoreCreateMutex();
|
||||
|
||||
loadBookmarks();
|
||||
selectorIndex = 0;
|
||||
updateRequired = true;
|
||||
|
||||
xTaskCreate(&BookmarkListActivity::taskTrampoline, "BookmarkListTask",
|
||||
2048, // Stack size
|
||||
this, // Parameters
|
||||
1, // Priority
|
||||
&displayTaskHandle // Task handle
|
||||
);
|
||||
}
|
||||
|
||||
void BookmarkListActivity::onExit() {
|
||||
Activity::onExit();
|
||||
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
if (displayTaskHandle) {
|
||||
vTaskDelete(displayTaskHandle);
|
||||
displayTaskHandle = nullptr;
|
||||
vTaskDelay(10 / portTICK_PERIOD_MS);
|
||||
}
|
||||
vSemaphoreDelete(renderingMutex);
|
||||
renderingMutex = nullptr;
|
||||
|
||||
bookmarks.clear();
|
||||
}
|
||||
|
||||
void BookmarkListActivity::loop() {
|
||||
// Handle confirmation state
|
||||
if (uiState == UIState::Confirming) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
uiState = UIState::Normal;
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
// Delete the bookmark
|
||||
if (!bookmarks.empty() && selectorIndex < static_cast<int>(bookmarks.size())) {
|
||||
const auto& bm = bookmarks[selectorIndex];
|
||||
BookmarkStore::removeBookmark(bookPath, bm.spineIndex, bm.contentOffset);
|
||||
loadBookmarks();
|
||||
|
||||
// Adjust selector if needed
|
||||
if (selectorIndex >= static_cast<int>(bookmarks.size()) && !bookmarks.empty()) {
|
||||
selectorIndex = static_cast<int>(bookmarks.size()) - 1;
|
||||
} else if (bookmarks.empty()) {
|
||||
selectorIndex = 0;
|
||||
}
|
||||
}
|
||||
uiState = UIState::Normal;
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal state handling
|
||||
const int itemCount = static_cast<int>(bookmarks.size());
|
||||
const int pageItems = getPageItems();
|
||||
|
||||
// Long press Confirm to delete bookmark
|
||||
if (mappedInput.isPressed(MappedInputManager::Button::Confirm) &&
|
||||
mappedInput.getHeldTime() >= ACTION_MENU_MS && !bookmarks.empty() &&
|
||||
selectorIndex < itemCount) {
|
||||
uiState = UIState::Confirming;
|
||||
updateRequired = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Short press Confirm - navigate to bookmark
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (mappedInput.getHeldTime() >= ACTION_MENU_MS) {
|
||||
return; // Was a long press
|
||||
}
|
||||
|
||||
if (!bookmarks.empty() && selectorIndex < itemCount) {
|
||||
const auto& bm = bookmarks[selectorIndex];
|
||||
onSelectBookmark(bm.spineIndex, bm.contentOffset);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Back button
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
onGoBack();
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigation
|
||||
const bool upReleased = mappedInput.wasReleased(MappedInputManager::Button::Up);
|
||||
const bool downReleased = mappedInput.wasReleased(MappedInputManager::Button::Down);
|
||||
|
||||
if (upReleased && itemCount > 0) {
|
||||
selectorIndex = (selectorIndex + itemCount - 1) % itemCount;
|
||||
updateRequired = true;
|
||||
} else if (downReleased && itemCount > 0) {
|
||||
selectorIndex = (selectorIndex + 1) % itemCount;
|
||||
updateRequired = true;
|
||||
}
|
||||
}
|
||||
|
||||
void BookmarkListActivity::displayTaskLoop() {
|
||||
while (true) {
|
||||
if (updateRequired) {
|
||||
updateRequired = false;
|
||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||
render();
|
||||
xSemaphoreGive(renderingMutex);
|
||||
}
|
||||
vTaskDelay(10 / portTICK_PERIOD_MS);
|
||||
}
|
||||
}
|
||||
|
||||
void BookmarkListActivity::render() const {
|
||||
renderer.clearScreen();
|
||||
|
||||
if (uiState == UIState::Confirming) {
|
||||
renderConfirmation();
|
||||
renderer.displayBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const int pageItems = getPageItems();
|
||||
const int itemCount = static_cast<int>(bookmarks.size());
|
||||
|
||||
// Calculate bezel-adjusted margins
|
||||
const int bezelTop = renderer.getBezelOffsetTop();
|
||||
const int bezelLeft = renderer.getBezelOffsetLeft();
|
||||
const int bezelRight = renderer.getBezelOffsetRight();
|
||||
const int bezelBottom = renderer.getBezelOffsetBottom();
|
||||
const int CONTENT_START_Y = BASE_CONTENT_START_Y + bezelTop;
|
||||
const int LEFT_MARGIN = BASE_LEFT_MARGIN + bezelLeft;
|
||||
const int RIGHT_MARGIN = BASE_RIGHT_MARGIN + bezelRight;
|
||||
|
||||
// Draw title
|
||||
auto truncatedTitle = renderer.truncatedText(UI_12_FONT_ID, bookTitle.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN);
|
||||
renderer.drawText(UI_12_FONT_ID, LEFT_MARGIN, BASE_TAB_BAR_Y + bezelTop, truncatedTitle.c_str(), true, EpdFontFamily::BOLD);
|
||||
|
||||
if (itemCount == 0) {
|
||||
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, CONTENT_START_Y, "No bookmarks");
|
||||
|
||||
const auto labels = mappedInput.mapLabels("\xc2\xab Back", "", "", "");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
renderer.displayBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
|
||||
|
||||
// Draw selection highlight
|
||||
renderer.fillRect(bezelLeft, CONTENT_START_Y + (selectorIndex % pageItems) * LINE_HEIGHT - 2,
|
||||
pageWidth - RIGHT_MARGIN - bezelLeft, LINE_HEIGHT);
|
||||
|
||||
// Draw items
|
||||
for (int i = pageStartIndex; i < itemCount && i < pageStartIndex + pageItems; i++) {
|
||||
const auto& bm = bookmarks[i];
|
||||
const int y = CONTENT_START_Y + (i % pageItems) * LINE_HEIGHT;
|
||||
const bool isSelected = (i == selectorIndex);
|
||||
|
||||
// Line 1: Bookmark name
|
||||
auto truncatedName = renderer.truncatedText(UI_10_FONT_ID, bm.name.c_str(), pageWidth - LEFT_MARGIN - RIGHT_MARGIN);
|
||||
renderer.drawText(UI_10_FONT_ID, LEFT_MARGIN, y + 2, truncatedName.c_str(), !isSelected);
|
||||
|
||||
// Line 2: Location info
|
||||
std::string locText = "Page " + std::to_string(bm.pageNumber + 1);
|
||||
renderer.drawText(SMALL_FONT_ID, LEFT_MARGIN, y + 26, locText.c_str(), !isSelected);
|
||||
}
|
||||
|
||||
// Draw scroll indicator
|
||||
const int screenHeight = renderer.getScreenHeight();
|
||||
const int contentHeight = screenHeight - CONTENT_START_Y - 60 - bezelBottom;
|
||||
ScreenComponents::drawScrollIndicator(renderer, getCurrentPage(), getTotalPages(), CONTENT_START_Y, contentHeight);
|
||||
|
||||
// Draw side button hints
|
||||
renderer.drawSideButtonHints(UI_10_FONT_ID, ">", "<");
|
||||
|
||||
// Draw bottom button hints
|
||||
const auto labels = mappedInput.mapLabels("\xc2\xab Back", "Go to", "", "");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
|
||||
renderer.displayBuffer();
|
||||
}
|
||||
|
||||
void BookmarkListActivity::renderConfirmation() const {
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
// Title
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 20, "Delete Bookmark?", true, EpdFontFamily::BOLD);
|
||||
|
||||
// Show bookmark name
|
||||
if (!bookmarks.empty() && selectorIndex < static_cast<int>(bookmarks.size())) {
|
||||
const auto& bm = bookmarks[selectorIndex];
|
||||
auto truncatedName = renderer.truncatedText(UI_10_FONT_ID, bm.name.c_str(), pageWidth - 40);
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, truncatedName.c_str());
|
||||
}
|
||||
|
||||
// Warning text
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 20, "This cannot be undone.");
|
||||
|
||||
// Draw bottom button hints
|
||||
const auto labels = mappedInput.mapLabels("\xc2\xab Cancel", "Delete", "", "");
|
||||
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
}
|
||||
65
src/activities/home/BookmarkListActivity.h
Normal file
65
src/activities/home/BookmarkListActivity.h
Normal file
@@ -0,0 +1,65 @@
|
||||
#pragma once
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../Activity.h"
|
||||
#include "BookmarkStore.h"
|
||||
|
||||
/**
|
||||
* BookmarkListActivity displays all bookmarks for a specific book.
|
||||
* - Short press: Navigate to bookmark location
|
||||
* - Long press Confirm: Delete bookmark (with confirmation)
|
||||
* - Back: Return to previous screen
|
||||
*/
|
||||
class BookmarkListActivity final : public Activity {
|
||||
public:
|
||||
enum class UIState { Normal, Confirming };
|
||||
|
||||
private:
|
||||
TaskHandle_t displayTaskHandle = nullptr;
|
||||
SemaphoreHandle_t renderingMutex = nullptr;
|
||||
|
||||
std::string bookPath;
|
||||
std::string bookTitle;
|
||||
std::vector<Bookmark> bookmarks;
|
||||
int selectorIndex = 0;
|
||||
bool updateRequired = false;
|
||||
UIState uiState = UIState::Normal;
|
||||
|
||||
// Callbacks
|
||||
const std::function<void()> onGoBack;
|
||||
const std::function<void(uint16_t spineIndex, uint32_t contentOffset)> onSelectBookmark;
|
||||
|
||||
// Number of items that fit on a page
|
||||
int getPageItems() const;
|
||||
int getTotalPages() const;
|
||||
int getCurrentPage() const;
|
||||
|
||||
// Data loading
|
||||
void loadBookmarks();
|
||||
|
||||
// Rendering
|
||||
static void taskTrampoline(void* param);
|
||||
[[noreturn]] void displayTaskLoop();
|
||||
void render() const;
|
||||
void renderConfirmation() const;
|
||||
|
||||
public:
|
||||
explicit BookmarkListActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const std::string& bookPath, const std::string& bookTitle,
|
||||
const std::function<void()>& onGoBack,
|
||||
const std::function<void(uint16_t spineIndex, uint32_t contentOffset)>& onSelectBookmark)
|
||||
: Activity("BookmarkList", renderer, mappedInput),
|
||||
bookPath(bookPath),
|
||||
bookTitle(bookTitle),
|
||||
onGoBack(onGoBack),
|
||||
onSelectBookmark(onSelectBookmark) {}
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
void loop() override;
|
||||
};
|
||||
Reference in New Issue
Block a user