diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 2f20a0d29b..ea1e1e0252 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -658,6 +658,30 @@ STR_WEATHER_SHOWERS: "Showers" STR_WEATHER_THUNDERSTORM: "Thunderstorm" STR_LAST_UPDATED: "Updated" +STR_TASKS: "To-Do List" +STR_TASKS_FETCHING: "Fetching tasks..." +STR_TASKS_NO_TASKS: "Nothing to do" +STR_TASKS_OFFLINE: "WiFi required" +STR_TASKS_FETCH_FAILED: "Failed to load tasks" +STR_TASKS_TOKEN_SETUP_HINT: "Edit /.crosspoint/tasks.json on the SD card and set todoistApiToken" +STR_TASKS_UPDATED: "Updated" +STR_PROVIDER_TODOIST: "Todoist" +STR_TASKS_PROVIDER: "Provider" +STR_TASKS_DESIGN_MODE: "Design" +STR_TASKS_MINIMAL: "Minimal" +STR_TASKS_DAILY: "Daily" +STR_TASKS_DATE_FILTER: "Date filter" +STR_TASKS_OVERDUE_FILTER: "Overdue filter" +STR_TASKS_FILTER_NONE: "None" +STR_TASKS_FILTER_TODAY: "Today" +STR_TASKS_FILTER_THIS_WEEK: "This week" +STR_TASKS_FILTER_THIS_MONTH: "This month" +STR_TASKS_FILTER_LAST_7: "Last 7 days" +STR_TASKS_FILTER_ALL: "All" +STR_TASKS_DATE_FORMAT: "Date format" +STR_TASKS_ORIENTATION: "Orientation" +STR_TASKS_FORGET: "Forget" +STR_TASKS_FORGET_PROMPT: "Are you sure?" STR_POMO_FOCUS_LABEL: "Focus:" STR_POMO_SHORT_BREAK: "Short break:" diff --git a/src/CrossPetSettings.cpp b/src/CrossPetSettings.cpp index 698791da8a..aae7f82b4e 100644 --- a/src/CrossPetSettings.cpp +++ b/src/CrossPetSettings.cpp @@ -28,6 +28,7 @@ bool CrossPetSettings::saveToFile() const { doc["appFlashcard"] = appFlashcard; doc["flashcardNewPerDay"] = flashcardNewPerDay; doc["flashcardMaxReviewPerDay"] = flashcardMaxReviewPerDay; + doc["appTasks"] = appTasks; String json; serializeJson(doc, json); @@ -82,6 +83,7 @@ bool CrossPetSettings::loadFromFile() { appFlashcard = doc["appFlashcard"] | (uint8_t)1; flashcardNewPerDay = doc["flashcardNewPerDay"] | (uint8_t)10; flashcardMaxReviewPerDay = doc["flashcardMaxReviewPerDay"] | (uint8_t)250; + appTasks = doc["appTasks"] | (uint8_t)1; LOG_DBG("CPS", "CrossPet settings loaded from file"); return true; } diff --git a/src/CrossPetSettings.h b/src/CrossPetSettings.h index 1ab30d0ec5..128a9a09c3 100644 --- a/src/CrossPetSettings.h +++ b/src/CrossPetSettings.h @@ -31,6 +31,7 @@ class CrossPetSettings { uint8_t appFlashcard = 1; // Per-app visibility toggle (1=show, 0=hide) uint8_t flashcardNewPerDay = 10; // New cards per day limit uint8_t flashcardMaxReviewPerDay = 250; // Max reviews per day (capped at 255 for uint8_t) + uint8_t appTasks = 1; // To-Do List app visibility (1=show, 0=hide) // Ghost Mode — ctOS-style security toolkit home screen uint8_t ghostMode = 0; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index bb040c27de..696fc38327 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -102,6 +102,7 @@ void SettingsActivity::onEnter() { {StrId::STR_SLEEP_IMAGE_PICKER, &CrossPetSettings::appSleepImagePicker, "appSleepImagePicker"}, {StrId::STR_GAMES, &CrossPetSettings::appGames, "appGames"}, {StrId::STR_FLASHCARD, &CrossPetSettings::appFlashcard, "appFlashcard"}, + {StrId::STR_TASKS, &CrossPetSettings::appTasks, "appTasks"}, }; for (const auto& t : appToggles) { auto field = t.field; diff --git a/src/activities/settings/TasksSettingsActivity.cpp b/src/activities/settings/TasksSettingsActivity.cpp new file mode 100644 index 0000000000..31c5ed7bda --- /dev/null +++ b/src/activities/settings/TasksSettingsActivity.cpp @@ -0,0 +1,226 @@ +#include "TasksSettingsActivity.h" + +#include +#include + +#include "MappedInputManager.h" +#include "activities/util/ConfirmationActivity.h" +#include "components/UITheme.h" +#include "fontIds.h" +#include "tasks/TasksConfig.h" +#include "tasks/TaskProvider.h" + +namespace { + +constexpr int kIdxProvider = 0; +constexpr int kIdxDesignMode = 1; +constexpr int kIdxDateFilter = 2; +constexpr int kIdxOverdueFilter = 3; +constexpr int kIdxDateFormat = 4; +constexpr int kIdxOrientation = 5; +constexpr int kIdxForget = 6; + +const char* designModeLabel(tasks::DesignMode m) { + switch (m) { + case tasks::DesignMode::Minimal: return tr(STR_TASKS_MINIMAL); + case tasks::DesignMode::Daily: return tr(STR_TASKS_DAILY); + } + return tr(STR_TASKS_MINIMAL); +} + +const char* dateFilterLabel(tasks::DateFilter f) { + switch (f) { + case tasks::DateFilter::None: return tr(STR_TASKS_FILTER_NONE); + case tasks::DateFilter::Today: return tr(STR_TASKS_FILTER_TODAY); + case tasks::DateFilter::ThisWeek: return tr(STR_TASKS_FILTER_THIS_WEEK); + case tasks::DateFilter::ThisMonth: return tr(STR_TASKS_FILTER_THIS_MONTH); + } + return tr(STR_TASKS_FILTER_TODAY); +} + +const char* overdueFilterLabel(tasks::OverdueFilter f) { + switch (f) { + case tasks::OverdueFilter::None: return tr(STR_TASKS_FILTER_NONE); + case tasks::OverdueFilter::Last7Days: return tr(STR_TASKS_FILTER_LAST_7); + case tasks::OverdueFilter::All: return tr(STR_TASKS_FILTER_ALL); + } + return tr(STR_TASKS_FILTER_LAST_7); +} + +const char* orientationLabel(GfxRenderer::Orientation o) { + switch (o) { + case GfxRenderer::Orientation::Portrait: return tr(STR_PORTRAIT); + case GfxRenderer::Orientation::PortraitInverted: return tr(STR_INVERTED); + case GfxRenderer::Orientation::LandscapeClockwise: return tr(STR_LANDSCAPE_CW); + case GfxRenderer::Orientation::LandscapeCounterClockwise: return tr(STR_LANDSCAPE_CCW); + } + return tr(STR_PORTRAIT); +} + +GfxRenderer::Orientation nextOrientation(GfxRenderer::Orientation o) { + switch (o) { + case GfxRenderer::Orientation::Portrait: return GfxRenderer::Orientation::PortraitInverted; + case GfxRenderer::Orientation::PortraitInverted: return GfxRenderer::Orientation::LandscapeClockwise; + case GfxRenderer::Orientation::LandscapeClockwise: return GfxRenderer::Orientation::LandscapeCounterClockwise; + case GfxRenderer::Orientation::LandscapeCounterClockwise: return GfxRenderer::Orientation::Portrait; + } + return GfxRenderer::Orientation::Portrait; +} + +StrId menuLabelStrId(int idx) { + switch (idx) { + case kIdxProvider: return StrId::STR_TASKS_PROVIDER; + case kIdxDesignMode: return StrId::STR_TASKS_DESIGN_MODE; + case kIdxDateFilter: return StrId::STR_TASKS_DATE_FILTER; + case kIdxOverdueFilter: return StrId::STR_TASKS_OVERDUE_FILTER; + case kIdxDateFormat: return StrId::STR_TASKS_DATE_FORMAT; + case kIdxOrientation: return StrId::STR_TASKS_ORIENTATION; + case kIdxForget: return StrId::STR_TASKS_FORGET; + } + return StrId::STR_TASKS; +} + +} // namespace + +void TasksSettingsActivity::onEnter() { + Activity::onEnter(); + selectedIndex = 0; + requestUpdate(); +} + +void TasksSettingsActivity::onExit() { + Activity::onExit(); +} + +void TasksSettingsActivity::loop() { + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + finish(); + return; + } + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + handleSelection(); + return; + } + buttonNavigator.onNext([this] { + selectedIndex = (selectedIndex + 1) % kItemCount; + requestUpdate(); + }); + buttonNavigator.onPrevious([this] { + selectedIndex = (selectedIndex + kItemCount - 1) % kItemCount; + requestUpdate(); + }); +} + +void TasksSettingsActivity::handleSelection() { + switch (static_cast(selectedIndex)) { + case kIdxProvider: + // Only one provider exists today. The row stays for forward + // compatibility; cycling becomes a real action when a 2nd lands. + break; + case kIdxDesignMode: { + tasks::DesignMode next = (TASKS_CONFIG.getDesignMode() == tasks::DesignMode::Minimal) + ? tasks::DesignMode::Daily + : tasks::DesignMode::Minimal; + TASKS_CONFIG.setDesignMode(next); + break; + } + case kIdxDateFilter: { + using DF = tasks::DateFilter; + DF cur = TASKS_CONFIG.getDateFilter(); + DF next = (cur == DF::None) ? DF::Today + : (cur == DF::Today) ? DF::ThisWeek + : (cur == DF::ThisWeek) ? DF::ThisMonth + : DF::None; + TASKS_CONFIG.setDateFilter(next); + break; + } + case kIdxOverdueFilter: { + using OF = tasks::OverdueFilter; + OF cur = TASKS_CONFIG.getOverdueFilter(); + OF next = (cur == OF::None) ? OF::Last7Days + : (cur == OF::Last7Days) ? OF::All + : OF::None; + TASKS_CONFIG.setOverdueFilter(next); + break; + } + case kIdxDateFormat: { + uint8_t raw = static_cast(TASKS_CONFIG.getDateFormat()); + raw = (raw + 1) % 6; + TASKS_CONFIG.setDateFormat(static_cast(raw)); + break; + } + case kIdxOrientation: { + auto next = nextOrientation(TASKS_CONFIG.getActivityOrientation()); + TASKS_CONFIG.setActivityOrientation(next); + break; + } + case kIdxForget: { + startActivityForResult( + std::make_unique(renderer, mappedInput, + tr(STR_TASKS_FORGET), + tr(STR_TASKS_FORGET_PROMPT)), + [this](const ActivityResult& result) { + if (!result.isCancelled) { + TASKS_CONFIG.forget(); + // Close settings so the user lands directly on the setup screen. + finish(); + return; + } + requestUpdate(); + }); + return; + } + } + requestUpdate(); +} + +void TasksSettingsActivity::render(RenderLock&&) { + renderer.clearScreen(); + + const auto& metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + // In landscape, the hint strip lands on a side edge (left for CW, right for + // CCW) instead of the bottom — so reserve horizontal space rather than + // trimming vertical content. + const auto orientation = renderer.getOrientation(); + const bool isLandscape = (orientation == GfxRenderer::LandscapeClockwise || + orientation == GfxRenderer::LandscapeCounterClockwise); + const bool hintOnLeft = (orientation == GfxRenderer::LandscapeClockwise); + const int hintReserve = metrics.buttonHintsHeight + metrics.verticalSpacing * 2; + const int hintLeftReserve = (isLandscape && hintOnLeft) ? hintReserve : 0; + const int hintRightReserve = (isLandscape && !hintOnLeft) ? hintReserve : 0; + const int contentWidth = pageWidth - hintLeftReserve - hintRightReserve; + + GUI.drawHeader(renderer, Rect{hintLeftReserve, metrics.topPadding, contentWidth, metrics.headerHeight}, tr(STR_TASKS)); + + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + const int contentHeight = pageHeight - contentTop - (isLandscape ? 0 : (metrics.buttonHintsHeight + metrics.verticalSpacing * 2)); + + GUI.drawList( + renderer, Rect{hintLeftReserve, contentTop, contentWidth, contentHeight}, kItemCount, + static_cast(selectedIndex), + [](int index) { + return std::string(I18N.get(menuLabelStrId(index))); + }, + nullptr, nullptr, + [](int index) -> std::string { + switch (index) { + case kIdxProvider: return std::string(TASKS_CONFIG.getActiveProvider().displayName()); + case kIdxDesignMode: return std::string(designModeLabel(TASKS_CONFIG.getDesignMode())); + case kIdxDateFilter: return std::string(dateFilterLabel(TASKS_CONFIG.getDateFilter())); + case kIdxOverdueFilter: return std::string(overdueFilterLabel(TASKS_CONFIG.getOverdueFilter())); + case kIdxDateFormat: return std::string(tasks::dateFormatToString(TASKS_CONFIG.getDateFormat())); + case kIdxOrientation: return std::string(orientationLabel(TASKS_CONFIG.getActivityOrientation())); + case kIdxForget: return std::string(); // action — no value column + } + return std::string(); + }, + true); + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/src/activities/settings/TasksSettingsActivity.h b/src/activities/settings/TasksSettingsActivity.h new file mode 100644 index 0000000000..c3ba0f6ed3 --- /dev/null +++ b/src/activities/settings/TasksSettingsActivity.h @@ -0,0 +1,28 @@ +#pragma once + +#include "activities/Activity.h" +#include "util/ButtonNavigator.h" + +#include + +// Per-app settings for the Tasks (To-Do List) activity. Launched from +// within TasksActivity via the Left or Right front button — no entry in +// the main SettingsActivity, mirroring crosspet's WeatherActivity +// pattern of keeping app preferences inside the owning activity. +class TasksSettingsActivity final : public Activity { + public: + TasksSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) + : Activity("TasksSettings", renderer, mappedInput) {} + + void onEnter() override; + void onExit() override; + void loop() override; + void render(RenderLock&&) override; + + private: + void handleSelection(); + + ButtonNavigator buttonNavigator; + size_t selectedIndex = 0; + static constexpr int kItemCount = 7; +}; diff --git a/src/activities/tasks/TasksActivity.cpp b/src/activities/tasks/TasksActivity.cpp new file mode 100644 index 0000000000..7728dc8b26 --- /dev/null +++ b/src/activities/tasks/TasksActivity.cpp @@ -0,0 +1,523 @@ +#include "TasksActivity.h" + +#include "Logging.h" +#include "activities/network/WifiSelectionActivity.h" +#include "activities/settings/TasksSettingsActivity.h" +#include "components/UITheme.h" +#include "fontIds.h" +#include "tasks/TasksConfig.h" +#include "tasks/TaskProvider.h" + +#include + +#include +#include +#include +#include + +namespace { + +// English month and day names. Kept local so adding the Tasks app doesn't +// force 19 new i18n keys for date rendering. If a localized variant is +// needed later, lift into I18n. +constexpr const char* kMonthNames[12] = { + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December"}; +constexpr const char* kDayNames[7] = { + "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}; + +// Unix epoch sentinel: anything above this is "clock has been set by NTP +// or similar." Matches the threshold WeatherActivity uses for its own +// time-validity gate. +constexpr time_t kClockSetThreshold = 1700000000; // ~Nov 2023 + +// Natural compare: digit runs are compared as integers so "task 2" < "task 10" +// instead of plain ASCII order which puts "10" before "2". +int naturalCmp(const char* a, const char* b) { + while (*a && *b) { + unsigned char ca = static_cast(*a); + unsigned char cb = static_cast(*b); + if (ca >= '0' && ca <= '9' && cb >= '0' && cb <= '9') { + unsigned long va = 0, vb = 0; + while (*a >= '0' && *a <= '9') { va = va * 10 + static_cast(*a - '0'); ++a; } + while (*b >= '0' && *b <= '9') { vb = vb * 10 + static_cast(*b - '0'); ++b; } + if (va != vb) return va < vb ? -1 : 1; + } else { + if (ca != cb) return ca < cb ? -1 : 1; + ++a; ++b; + } + } + if (*a == *b) return 0; + return *a ? 1 : -1; +} + +} // namespace + +void TasksActivity::onEnter() { + Activity::onEnter(); + _entryOrientation = renderer.getOrientation(); + renderer.setOrientation(TASKS_CONFIG.getActivityOrientation()); + + _scrollOffset = 0; + _selectedIndex = 0; + _lastVisibleIndex = -1; + _tasks.clear(); + _today[0] = '\0'; + + if (!TASKS_CONFIG.hasToken()) { + _state = State::Setup; + requestUpdate(true); + return; + } + + _state = State::Loading; + requestUpdate(true); + startFetch(); +} + +void TasksActivity::onExit() { + WiFi.disconnect(false); + delay(100); + WiFi.mode(WIFI_OFF); + delay(100); + renderer.setOrientation(_entryOrientation); + Activity::onExit(); +} + +void TasksActivity::loop() { + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + finish(); + return; + } + + // Confirm = Refresh (ShowingTasks/ShowingError) OR Retry from Setup (re-load + // config in case the user populated the token on the SD card). + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (_state == State::Setup) { + TASKS_CONFIG.load(); + if (TASKS_CONFIG.hasToken()) { + _state = State::Loading; + requestUpdate(true); + startFetch(); + } + return; + } + if (_state == State::ShowingTasks || _state == State::ShowingError) { + TASKS_CONFIG.load(); // re-check token + if (!TASKS_CONFIG.hasToken()) { + _state = State::Setup; + requestUpdate(true); + return; + } + _state = State::Loading; + _scrollOffset = 0; + _tasks.clear(); + requestUpdate(true); + startFetch(); + return; + } + } + + // Left or Right (front buttons) open Tasks Settings. Mirrors WeatherActivity's + // pattern of using front buttons to enter a submenu while side buttons drive + // scroll. Available in every state — including Setup, so users without a + // token can still adjust display preferences. + if (mappedInput.wasReleased(MappedInputManager::Button::Left) || + mappedInput.wasReleased(MappedInputManager::Button::Right)) { + startActivityForResult( + std::make_unique(renderer, mappedInput), + [this](const ActivityResult&) { + // Restore orientation (settings may have changed it) and re-render. + renderer.setOrientation(TASKS_CONFIG.getActivityOrientation()); + // Forget may have wiped the token while we were in settings — + // drop back to the setup screen instead of stale-rendering tasks. + if (!TASKS_CONFIG.hasToken()) { + _state = State::Setup; + _tasks.clear(); + _scrollOffset = 0; + _selectedIndex = 0; + } + requestUpdate(true); + }); + return; + } + + // Up/Down (side buttons) scroll the task list. ShowingTasks only. + if (_state != State::ShowingTasks) return; + if (mappedInput.wasReleased(MappedInputManager::Button::Down)) { + if (_selectedIndex + 1 >= static_cast(_tasks.size())) return; + ++_selectedIndex; + if (_lastVisibleIndex >= 0 && _selectedIndex > _lastVisibleIndex) { + ++_scrollOffset; + } + requestUpdate(); + } + if (mappedInput.wasReleased(MappedInputManager::Button::Up)) { + if (_selectedIndex == 0) return; + --_selectedIndex; + if (_selectedIndex < _scrollOffset) _scrollOffset = _selectedIndex; + requestUpdate(); + } +} + +void TasksActivity::startFetch() { + if (WiFi.status() == WL_CONNECTED) { + proceedWithFetch(); + return; + } + startActivityForResult( + std::make_unique(renderer, mappedInput), + [this](const ActivityResult& result) { + if (result.isCancelled || WiFi.status() != WL_CONNECTED) { + _state = State::ShowingError; + _errorStrId = StrId::STR_TASKS_OFFLINE; + requestUpdate(); + return; + } + renderer.setOrientation(TASKS_CONFIG.getActivityOrientation()); + // Allow lwIP's DNS resolver to initialize after WiFi associates — + // without this getaddrinfo() returns EAI_FAIL (202) for the first + // request on a fresh connection. Mirrors WeatherActivity::silentRefresh. + delay(500); + proceedWithFetch(); + }); +} + +void TasksActivity::proceedWithFetch() { + using tasks::FetchResult; + FetchResult r = TASKS_CONFIG.getActiveProvider().fetch( + TASKS_CONFIG.getFilter(), _tasks); + + if (r != FetchResult::Ok) { + _state = State::ShowingError; + _errorStrId = fetchResultToStrId(); + requestUpdate(true); + return; + } + + // Capture local time AT fetch. If clock isn't synced (time_t < threshold), + // leave _today empty so renderDaily knows to suppress the date header. + time_t now = time(nullptr); + if (now >= kClockSetThreshold) { + struct tm tm_now; + localtime_r(&now, &tm_now); + _capturedHour = static_cast(tm_now.tm_hour); + _capturedMin = static_cast(tm_now.tm_min); + _capturedDay = static_cast(tm_now.tm_mday); + _capturedMonth = static_cast(tm_now.tm_mon + 1); + _capturedYear = static_cast(tm_now.tm_year + 1900); + _capturedDow = static_cast(tm_now.tm_wday); + strftime(_today, sizeof(_today), "%Y-%m-%d", &tm_now); + } + + // Sort by dueDate first (past → today → future, lexicographic on YYYY-MM-DD + // == chronological), then by title within the same date group. + std::sort(_tasks.begin(), _tasks.end(), + [](const tasks::Task& a, const tasks::Task& b) { + int dateCmp = strcmp(a.dueDate, b.dueDate); + if (dateCmp != 0) return dateCmp < 0; + return naturalCmp(a.title, b.title) < 0; + }); + + _state = State::ShowingTasks; + _scrollOffset = 0; + _selectedIndex = 0; + _lastVisibleIndex = -1; + requestUpdate(true); +} + +StrId TasksActivity::fetchResultToStrId() const { + // Generic error UI: distinguish auth from "everything else" — the user + // action is different (fix token vs. retry). + // Kept minimal because we don't want a separate i18n string per error + // bucket until we know which ones users actually hit. + return StrId::STR_TASKS_FETCH_FAILED; +} + +void TasksActivity::render(RenderLock&&) { + switch (_state) { + case State::Setup: renderSetup(); break; + case State::Loading: renderLoading(); break; + case State::ShowingError: renderError(); break; + case State::ShowingTasks: renderTaskList(); break; + } + renderer.displayBuffer(); +} + +void TasksActivity::renderSetup() { + renderer.clearScreen(); + + const int pageWidth = renderer.getScreenWidth(); + const auto& metrics = UITheme::getInstance().getMetrics(); + + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_TASKS)); + + // Multi-line setup instruction. drawPopup is single-line and the JSON path + // doesn't fit, so render manually with wrap. + constexpr int kSidePadding = 30; + const int maxTextWidth = pageWidth - 2 * kSidePadding; + const int lineHeight = renderer.getLineHeight(UI_12_FONT_ID); + auto lines = renderer.wrappedText(UI_12_FONT_ID, tr(STR_TASKS_TOKEN_SETUP_HINT), maxTextWidth, 8); + + int y = metrics.topPadding + metrics.headerHeight + 40; + for (const auto& line : lines) { + const int w = renderer.getTextWidth(UI_12_FONT_ID, line.c_str()); + renderer.drawText(UI_12_FONT_ID, (pageWidth - w) / 2, y, line.c_str(), true); + y += lineHeight + 4; + } + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_RETRY), + tr(STR_SETTINGS_TITLE), tr(STR_SETTINGS_TITLE)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); +} + +void TasksActivity::renderLoading() { + renderer.clearScreen(); + GUI.drawPopup(renderer, tr(STR_TASKS_FETCHING)); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); +} + +void TasksActivity::renderError() { + renderer.clearScreen(); + GUI.drawPopup(renderer, I18N.get(_errorStrId)); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_RETRY), + tr(STR_SETTINGS_TITLE), tr(STR_SETTINGS_TITLE)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); +} + +void TasksActivity::renderTaskList() { + if (TASKS_CONFIG.getDesignMode() == tasks::DesignMode::Daily) { + renderDaily(); + } else { + renderMinimal(); + } +} + +void TasksActivity::renderMinimal() { + renderer.clearScreen(); + + const int pageWidth = renderer.getScreenWidth(); + const int pageHeight = renderer.getScreenHeight(); + const auto& metrics = UITheme::getInstance().getMetrics(); + + const auto orientation = renderer.getOrientation(); + const bool isLandscape = (orientation == GfxRenderer::LandscapeClockwise || + orientation == GfxRenderer::LandscapeCounterClockwise); + const bool hintOnLeft = (orientation == GfxRenderer::LandscapeClockwise); + const int hintReserve = metrics.buttonHintsHeight + metrics.verticalSpacing * 2; + const int hintLeftReserve = (isLandscape && hintOnLeft) ? hintReserve : 0; + const int hintRightReserve = (isLandscape && !hintOnLeft) ? hintReserve : 0; + + // Timestamp header. If the clock isn't set, just show the title — no + // garbage "00:00" date string. + char timestamp[32] = ""; + if (_today[0] != '\0') { + char dateStr[8]; + tasks::formatDate(_capturedDay, _capturedMonth, TASKS_CONFIG.getDateFormat(), + dateStr, sizeof(dateStr)); + snprintf(timestamp, sizeof(timestamp), "%s %02u:%02u", + dateStr, + static_cast(_capturedHour), + static_cast(_capturedMin)); + } + GUI.drawHeader( + renderer, + Rect{hintLeftReserve, metrics.topPadding, + pageWidth - hintLeftReserve - hintRightReserve, metrics.headerHeight}, + tr(STR_TASKS), timestamp); + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_RETRY), + tr(STR_SETTINGS_TITLE), tr(STR_SETTINGS_TITLE)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + if (_tasks.empty()) { + GUI.drawPopup(renderer, tr(STR_TASKS_NO_TASKS)); + return; + } + + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + const int contentHeight = pageHeight - contentTop - (isLandscape ? 0 : hintReserve); + const int sidePadding = metrics.contentSidePadding; + const int tileX = sidePadding + hintLeftReserve; + const int tileWidth = pageWidth - sidePadding * 2 - hintLeftReserve - hintRightReserve; + drawTaskRows(contentTop, contentHeight, tileX, tileWidth); +} + +void TasksActivity::renderDaily() { + renderer.clearScreen(); + + const int pageWidth = renderer.getScreenWidth(); + const int pageHeight = renderer.getScreenHeight(); + const auto& metrics = UITheme::getInstance().getMetrics(); + + const auto orientation = renderer.getOrientation(); + const bool isLandscape = (orientation == GfxRenderer::LandscapeClockwise || + orientation == GfxRenderer::LandscapeCounterClockwise); + const bool hintOnLeft = (orientation == GfxRenderer::LandscapeClockwise); + const int hintReserve = metrics.buttonHintsHeight + metrics.verticalSpacing * 2; + const int hintLeftReserve = (isLandscape && hintOnLeft) ? hintReserve : 0; + const int hintRightReserve = (isLandscape && !hintOnLeft) ? hintReserve : 0; + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_RETRY), + tr(STR_SETTINGS_TITLE), tr(STR_SETTINGS_TITLE)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + const int leftEdge = hintLeftReserve; + const int rightEdge = pageWidth - hintRightReserve; + const int columnWidth = rightEdge - leftEdge; + int y = metrics.topPadding + metrics.verticalSpacing * 2; + + // Date + day-of-week header, only when the clock is set. Without it, + // we'd render bogus "Thursday, January 1, 1970" — better to suppress + // and let the tasks fill the screen. + if (_today[0] != '\0') { + const int monthIdx = (_capturedMonth >= 1 && _capturedMonth <= 12) ? _capturedMonth - 1 : 0; + char dateLine[32]; + snprintf(dateLine, sizeof(dateLine), "%s %u, %u", + kMonthNames[monthIdx], static_cast(_capturedDay), + static_cast(_capturedYear)); + const int dw = renderer.getTextWidth(UI_12_FONT_ID, dateLine); + renderer.drawText(UI_12_FONT_ID, leftEdge + (columnWidth - dw) / 2, y, dateLine, true); + y += renderer.getLineHeight(UI_12_FONT_ID) + 4; + + const int dowIdx = (_capturedDow <= 6) ? _capturedDow : 0; + const char* dow = kDayNames[dowIdx]; + const int dowW = renderer.getTextWidth(LEXEND_18_FONT_ID, dow, EpdFontFamily::BOLD); + renderer.drawText(LEXEND_18_FONT_ID, + leftEdge + (columnWidth - dowW) / 2, y, dow, true, EpdFontFamily::BOLD); + y += renderer.getLineHeight(LEXEND_18_FONT_ID) + 8; + + // Divider between header and task list. + renderer.drawLine(leftEdge + 12, y, rightEdge - 12, y, true); + y += 6; + } + + // Bottom "Updated dd/mm HH:MM" line + divider above it. Mirrors the + // top header sandwich. Suppressed when no clock. + int contentBottom = pageHeight - (isLandscape ? 0 : hintReserve); + if (_today[0] != '\0') { + char dateStr[8]; + tasks::formatDate(_capturedDay, _capturedMonth, TASKS_CONFIG.getDateFormat(), + dateStr, sizeof(dateStr)); + char updatedLine[32]; + snprintf(updatedLine, sizeof(updatedLine), "%s %s %02u:%02u", + tr(STR_TASKS_UPDATED), dateStr, + static_cast(_capturedHour), + static_cast(_capturedMin)); + const int updatedH = renderer.getLineHeight(SMALL_FONT_ID); + constexpr int kBottomPadding = 4; + constexpr int kBottomDividerGap = 6; + const int updatedY = pageHeight - (isLandscape ? 0 : hintReserve) - updatedH - kBottomPadding; + const int bottomDividerY = updatedY - kBottomDividerGap; + renderer.drawLine(leftEdge + 12, bottomDividerY, rightEdge - 12, bottomDividerY, true); + const int uw = renderer.getTextWidth(SMALL_FONT_ID, updatedLine); + renderer.drawText(SMALL_FONT_ID, leftEdge + (columnWidth - uw) / 2, updatedY, updatedLine, true); + contentBottom = bottomDividerY - 4; + } + + if (_tasks.empty()) { + GUI.drawPopup(renderer, tr(STR_TASKS_NO_TASKS)); + return; + } + + const int contentTop = y; + const int contentHeight = std::max(0, contentBottom - contentTop); + const int sidePadding = metrics.contentSidePadding; + const int tileX = sidePadding + hintLeftReserve; + const int tileWidth = pageWidth - sidePadding * 2 - hintLeftReserve - hintRightReserve; + drawTaskRows(contentTop, contentHeight, tileX, tileWidth); +} + +void TasksActivity::drawTaskRows(int contentTop, int contentHeight, int tileX, int tileWidth) { + constexpr const char* kBulletNormal = "\xE2\x80\xA2"; // U+2022 BULLET + constexpr const char* kBulletOverdue = "!"; + constexpr const char* kBulletFuture = "\xE2\x80\xBA"; // U+203A + constexpr int kTilePaddingX = 10; + constexpr int kTilePaddingY = 6; + constexpr int kBulletGap = 8; + constexpr int kRowGap = 2; + constexpr int kCursorRadius = 6; + constexpr int kDateGap = 8; + constexpr int kMaxLines = 2; + + const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID); + const int bulletColWidth = std::max({ + renderer.getTextWidth(UI_10_FONT_ID, kBulletNormal), + renderer.getTextWidth(UI_10_FONT_ID, kBulletOverdue), + renderer.getTextWidth(UI_10_FONT_ID, kBulletFuture)}); + const int dateColWidth = renderer.getTextWidth(UI_10_FONT_ID, "00/00"); + + const int textX = tileX + kTilePaddingX + bulletColWidth + kBulletGap; + const int textWidth = tileX + tileWidth - kTilePaddingX - textX; + + const int totalTasks = static_cast(_tasks.size()); + if (_scrollOffset > totalTasks - 1) _scrollOffset = std::max(0, totalTasks - 1); + if (_selectedIndex > totalTasks - 1) _selectedIndex = std::max(0, totalTasks - 1); + + int y = contentTop; + int rendered = 0; + int lastFullyVisible = -1; + for (int taskIdx = _scrollOffset; taskIdx < totalTasks; ++taskIdx) { + const auto& t = _tasks[taskIdx]; + + const bool isFuture = _today[0] != '\0' && t.dueDate[0] != '\0' && + strcmp(t.dueDate, _today) > 0; + const bool showDate = t.dueDate[0] != '\0'; + + char fullTitle[128]; + snprintf(fullTitle, sizeof(fullTitle), "%s%s%s", + t.dueTime[0] ? t.dueTime : "", + t.dueTime[0] ? " " : "", + t.title); + + const int rowTextWidth = showDate ? textWidth - dateColWidth - kDateGap : textWidth; + auto lines = renderer.wrappedText(UI_10_FONT_ID, fullTitle, rowTextWidth, kMaxLines); + const int textBlockHeight = static_cast(lines.size()) * lineHeight; + const int tileHeight = textBlockHeight + kTilePaddingY * 2; + if (y + tileHeight > contentTop + contentHeight) break; + + const bool selected = (taskIdx == _selectedIndex); + if (selected) { + renderer.fillRoundedRect(tileX, y, tileWidth, tileHeight, kCursorRadius, Color::LightGray); + } + + const char* marker = t.overdue ? kBulletOverdue + : isFuture ? kBulletFuture + : kBulletNormal; + const int markerX = tileX + kTilePaddingX; + const int firstLineY = y + kTilePaddingY; + renderer.drawText(UI_10_FONT_ID, markerX, firstLineY, marker, true); + + for (size_t li = 0; li < lines.size(); ++li) { + renderer.drawText(UI_10_FONT_ID, textX, + firstLineY + static_cast(li) * lineHeight, + lines[li].c_str(), true); + } + + if (showDate) { + const int day = (t.dueDate[8] - '0') * 10 + (t.dueDate[9] - '0'); + const int mon = (t.dueDate[5] - '0') * 10 + (t.dueDate[6] - '0'); + char dateBuf[8]; + tasks::formatDate(day, mon, TASKS_CONFIG.getDateFormat(), dateBuf, sizeof(dateBuf)); + const int dateWidth = renderer.getTextWidth(UI_10_FONT_ID, dateBuf); + const int dateX = tileX + tileWidth - kTilePaddingX - dateWidth; + renderer.drawText(UI_10_FONT_ID, dateX, firstLineY, dateBuf, true); + } + + y += tileHeight + kRowGap; + rendered++; + lastFullyVisible = taskIdx; + } + _lastVisibleIndex = lastFullyVisible; + + // Scroll bar + if (rendered < totalTasks - _scrollOffset || _scrollOffset > 0) { + const int barX = tileX + tileWidth + 1; + const int barTrackHeight = contentHeight; + const int barHeight = std::max(8, (barTrackHeight * rendered) / totalTasks); + const int maxOffset = std::max(1, totalTasks - rendered); + const int barY = contentTop + ((barTrackHeight - barHeight) * _scrollOffset) / maxOffset; + renderer.fillRect(barX, barY, 2, barHeight, true); + } +} diff --git a/src/activities/tasks/TasksActivity.h b/src/activities/tasks/TasksActivity.h new file mode 100644 index 0000000000..4b9a5006fc --- /dev/null +++ b/src/activities/tasks/TasksActivity.h @@ -0,0 +1,66 @@ +#pragma once + +#include "activities/Activity.h" +#include "MappedInputManager.h" +#include "tasks/Task.h" +#include "util/ButtonNavigator.h" + +#include +#include + +#include + +class TasksActivity : public Activity { + public: + TasksActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) + : Activity("Tasks", renderer, mappedInput) {} + + void onEnter() override; + void onExit() override; + void loop() override; + void render(RenderLock&& lock) override; + bool preventAutoSleep() override { return true; } + + private: + enum class State { + Setup, // tasks.json missing or todoistApiToken empty + Loading, // fetch in progress / awaiting WiFi + ShowingTasks, + ShowingError, + }; + + void startFetch(); + void proceedWithFetch(); // after WiFi is up + + StrId fetchResultToStrId() const; + + void renderSetup(); + void renderLoading(); + void renderError(); + void renderTaskList(); + void renderMinimal(); + void renderDaily(); + void drawTaskRows(int contentTop, int contentHeight, int tileX, int tileWidth); + + std::vector _tasks; + State _state = State::Setup; + int _scrollOffset = 0; + int _selectedIndex = 0; + int _lastVisibleIndex = -1; + StrId _errorStrId = StrId::STR_TASKS_FETCH_FAILED; + + // Capture time at fetch for the header timestamp + per-row "today" comparison. + // _today is empty if the clock hadn't synced — the Daily renderer falls back + // to suppressing the date header in that case. + uint8_t _capturedHour = 0; + uint8_t _capturedMin = 0; + uint8_t _capturedDay = 0; + uint8_t _capturedMonth = 0; + uint16_t _capturedYear = 0; + uint8_t _capturedDow = 0; + char _today[11] = ""; + + // Snapshot of renderer orientation at onEnter() so we can restore it on exit. + GfxRenderer::Orientation _entryOrientation = GfxRenderer::Orientation::Portrait; + ButtonNavigator _navigator; +}; diff --git a/src/activities/tools/ToolsActivity.cpp b/src/activities/tools/ToolsActivity.cpp index 2346978057..9469431f9a 100644 --- a/src/activities/tools/ToolsActivity.cpp +++ b/src/activities/tools/ToolsActivity.cpp @@ -16,6 +16,7 @@ #include "SleepImagePickerActivity.h" #include "activities/browser/OpdsBookBrowserActivity.h" #include "../flashcard/FlashcardDeckListActivity.h" +#include "../tasks/TasksActivity.h" #include "components/UITheme.h" #include "CrossPetSettings.h" #include "CrossPointSettings.h" @@ -58,6 +59,11 @@ void ToolsActivity::buildMenu() { activityManager.pushActivity(std::make_unique(renderer, mappedInput)); }}); + if (PET_SETTINGS.appTasks) + menuEntries.push_back({StrId::STR_TASKS, [this] { + activityManager.pushActivity(std::make_unique(renderer, mappedInput)); + }}); + // OPDS browser (if configured) if (SETTINGS.opdsServerUrl[0]) menuEntries.push_back({StrId::STR_OPDS_BROWSER, [this] { diff --git a/src/main.cpp b/src/main.cpp index b0112147c7..18fefbce61 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -29,6 +29,7 @@ #include "CrossPetSettings.h" #include "MappedInputManager.h" #include "RecentBooksStore.h" +#include "tasks/TasksConfig.h" #include "activities/Activity.h" #include "activities/ActivityManager.h" #include "components/UITheme.h" @@ -339,6 +340,7 @@ void setup() { SETTINGS.loadFromFile(); PET_SETTINGS.loadFromFile(); + TASKS_CONFIG.load(); I18N.loadSettings(); FontMgr.scanFonts(); FontMgr.loadSettings(); diff --git a/src/tasks/Task.h b/src/tasks/Task.h new file mode 100644 index 0000000000..63c782569c --- /dev/null +++ b/src/tasks/Task.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include + +namespace tasks { + +// Fixed-size POD so a std::vector reserves a contiguous block without +// any per-task heap allocation. Title is hard-truncated; the active view +// ellipsises further when rendering. +struct Task { + static constexpr size_t kTitleCapacity = 96; + static constexpr size_t kDueDateCapacity = 11; // "YYYY-MM-DD\0" + static constexpr size_t kDueTimeCapacity = 6; // "HH:MM\0" + + char title[kTitleCapacity]; + char dueDate[kDueDateCapacity]; // empty string if no due date + char dueTime[kDueTimeCapacity]; // empty string if no time + uint8_t priority; // 1 (lowest) to 4 (highest), 0 = unknown + bool overdue; +}; + +} // namespace tasks diff --git a/src/tasks/TaskProvider.h b/src/tasks/TaskProvider.h new file mode 100644 index 0000000000..22cfde9ca7 --- /dev/null +++ b/src/tasks/TaskProvider.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include + +#include "Task.h" +#include "TasksFilter.h" + +namespace tasks { + +enum class FetchResult : uint8_t { + Ok = 0, + NoAuth, // empty / invalid token (provider-decided) + NetworkError, // WiFi / DNS / TCP / TLS + AuthError, // 401 / 403 + RateLimited, // 429 + ParseError, // malformed response body + Empty, // call succeeded but no usable data +}; + +// Token-based task provider interface. Implementations are global static +// singletons (zero heap for the provider itself). Caller owns `out` and +// MUST call out.reserve(N) before invoking fetch — the ESP32-C3 RAM +// rules in .skills/SKILL.md make this mandatory. +// +// Honest limitation: this interface assumes synchronous token-based auth. +// OAuth providers (Google Tasks, Microsoft To Do) will need a +// TokenProvider subinterface or interface evolution when they land. The +// shape was chosen against Todoist as the v1 concrete; this is documented +// so the next person isn't surprised by the assumption. +class TaskProvider { + public: + virtual ~TaskProvider() = default; + + // Human-readable provider name for UI (e.g. "Todoist"). Not i18n'd — + // backend names are de-facto proper nouns. + virtual const char* displayName() const = 0; + + // Fetch tasks matching `filter`. WiFi must be up and the system clock + // must be set (NTP) for calendar-bounded date filters. Synchronous. + virtual FetchResult fetch(const TasksFilter& filter, + std::vector& out) = 0; +}; + +} // namespace tasks diff --git a/src/tasks/TasksConfig.cpp b/src/tasks/TasksConfig.cpp new file mode 100644 index 0000000000..6e1d23648a --- /dev/null +++ b/src/tasks/TasksConfig.cpp @@ -0,0 +1,301 @@ +#include "TasksConfig.h" + +#include +#include +#include + +#include +#include + +// Note: TaskProvider include and getActiveProvider() definition deferred +// to Phase 2 (when TodoistProvider lands). TasksConfig.h declares the +// method; the definition lives alongside TodoistProvider so this file +// doesn't depend on Phase 2 code. + +namespace tasks { + +namespace { + +constexpr const char* kConfigPath = "/.crosspoint/tasks.json"; + +const char* providerToString(Provider p) { + switch (p) { + case Provider::Todoist: return "todoist"; + } + return "todoist"; +} + +Provider providerFromString(const char* s, Provider fallback) { + if (!s) return fallback; + if (strcmp(s, "todoist") == 0) return Provider::Todoist; + return fallback; +} + +const char* orientationToString(GfxRenderer::Orientation o) { + switch (o) { + case GfxRenderer::Orientation::Portrait: return "portrait"; + case GfxRenderer::Orientation::PortraitInverted: return "portrait_inverted"; + case GfxRenderer::Orientation::LandscapeClockwise: return "landscape_cw"; + case GfxRenderer::Orientation::LandscapeCounterClockwise: return "landscape_ccw"; + } + return "portrait"; +} + +GfxRenderer::Orientation orientationFromString(const char* s, GfxRenderer::Orientation fallback) { + if (!s) return fallback; + if (strcmp(s, "portrait") == 0) return GfxRenderer::Orientation::Portrait; + if (strcmp(s, "portrait_inverted") == 0) return GfxRenderer::Orientation::PortraitInverted; + if (strcmp(s, "landscape_cw") == 0) return GfxRenderer::Orientation::LandscapeClockwise; + if (strcmp(s, "landscape_ccw") == 0) return GfxRenderer::Orientation::LandscapeCounterClockwise; + return fallback; +} + +const char* dateFilterToString(DateFilter f) { + switch (f) { + case DateFilter::None: return "none"; + case DateFilter::Today: return "today"; + case DateFilter::ThisWeek: return "this_week"; + case DateFilter::ThisMonth: return "this_month"; + } + return "today"; +} + +DateFilter dateFilterFromString(const char* s, DateFilter fallback) { + if (!s) return fallback; + if (strcmp(s, "none") == 0) return DateFilter::None; + if (strcmp(s, "today") == 0) return DateFilter::Today; + if (strcmp(s, "this_week") == 0) return DateFilter::ThisWeek; + if (strcmp(s, "this_month") == 0) return DateFilter::ThisMonth; + return fallback; +} + +const char* overdueFilterToString(OverdueFilter f) { + switch (f) { + case OverdueFilter::None: return "none"; + case OverdueFilter::Last7Days: return "last_7_days"; + case OverdueFilter::All: return "all"; + } + return "last_7_days"; +} + +OverdueFilter overdueFilterFromString(const char* s, OverdueFilter fallback) { + if (!s) return fallback; + if (strcmp(s, "none") == 0) return OverdueFilter::None; + if (strcmp(s, "last_7_days") == 0) return OverdueFilter::Last7Days; + if (strcmp(s, "all") == 0) return OverdueFilter::All; + return fallback; +} + +DateFormat dateFormatFromString(const char* s, DateFormat fallback) { + if (!s) return fallback; + if (strcmp(s, "dd/mm") == 0) return DateFormat::DayMonthSlash; + if (strcmp(s, "mm/dd") == 0) return DateFormat::MonthDaySlash; + if (strcmp(s, "dd-mm") == 0) return DateFormat::DayMonthDash; + if (strcmp(s, "mm-dd") == 0) return DateFormat::MonthDayDash; + if (strcmp(s, "dd.mm") == 0) return DateFormat::DayMonthDot; + if (strcmp(s, "mm.dd") == 0) return DateFormat::MonthDayDot; + return fallback; +} + +DesignMode designModeFromString(const char* s, DesignMode fallback) { + if (!s) return fallback; + if (strcmp(s, "minimal") == 0) return DesignMode::Minimal; + if (strcmp(s, "daily") == 0) return DesignMode::Daily; + return fallback; +} + +} // namespace + +const char* dateFormatToString(DateFormat f) { + switch (f) { + case DateFormat::DayMonthSlash: return "dd/mm"; + case DateFormat::MonthDaySlash: return "mm/dd"; + case DateFormat::DayMonthDash: return "dd-mm"; + case DateFormat::MonthDayDash: return "mm-dd"; + case DateFormat::DayMonthDot: return "dd.mm"; + case DateFormat::MonthDayDot: return "mm.dd"; + } + return "dd/mm"; +} + +const char* designModeToString(DesignMode d) { + switch (d) { + case DesignMode::Minimal: return "minimal"; + case DesignMode::Daily: return "daily"; + } + return "minimal"; +} + +size_t formatDate(int day, int month, DateFormat fmt, char* out, size_t outSize) { + if (!out || outSize < 6) return 0; + const char sep = (fmt == DateFormat::DayMonthSlash || fmt == DateFormat::MonthDaySlash) ? '/' + : (fmt == DateFormat::DayMonthDash || fmt == DateFormat::MonthDayDash) ? '-' + : '.'; + const bool dayFirst = (fmt == DateFormat::DayMonthSlash || + fmt == DateFormat::DayMonthDash || + fmt == DateFormat::DayMonthDot); + const int a = dayFirst ? day : month; + const int b = dayFirst ? month : day; + int n = snprintf(out, outSize, "%02d%c%02d", a, sep, b); + return (n > 0 && static_cast(n) < outSize) ? static_cast(n) : 0; +} + +TasksConfig& TasksConfig::getInstance() { + static TasksConfig instance; + return instance; +} + +bool TasksConfig::load() { + // Reset to defaults before reading. + provider = Provider::Todoist; + todoistApiToken.clear(); + designMode = DesignMode::Minimal; + dateFilter = DateFilter::Today; + overdueFilter = OverdueFilter::Last7Days; + dateFormat = DateFormat::DayMonthSlash; + activityOrientation = GfxRenderer::Orientation::Portrait; + snapshotOrientation = GfxRenderer::Orientation::Portrait; + loaded = false; + + // First-launch: file doesn't exist. Write a stub so the user has + // something to edit, then load defaults. + if (!Storage.exists(kConfigPath)) { + LOG_DBG("TASKS", "No config at %s — writing stub", kConfigPath); + writeStubIfMissing(); + loaded = true; + return true; + } + + String json = Storage.readFile(kConfigPath); + if (json.isEmpty()) { + LOG_ERR("TASKS", "Config file empty/unreadable at %s", kConfigPath); + return false; + } + + JsonDocument doc; + auto err = deserializeJson(doc, json); + if (err) { + LOG_ERR("TASKS", "JSON parse error: %s", err.c_str()); + return false; + } + + provider = providerFromString( + doc["provider"] | static_cast(nullptr), Provider::Todoist); + todoistApiToken = doc["todoistApiToken"] | std::string(""); + + designMode = designModeFromString( + doc["design_mode"] | static_cast(nullptr), DesignMode::Minimal); + dateFilter = dateFilterFromString( + doc["date_filter"] | static_cast(nullptr), DateFilter::Today); + overdueFilter = overdueFilterFromString( + doc["overdue_filter"] | static_cast(nullptr), OverdueFilter::Last7Days); + dateFormat = dateFormatFromString( + doc["date_format"] | static_cast(nullptr), DateFormat::DayMonthSlash); + activityOrientation = orientationFromString( + doc["activity_orientation"] | static_cast(nullptr), + GfxRenderer::Orientation::Portrait); + snapshotOrientation = orientationFromString( + doc["snapshot_orientation"] | static_cast(nullptr), + GfxRenderer::Orientation::Portrait); + + loaded = true; + LOG_DBG("TASKS", "Config loaded (token=%s)", todoistApiToken.empty() ? "no" : "yes"); + return true; +} + +bool TasksConfig::writeStubIfMissing() { + if (Storage.exists(kConfigPath)) return true; + Storage.mkdir("/.crosspoint"); + + JsonDocument doc; + doc["provider"] = providerToString(provider); + doc["todoistApiToken"] = ""; + doc["design_mode"] = designModeToString(designMode); + doc["date_filter"] = dateFilterToString(dateFilter); + doc["overdue_filter"] = overdueFilterToString(overdueFilter); + doc["date_format"] = dateFormatToString(dateFormat); + doc["activity_orientation"] = orientationToString(activityOrientation); + doc["snapshot_orientation"] = orientationToString(snapshotOrientation); + + String out; + serializeJsonPretty(doc, out); + bool ok = Storage.writeFile(kConfigPath, out); + if (!ok) LOG_ERR("TASKS", "Failed to write stub config"); + return ok; +} + +bool TasksConfig::persist() { + Storage.mkdir("/.crosspoint"); + + JsonDocument doc; + doc["provider"] = providerToString(provider); + doc["todoistApiToken"] = todoistApiToken; + doc["design_mode"] = designModeToString(designMode); + doc["date_filter"] = dateFilterToString(dateFilter); + doc["overdue_filter"] = overdueFilterToString(overdueFilter); + doc["date_format"] = dateFormatToString(dateFormat); + doc["activity_orientation"] = orientationToString(activityOrientation); + doc["snapshot_orientation"] = orientationToString(snapshotOrientation); + + String out; + serializeJsonPretty(doc, out); + bool ok = Storage.writeFile(kConfigPath, out); + if (ok) { + LOG_DBG("TASKS", "Config persisted"); + } else { + LOG_ERR("TASKS", "Failed to persist config"); + } + return ok; +} + +// getActiveProvider() is defined in Phase 2 (providers/todoist/TodoistProvider.cpp). + +bool TasksConfig::setProvider(Provider p) { + if (p == provider) return true; + provider = p; + return persist(); +} + +bool TasksConfig::setDesignMode(DesignMode d) { + if (d == designMode) return true; + designMode = d; + return persist(); +} + +bool TasksConfig::setDateFilter(DateFilter f) { + if (f == dateFilter) return true; + dateFilter = f; + return persist(); +} + +bool TasksConfig::setOverdueFilter(OverdueFilter f) { + if (f == overdueFilter) return true; + overdueFilter = f; + return persist(); +} + +bool TasksConfig::setDateFormat(DateFormat f) { + if (f == dateFormat) return true; + dateFormat = f; + return persist(); +} + +bool TasksConfig::setActivityOrientation(GfxRenderer::Orientation o) { + if (o == activityOrientation) return true; + activityOrientation = o; + return persist(); +} + +bool TasksConfig::setSnapshotOrientation(GfxRenderer::Orientation o) { + if (o == snapshotOrientation) return true; + snapshotOrientation = o; + return persist(); +} + +bool TasksConfig::forget() { + if (todoistApiToken.empty()) return true; + todoistApiToken.clear(); + return persist(); +} + +} // namespace tasks diff --git a/src/tasks/TasksConfig.h b/src/tasks/TasksConfig.h new file mode 100644 index 0000000000..15b9ac65fc --- /dev/null +++ b/src/tasks/TasksConfig.h @@ -0,0 +1,110 @@ +#pragma once + +#include + +#include +#include +#include + +#include "TasksFilter.h" + +namespace tasks { + +class TaskProvider; // forward decl + +enum class Provider : uint8_t { + Todoist = 0, +}; + +// Overall layout. Minimal = compact list. Daily = date + day-of-week + tasks. +enum class DesignMode : uint8_t { + Minimal = 0, // ← default + Daily = 1, +}; + +// Display format for any date rendered by the activity. Year is omitted +// (task date ranges never span multiple years in normal use). +enum class DateFormat : uint8_t { + DayMonthSlash = 0, // 31/12 ← default + MonthDaySlash = 1, // 12/31 + DayMonthDash = 2, // 31-12 + MonthDayDash = 3, // 12-31 + DayMonthDot = 4, // 31.12 + MonthDayDot = 5, // 12.31 +}; + +// Render a date into `out` per `fmt`. Always writes a NUL terminator. +// outSize must be >= 6. Returns chars written (excluding NUL), 0 on overflow. +size_t formatDate(int day, int month, DateFormat fmt, char* out, size_t outSize); + +// Canonical wire string for `f`, e.g. "dd/mm". Used as JSON value and as +// settings-row label (universal English shorthand serves both). +const char* dateFormatToString(DateFormat f); + +// Canonical wire string for the design mode ("minimal" / "daily"). +const char* designModeToString(DesignMode d); + +class TasksConfig { + public: + static TasksConfig& getInstance(); + + // Loads /.crosspoint/tasks.json. If the file doesn't exist, writes a + // stub with default values + empty todoistApiToken (user is expected to + // edit the file via SD card to populate the token). Returns true on + // success in either case; false only on disk error. + bool load(); + + // Token getter (no setter — token is only loaded from disk). + const std::string& getTodoistApiToken() const { return todoistApiToken; } + bool hasToken() const { return !todoistApiToken.empty(); } + + Provider getProvider() const { return provider; } + DesignMode getDesignMode() const { return designMode; } + DateFilter getDateFilter() const { return dateFilter; } + OverdueFilter getOverdueFilter() const { return overdueFilter; } + DateFormat getDateFormat() const { return dateFormat; } + GfxRenderer::Orientation getActivityOrientation() const { return activityOrientation; } + GfxRenderer::Orientation getSnapshotOrientation() const { return snapshotOrientation; } + + // Convenience: build a TasksFilter from current date+overdue filters. + TasksFilter getFilter() const { return TasksFilter{dateFilter, overdueFilter}; } + + // Active provider singleton. Switches on `provider`. + TaskProvider& getActiveProvider(); + + // Setters — value-change-guarded, persist on change. Return false on + // disk error; return true if nothing changed (no-op). + bool setProvider(Provider p); + bool setDesignMode(DesignMode d); + bool setDateFilter(DateFilter f); + bool setOverdueFilter(OverdueFilter f); + bool setDateFormat(DateFormat f); + bool setActivityOrientation(GfxRenderer::Orientation o); + bool setSnapshotOrientation(GfxRenderer::Orientation o); + + // Blank the token on disk. Used by the "Forget" settings row. Activity + // reverts to setup state on next launch. + bool forget(); + + private: + TasksConfig() = default; + TasksConfig(const TasksConfig&) = delete; + TasksConfig& operator=(const TasksConfig&) = delete; + + bool persist(); + bool writeStubIfMissing(); + + Provider provider = Provider::Todoist; + std::string todoistApiToken; + DesignMode designMode = DesignMode::Minimal; + DateFilter dateFilter = DateFilter::Today; + OverdueFilter overdueFilter = OverdueFilter::Last7Days; + DateFormat dateFormat = DateFormat::DayMonthSlash; + GfxRenderer::Orientation activityOrientation = GfxRenderer::Orientation::Portrait; + GfxRenderer::Orientation snapshotOrientation = GfxRenderer::Orientation::Portrait; + bool loaded = false; +}; + +} // namespace tasks + +#define TASKS_CONFIG tasks::TasksConfig::getInstance() diff --git a/src/tasks/TasksFilter.h b/src/tasks/TasksFilter.h new file mode 100644 index 0000000000..16e2e66ce6 --- /dev/null +++ b/src/tasks/TasksFilter.h @@ -0,0 +1,29 @@ +#pragma once + +#include + +namespace tasks { + +// Date upper bound for fetched tasks. Calendar boundaries (ThisWeek / +// ThisMonth) are resolved against `time(nullptr)` at fetch time. Defaults +// to Today. +enum class DateFilter : uint8_t { + None = 0, + Today = 1, + ThisWeek = 2, + ThisMonth = 3, +}; + +// Overdue lower bound. Defaults to Last7Days. +enum class OverdueFilter : uint8_t { + None = 0, + Last7Days = 1, + All = 2, +}; + +struct TasksFilter { + DateFilter date = DateFilter::Today; + OverdueFilter overdue = OverdueFilter::Last7Days; +}; + +} // namespace tasks diff --git a/src/tasks/providers/todoist/TodoistFilter.cpp b/src/tasks/providers/todoist/TodoistFilter.cpp new file mode 100644 index 0000000000..b694227078 --- /dev/null +++ b/src/tasks/providers/todoist/TodoistFilter.cpp @@ -0,0 +1,96 @@ +#include "TodoistFilter.h" + +#include +#include +#include + +namespace tasks::todoist { + +namespace { + +// Format `today + daysAhead` as YYYY-MM-DD in the device's local timezone. +// Caller must have already verified the clock is set (via NTP). +std::string formatLocalDate(int daysAhead) { + time_t now = time(nullptr); + struct tm tm; + localtime_r(&now, &tm); + tm.tm_mday += daysAhead; + mktime(&tm); // normalises across month/year boundaries + char buf[11]; + strftime(buf, sizeof(buf), "%Y-%m-%d", &tm); + return std::string(buf); +} + +// Days from today to the upcoming Monday (1..7). ISO week ends on Sunday, +// so the strict `due before:` cutoff for "this week" is next Monday. If +// today is Monday we want a full week ahead, not zero — 0 maps to 7. +int daysUntilNextMonday() { + time_t now = time(nullptr); + struct tm tm; + localtime_r(&now, &tm); + int days = (1 - tm.tm_wday + 7) % 7; + return days == 0 ? 7 : days; +} + +// First day of next calendar month, YYYY-MM-DD. +std::string firstOfNextMonth() { + time_t now = time(nullptr); + struct tm tm; + localtime_r(&now, &tm); + tm.tm_mon += 1; + tm.tm_mday = 1; + mktime(&tm); // normalises December → January roll-over + char buf[11]; + strftime(buf, sizeof(buf), "%Y-%m-%d", &tm); + return std::string(buf); +} + +} // namespace + +std::string buildQuery(DateFilter dateF, OverdueFilter overdueF) { + std::string date; + switch (dateF) { + case DateFilter::None: break; + case DateFilter::Today: date = "today"; break; + case DateFilter::ThisWeek: + date = "due after: yesterday & due before: " + + formatLocalDate(daysUntilNextMonday()); + break; + case DateFilter::ThisMonth: + date = "due after: yesterday & due before: " + firstOfNextMonth(); + break; + } + + std::string overdue; + switch (overdueF) { + case OverdueFilter::None: break; + case OverdueFilter::Last7Days: overdue = "overdue & due after: -7 days"; break; + case OverdueFilter::All: overdue = "overdue"; break; + } + + if (date.empty() && overdue.empty()) return "today"; // degenerate fallback + if (date.empty()) return overdue; + if (overdue.empty()) return date; + return "(" + date + ") | (" + overdue + ")"; +} + +std::string urlEncode(const std::string& s) { + std::string out; + out.reserve(s.size() * 3); + static const char hex[] = "0123456789ABCDEF"; + for (char c : s) { + unsigned char uc = static_cast(c); + if ((uc >= 'A' && uc <= 'Z') || (uc >= 'a' && uc <= 'z') || + (uc >= '0' && uc <= '9') || + uc == '-' || uc == '_' || uc == '.' || uc == '~') { + out.push_back(c); + } else { + out.push_back('%'); + out.push_back(hex[uc >> 4]); + out.push_back(hex[uc & 0x0F]); + } + } + return out; +} + +} // namespace tasks::todoist diff --git a/src/tasks/providers/todoist/TodoistFilter.h b/src/tasks/providers/todoist/TodoistFilter.h new file mode 100644 index 0000000000..bab6f9dfe1 --- /dev/null +++ b/src/tasks/providers/todoist/TodoistFilter.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +#include "tasks/TasksFilter.h" + +namespace tasks::todoist { + +// Translate a generic two-axis TasksFilter into a Todoist query DSL string. +// Calendar boundaries (ThisWeek / ThisMonth) are resolved against +// `time(nullptr)` in the device's current timezone, so the caller must +// ensure NTP has set the clock before calling. +// +// Returns the *unencoded* query — caller is responsible for URL encoding +// before substituting into the endpoint URL. +std::string buildQuery(DateFilter dateF, OverdueFilter overdueF); + +// Percent-encode an unreserved-only string for the URL query value. +std::string urlEncode(const std::string& s); + +} // namespace tasks::todoist diff --git a/src/tasks/providers/todoist/TodoistProvider.cpp b/src/tasks/providers/todoist/TodoistProvider.cpp new file mode 100644 index 0000000000..6ae8d7968f --- /dev/null +++ b/src/tasks/providers/todoist/TodoistProvider.cpp @@ -0,0 +1,260 @@ +#include "TodoistProvider.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "TodoistFilter.h" +#include "tasks/TasksConfig.h" + +namespace tasks { + +namespace { + +constexpr const char* kEndpointBase = + "https://api.todoist.com/api/v1/tasks/filter?query="; +constexpr int kHttpTimeoutMs = 15000; +// Keep HTTP rx/tx buffers small. mbedTLS handshake on ESP32-C3 needs ~32 KB +// of heap on top of these — every KB we free here is one mbedTLS can take. +constexpr size_t kHttpBufSize = 2048; +constexpr size_t kMaxTasks = 64; +// Hard cap on the response body. Allocated once via malloc() and never +// reallocated: std::string's geometric growth (2x) creates transient memory +// spikes that trip OOM on the C3 when responses get large. A fixed buffer +// trades a higher steady-state footprint for predictability. +// +// 16 KB chosen empirically: even ThisMonth on a heavily-loaded account +// rarely pushes past 12 KB of JSON (64-task cap × ~180 bytes/task incl. +// envelope). 32 KB was originally picked for paranoia, but the C3's heap +// fragmentation after WiFi+NTP+TLS handshake makes a single contiguous +// 32 KB block unreliable — largest free block hovers around 24-28 KB. +// Truncation is handled gracefully (we parse what we got). +constexpr size_t kMaxResponseBytes = 16 * 1024; + +struct ResponseBuffer { + char* data = nullptr; + size_t capacity = 0; + size_t size = 0; + bool truncated = false; + bool allocFailed = false; +}; + +esp_err_t httpEventHandler(esp_http_client_event_t* evt) { + if (evt->event_id != HTTP_EVENT_ON_DATA) return ESP_OK; + auto* buf = static_cast(evt->user_data); + if (!buf || !evt->data || evt->data_len <= 0) return ESP_OK; + // Lazy allocation: claim the response buffer only after the TLS handshake + // has run and freed its scratch. Allocating up-front leaves mbedtls without + // a contiguous block big enough for SSL setup on the C3 (~40KB), even + // when total free heap looks healthy. + if (!buf->data) { + if (buf->allocFailed) return ESP_OK; + buf->data = static_cast(malloc(buf->capacity)); + if (!buf->data) { + buf->allocFailed = true; + size_t freeBytes = heap_caps_get_free_size(MALLOC_CAP_8BIT); + size_t largest = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT); + LOG_ERR("TODOIST", "OOM allocating %u-byte response buffer (free=%u, largest=%u)", + static_cast(buf->capacity), + static_cast(freeBytes), + static_cast(largest)); + return ESP_OK; + } + } + size_t len = static_cast(evt->data_len); + size_t avail = buf->capacity - buf->size; + if (len > avail) { + buf->truncated = true; + len = avail; + if (len == 0) return ESP_OK; + } + memcpy(buf->data + buf->size, evt->data, len); + buf->size += len; + return ESP_OK; +} + +void extractDueTime(const char* due, char* out, size_t outCap) { + if (outCap == 0) return; + out[0] = '\0'; + if (!due) return; + const char* tPos = strchr(due, 'T'); + if (!tPos) return; + if (strlen(tPos + 1) < 5) return; + if (outCap < 6) return; + snprintf(out, outCap, "%c%c:%c%c", + tPos[1], tPos[2], tPos[4], tPos[5]); +} + +bool isOverdue(const char* due) { + if (!due) return false; + int y, mo, d; + if (sscanf(due, "%4d-%2d-%2d", &y, &mo, &d) != 3) return false; + + time_t now = time(nullptr); + if (now < 1700000000) return false; // clock not set yet + struct tm nowTm; + localtime_r(&now, &nowTm); + + if (y < nowTm.tm_year + 1900) return true; + if (y > nowTm.tm_year + 1900) return false; + if (mo < nowTm.tm_mon + 1) return true; + if (mo > nowTm.tm_mon + 1) return false; + return d < nowTm.tm_mday; +} + +void copyTitle(char* dst, size_t dstCap, const char* src) { + if (dstCap == 0) return; + if (!src) { + dst[0] = '\0'; + return; + } + size_t n = strlen(src); + if (n >= dstCap) n = dstCap - 1; + memcpy(dst, src, n); + dst[n] = '\0'; +} + +FetchResult httpStatusToFetchResult(int code) { + if (code == 200) return FetchResult::Ok; + if (code == 401 || code == 403) return FetchResult::AuthError; + if (code == 429) return FetchResult::RateLimited; + return FetchResult::NetworkError; +} + +} // namespace + +TodoistProvider& TodoistProvider::instance() { + static TodoistProvider inst; + return inst; +} + +FetchResult TodoistProvider::fetch(const TasksFilter& filter, + std::vector& out) { + out.clear(); + out.reserve(kMaxTasks); + + const std::string& apiToken = TASKS_CONFIG.getTodoistApiToken(); + if (apiToken.empty()) { + LOG_ERR("TODOIST", "Empty token"); + return FetchResult::NoAuth; + } + + const std::string query = todoist::buildQuery(filter.date, filter.overdue); + const std::string url = std::string(kEndpointBase) + todoist::urlEncode(query); + LOG_DBG("TODOIST", "Query: %s", query.c_str()); + + // Response buffer descriptor — actual char[] allocated lazily in + // the event handler post-TLS to avoid heap fragmentation. + ResponseBuffer buf; + buf.capacity = kMaxResponseBytes; + + esp_http_client_config_t config = {}; + config.url = url.c_str(); + config.event_handler = httpEventHandler; + config.user_data = &buf; + config.method = HTTP_METHOD_GET; + config.timeout_ms = kHttpTimeoutMs; + config.buffer_size = kHttpBufSize; + config.buffer_size_tx = kHttpBufSize; + config.crt_bundle_attach = esp_crt_bundle_attach; + + esp_http_client_handle_t client = esp_http_client_init(&config); + if (!client) { + LOG_ERR("TODOIST", "esp_http_client_init failed"); + free(buf.data); + return FetchResult::NetworkError; + } + + std::string authHeader = "Bearer " + apiToken; + if (esp_http_client_set_header(client, "Authorization", authHeader.c_str()) != ESP_OK || + esp_http_client_set_header(client, "Accept", "application/json") != ESP_OK) { + LOG_ERR("TODOIST", "Set header failed"); + esp_http_client_cleanup(client); + free(buf.data); + return FetchResult::NetworkError; + } + + esp_err_t err = esp_http_client_perform(client); + const int httpCode = esp_http_client_get_status_code(client); + esp_http_client_cleanup(client); + + LOG_DBG("TODOIST", "HTTP %d (err=%d, %u bytes%s)", + httpCode, err, static_cast(buf.size), + buf.truncated ? " [truncated]" : ""); + + if (err != ESP_OK || buf.allocFailed) { + free(buf.data); + return FetchResult::NetworkError; + } + FetchResult statusResult = httpStatusToFetchResult(httpCode); + if (statusResult != FetchResult::Ok) { + free(buf.data); + return statusResult; + } + + if (buf.truncated) { + LOG_ERR("TODOIST", "Response truncated at cap (%u bytes)", + static_cast(buf.capacity)); + // Not fatal — try to parse what we have. + } + + JsonDocument doc; + auto parseErr = deserializeJson(doc, buf.data, buf.size); + // Free raw bytes immediately — ArduinoJson copied what it needed. + free(buf.data); + buf.data = nullptr; + if (parseErr) { + LOG_ERR("TODOIST", "JSON parse: %s", parseErr.c_str()); + return FetchResult::ParseError; + } + if (!doc["results"].is()) { + LOG_ERR("TODOIST", "Response missing 'results' array"); + return FetchResult::ParseError; + } + if (!doc["next_cursor"].isNull()) { + LOG_DBG("TODOIST", "Truncated: more tasks available via cursor"); + } + + JsonArray arr = doc["results"].as(); + for (JsonObject task : arr) { + if (out.size() >= kMaxTasks) break; + Task t = {}; + copyTitle(t.title, Task::kTitleCapacity, task["content"] | ""); + t.priority = static_cast(task["priority"] | 1); + + const char* date = nullptr; + const char* datetime = nullptr; + if (task["due"].is()) { + JsonObject due = task["due"].as(); + date = due["date"] | static_cast(nullptr); + datetime = due["datetime"] | static_cast(nullptr); + } + extractDueTime(datetime, t.dueTime, Task::kDueTimeCapacity); + t.dueDate[0] = '\0'; + if (date && strlen(date) >= 10) { + memcpy(t.dueDate, date, 10); + t.dueDate[10] = '\0'; + } + t.overdue = isOverdue(date); + + out.push_back(t); + } + + LOG_DBG("TODOIST", "Parsed %u tasks", static_cast(out.size())); + return FetchResult::Ok; +} + +// Defined here (rather than in TasksConfig.cpp) so TasksConfig.cpp stays +// free of provider dependencies. The dispatch grows when a 2nd provider lands. +TaskProvider& TasksConfig::getActiveProvider() { + return TodoistProvider::instance(); +} + +} // namespace tasks diff --git a/src/tasks/providers/todoist/TodoistProvider.h b/src/tasks/providers/todoist/TodoistProvider.h new file mode 100644 index 0000000000..d4fa81227c --- /dev/null +++ b/src/tasks/providers/todoist/TodoistProvider.h @@ -0,0 +1,28 @@ +#pragma once + +#include "tasks/TaskProvider.h" + +namespace tasks { + +// Todoist concrete implementation of TaskProvider. Talks to +// https://api.todoist.com/api/v1/tasks/filter with a Bearer token. +// +// Provider is a singleton accessed via instance(). Zero heap for the +// object itself; the only transient allocation is the 16KB response +// buffer inside fetch(), claimed lazily post-TLS handshake and freed +// after JSON parse. +class TodoistProvider final : public TaskProvider { + public: + static TodoistProvider& instance(); + + const char* displayName() const override { return "Todoist"; } + FetchResult fetch(const TasksFilter& filter, + std::vector& out) override; + + private: + TodoistProvider() = default; + TodoistProvider(const TodoistProvider&) = delete; + TodoistProvider& operator=(const TodoistProvider&) = delete; +}; + +} // namespace tasks