diff --git a/lib/apps_model/src/settings_menu.cpp b/lib/apps_model/src/settings_menu.cpp new file mode 100644 index 0000000..3059ebe --- /dev/null +++ b/lib/apps_model/src/settings_menu.cpp @@ -0,0 +1,143 @@ +#include "settings_menu.h" + +#include "choices.h" + +namespace roro { + +namespace { + +using Row = SettingsMenu::Row; +using Kind = SettingsMenu::Kind; + +struct RowDef { + Row row; + Kind kind; + const char* label; +}; + +const RowDef kRows[] = { + {Row::LongName, Kind::Text, "Long name"}, {Row::ShortName, Kind::Text, "Short name"}, + {Row::Region, Kind::Choice, "Region"}, {Row::Timezone, Kind::Choice, "Timezone"}, + {Row::Brightness, Kind::Slider, "Brightness"}, {Row::DimTimeout, Kind::Choice, "Dim after"}, + {Row::OffTimeout, Kind::Choice, "Screen off after"}, {Row::Sound, Kind::Toggle, "Sound & LED"}, + {Row::ProbeMacs, Kind::Toggle, "Probe MACs"}, {Row::Storage, Kind::Page, "Storage"}, + {Row::About, Kind::Page, "About"}, +}; + +const int kDimSeconds[] = {10, 15, 30, 60, 120, 300}; +const int kOffSeconds[] = {30, 60, 120, 300, 600, 1800}; + +std::string formatSeconds(int s) { return s < 60 ? std::to_string(s) + " s" : std::to_string(s / 60) + " min"; } + +template +std::vector labels(const Choice (&table)[N]) { + std::vector out; + for (auto& c : table) out.push_back(c.label); + return out; +} + +template +std::vector durations(const int (&seconds)[N]) { + std::vector out; + for (int s : seconds) out.push_back(formatSeconds(s)); + return out; +} + +template +int indexOf(const Choice (&table)[N], const std::string& value) { + for (size_t i = 0; i < N; i++) + if (value == table[i].value) return static_cast(i); + return -1; +} + +template +int indexOf(const int (&table)[N], int value) { + for (size_t i = 0; i < N; i++) + if (table[i] == value) return static_cast(i); + return -1; +} + +} // namespace + +int SettingsMenu::count() const { return sizeof(kRows) / sizeof(kRows[0]); } +SettingsMenu::Row SettingsMenu::row(int i) const { return kRows[i].row; } +SettingsMenu::Kind SettingsMenu::kind(int i) const { return kRows[i].kind; } +std::string SettingsMenu::label(int i) const { return kRows[i].label; } + +std::string SettingsMenu::value(int i) const { + switch (row(i)) { + case Row::LongName: return settings_.getString(Setting::LongName); + case Row::ShortName: return settings_.getString(Setting::ShortName); + case Row::Region: return settings_.getString(Setting::Region); + case Row::Timezone: { + int c = currentChoice(i); + return c >= 0 ? kTimezones[c].label : "Custom"; + } + case Row::Brightness: return std::to_string(settings_.getInt(Setting::Brightness)) + "%"; + case Row::DimTimeout: return formatSeconds(settings_.getInt(Setting::DimTimeoutS)); + case Row::OffTimeout: return formatSeconds(settings_.getInt(Setting::OffTimeoutS)); + case Row::Sound: return settings_.getBool(Setting::Sound) ? "On" : "Off"; + case Row::ProbeMacs: return settings_.getBool(Setting::ProbeMacRaw) ? "Raw" : "Pseudonymised"; + default: return ""; + } +} + +std::vector SettingsMenu::choices(int i) const { + switch (row(i)) { + case Row::Region: return labels(kRegions); + case Row::Timezone: return labels(kTimezones); + case Row::DimTimeout: return durations(kDimSeconds); + case Row::OffTimeout: return durations(kOffSeconds); + default: return {}; + } +} + +int SettingsMenu::currentChoice(int i) const { + switch (row(i)) { + case Row::Region: return indexOf(kRegions, settings_.getString(Setting::Region)); + case Row::Timezone: return indexOf(kTimezones, settings_.getString(Setting::Timezone)); + case Row::DimTimeout: return indexOf(kDimSeconds, settings_.getInt(Setting::DimTimeoutS)); + case Row::OffTimeout: return indexOf(kOffSeconds, settings_.getInt(Setting::OffTimeoutS)); + default: return -1; + } +} + +std::string SettingsMenu::choose(int i, int c) { + switch (row(i)) { + case Row::Region: + settings_.setString(Setting::Region, kRegions[c].value); + settings_.setBool(Setting::RegionConfirmed, true); + return ""; + case Row::Timezone: settings_.setString(Setting::Timezone, kTimezones[c].value); return ""; + case Row::DimTimeout: + return settings_.setInt(Setting::DimTimeoutS, kDimSeconds[c]) ? "" : "Dimming must happen before screen off"; + case Row::OffTimeout: + return settings_.setInt(Setting::OffTimeoutS, kOffSeconds[c]) ? "" : "Screen off must come after dimming"; + default: return "Not a choice"; + } +} + +void SettingsMenu::toggle(int i) { + if (row(i) == Row::Sound) settings_.setBool(Setting::Sound, !settings_.getBool(Setting::Sound)); + if (row(i) == Row::ProbeMacs) settings_.setBool(Setting::ProbeMacRaw, !settings_.getBool(Setting::ProbeMacRaw)); +} + +void SettingsMenu::adjust(int i, int direction) { + if (row(i) != Row::Brightness) return; + int next = settings_.getInt(Setting::Brightness) + (direction > 0 ? 10 : -10); + if (next < 10) next = 10; + if (next > 100) next = 100; + settings_.setInt(Setting::Brightness, next); +} + +int SettingsMenu::maxBytes(int i) const { + return row(i) == Row::ShortName ? 4 : row(i) == Row::LongName ? 39 : 0; +} + +std::string SettingsMenu::setText(int i, const std::string& text) { + Setting s = row(i) == Row::ShortName ? Setting::ShortName : Setting::LongName; + if (settings_.setString(s, text)) return ""; + return "Must be 1 to " + std::to_string(maxBytes(i)) + " bytes"; +} + +} // namespace roro diff --git a/lib/apps_model/src/settings_menu.h b/lib/apps_model/src/settings_menu.h new file mode 100644 index 0000000..9f65cd7 --- /dev/null +++ b/lib/apps_model/src/settings_menu.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include + +#include "settings.h" + +namespace roro { + +// What the Settings App lists: one row per user-facing setting (plus sub-pages), with readable +// values, choice lists and validation messages. Rendering and navigation live in the App. +class SettingsMenu { + public: + enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, ProbeMacs, Storage, About }; + enum class Kind { Text, Choice, Toggle, Slider, Page }; + + explicit SettingsMenu(Settings& settings) : settings_(settings) {} + + int count() const; + Row row(int i) const; + Kind kind(int i) const; + std::string label(int i) const; + std::string value(int i) const; + + // Choice rows + std::vector choices(int i) const; + int currentChoice(int i) const; // -1 if the current value isn't one of the choices + std::string choose(int i, int choice); // empty on success, otherwise why it was refused + + // Toggle and Slider rows + void toggle(int i); + void adjust(int i, int direction); + + // Text rows + int maxBytes(int i) const; + std::string setText(int i, const std::string& text); // empty on success, otherwise why + + private: + Settings& settings_; +}; + +} // namespace roro diff --git a/lib/apps_model/src/setup_wizard.cpp b/lib/apps_model/src/setup_wizard.cpp new file mode 100644 index 0000000..919a9a6 --- /dev/null +++ b/lib/apps_model/src/setup_wizard.cpp @@ -0,0 +1,88 @@ +#include "setup_wizard.h" + +#include "choices.h" + +namespace roro { + +SetupWizard::SetupWizard(Settings& settings, std::string defaultLongName, std::string defaultShortName) + : settings_(settings), longName_(std::move(defaultLongName)), shortName_(std::move(defaultShortName)) {} + +int SetupWizard::choiceCount() const { + if (step_ == Step::Region) return sizeof(kRegions) / sizeof(kRegions[0]); + if (step_ == Step::Timezone) return sizeof(kTimezones) / sizeof(kTimezones[0]); + return 0; +} + +std::string SetupWizard::choiceLabel(int i) const { + return step_ == Step::Region ? kRegions[i].label : kTimezones[i].label; +} + +int SetupWizard::selectedChoice() const { return step_ == Step::Region ? region_ : timezone_; } + +void SetupWizard::selectChoice(int i) { + if (i < 0 || i >= choiceCount()) return; + (step_ == Step::Region ? region_ : timezone_) = i; +} + +void SetupWizard::enter(Step step) { + step_ = step; + error_.clear(); + if (step == Step::LongName) { + editor_ = LineEditor(39); + editor_.setText(longName_); + } else if (step == Step::ShortName) { + editor_ = LineEditor(4); + editor_.setText(shortName_); + } +} + +bool SetupWizard::next() { + switch (step_) { + case Step::Welcome: enter(Step::LongName); return true; + case Step::LongName: + if (editor_.text().empty()) { + error_ = "Please enter a name"; + return false; + } + longName_ = editor_.text(); + enter(Step::ShortName); + return true; + case Step::ShortName: + if (editor_.text().empty()) { + error_ = "Please enter 1 to 4 characters"; + return false; + } + shortName_ = editor_.text(); + enter(Step::Region); + return true; + case Step::Region: enter(Step::Timezone); return true; + case Step::Timezone: enter(Step::Done); return true; + case Step::Done: + save(); + finished_ = true; + return true; + } + return false; +} + +void SetupWizard::back() { + switch (step_) { + case Step::LongName: longName_ = editor_.text(); enter(Step::Welcome); break; + case Step::ShortName: shortName_ = editor_.text(); enter(Step::LongName); break; + case Step::Region: enter(Step::ShortName); break; + case Step::Timezone: enter(Step::Region); break; + case Step::Done: enter(Step::Timezone); break; + case Step::Welcome: break; + } +} + +void SetupWizard::save() { + settings_.setString(Setting::LongName, longName_); + settings_.setString(Setting::ShortName, shortName_); + settings_.setString(Setting::Region, kRegions[region_].value); + settings_.setString(Setting::Timezone, kTimezones[timezone_].value); + settings_.setBool(Setting::RegionConfirmed, true); + settings_.setBool(Setting::SetupDone, true); +} + +} // namespace roro diff --git a/lib/apps_model/src/setup_wizard.h b/lib/apps_model/src/setup_wizard.h new file mode 100644 index 0000000..28b9755 --- /dev/null +++ b/lib/apps_model/src/setup_wizard.h @@ -0,0 +1,46 @@ +#pragma once + +#include + +#include "line_editor.h" +#include "settings.h" + +namespace roro { + +// The first-boot setup flow: names, Region confirmation, timezone. Nothing is saved until the +// last step, so an interrupted setup simply starts again at the next boot. +class SetupWizard { + public: + enum class Step { Welcome, LongName, ShortName, Region, Timezone, Done }; + + SetupWizard(Settings& settings, std::string defaultLongName, std::string defaultShortName); + + Step step() const { return step_; } + bool finished() const { return finished_; } + + // Name steps edit text; Region and Timezone steps pick a choice. + LineEditor& editor() { return editor_; } + int choiceCount() const; + std::string choiceLabel(int i) const; + int selectedChoice() const; + void selectChoice(int i); + + bool next(); // validates the step; false (with error()) if it can't advance + void back(); + const std::string& error() const { return error_; } + + private: + void enter(Step step); + void save(); + + Settings& settings_; + Step step_ = Step::Welcome; + bool finished_ = false; + std::string error_; + LineEditor editor_{39}; + std::string longName_, shortName_; + int region_ = 0; + int timezone_ = 0; +}; + +} // namespace roro diff --git a/lib/core/src/app_manager.cpp b/lib/core/src/app_manager.cpp index 3cc6af4..a0b2254 100644 --- a/lib/core/src/app_manager.cpp +++ b/lib/core/src/app_manager.cpp @@ -21,19 +21,41 @@ void AppManager::begin() { redraw_ = true; } -bool AppManager::open(const char* id) { - for (auto& info : apps_) { - if (std::strcmp(info.id, id) == 0) { - switchTo(*info.app); - return true; - } - } - return false; +App* AppManager::find(const char* id) const { + for (auto& info : apps_) + if (std::strcmp(info.id, id) == 0) return info.app; + return nullptr; } -void AppManager::home() { switchTo(launcher_); } +bool AppManager::open(const char* id) { + App* app = find(id); + if (!app || modal_) return false; + switchTo(*app); + return true; +} + +void AppManager::home() { + if (!modal_) switchTo(launcher_); +} + +bool AppManager::openModal(const char* id) { + App* app = find(id); + if (!app) return false; + switchTo(*app); + modal_ = true; + return true; +} + +void AppManager::endModal() { + modal_ = false; + switchTo(launcher_); +} void AppManager::handleKey(const KeyEvent& event) { + if (modal_) { + foreground_->onKey(event); + return; + } if (event.key == Key::Home) { home(); return; @@ -42,6 +64,12 @@ void AppManager::handleKey(const KeyEvent& event) { if (!consumed && event.key == Key::Back) home(); } +const char* AppManager::foregroundTitle() const { + for (auto& info : apps_) + if (info.app == foreground_) return info.title; + return nullptr; +} + bool AppManager::takeRedraw() { bool r = redraw_ | foreground_->consumeRedraw(); redraw_ = false; diff --git a/lib/core/src/app_manager.h b/lib/core/src/app_manager.h index 7656644..317fe55 100644 --- a/lib/core/src/app_manager.h +++ b/lib/core/src/app_manager.h @@ -23,24 +23,32 @@ class AppManager { std::vector visibleApps() const; void begin(); - bool open(const char* id); + bool open(const char* id); // refused while a modal App runs void home(); + // A modal App (e.g. the first-boot wizard) can't be left with Home or Back: those keys go to it, + // and it ends itself with endModal(), which returns to the Launcher. + bool openModal(const char* id); + void endModal(); + void handleKey(const KeyEvent& event); void update(uint32_t nowMs) { foreground_->update(nowMs); } App& foreground() const { return *foreground_; } + const char* foregroundTitle() const; // nullptr for the Launcher // True once after the screen needs redrawing (App switch, or the App asked for it). bool takeRedraw(); private: void switchTo(App& app); + App* find(const char* id) const; App& launcher_; App* foreground_; std::vector apps_; bool redraw_ = true; + bool modal_ = false; }; } // namespace roro diff --git a/lib/services/src/choices.h b/lib/services/src/choices.h new file mode 100644 index 0000000..7b440ac --- /dev/null +++ b/lib/services/src/choices.h @@ -0,0 +1,24 @@ +#pragma once + +namespace roro { + +struct Choice { + const char* label; + const char* value; +}; + +// Timezones offered in Settings and the setup wizard (POSIX TZ strings). +inline const Choice kTimezones[] = { + {"Brussels", "CET-1CEST,M3.5.0,M10.5.0/3"}, // also Paris, Amsterdam, Berlin... + {"London", "GMT0BST,M3.5.0/1,M10.5.0"}, + {"Lisbon", "WET0WEST,M3.5.0/1,M10.5.0"}, + {"Helsinki", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"UTC", "UTC0"}, +}; + +// Regions the radio may be used in (see Region in CONTEXT.md). +inline const Choice kRegions[] = { + {"EU868 (Europe, 869.5 MHz)", "EU868"}, +}; + +} // namespace roro diff --git a/platformio.ini b/platformio.ini index 952ceba..fbd0c01 100644 --- a/platformio.ini +++ b/platformio.ini @@ -7,6 +7,7 @@ default_envs = cardputer-adv [env] extra_scripts = pre:scripts/version.py test_framework = unity +lib_ldf_mode = deep+ [env:cardputer-adv] platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip diff --git a/src/apps/diagnostics_app.cpp b/src/apps/diagnostics_app.cpp deleted file mode 100644 index 985fd05..0000000 --- a/src/apps/diagnostics_app.cpp +++ /dev/null @@ -1,84 +0,0 @@ -#include "diagnostics_app.h" - -#include - -#include "ui/fonts.h" -#include "ui/widgets.h" -#include "version.h" - -namespace roro { - -bool DiagnosticsApp::onKey(const KeyEvent& e) { - requestRedraw(); - if (formatDialog_) { - formatDialog_->onKey(e); - if (formatDialog_->result() == 1) storage_.requestFormat(); - if (formatDialog_->result() != DialogModel::kPending) formatDialog_.reset(); - return true; - } - if (e.key != Key::Char) return false; - int brightness = settings_.getInt(Setting::Brightness); - switch (e.ch) { - case '+': - case '=': settings_.setInt(Setting::Brightness, brightness + 10); return true; - case '-': settings_.setInt(Setting::Brightness, brightness - 10); return true; - case 'd': - settings_.setInt(Setting::DimTimeoutS, 5); - settings_.setInt(Setting::OffTimeoutS, 10); - return true; - case 'r': // restore default timeouts - settings_.setInt(Setting::OffTimeoutS, 60); - settings_.setInt(Setting::DimTimeoutS, 30); - return true; - case 'f': - if (storage_.state().present) formatDialog_.reset(new DialogModel({"Cancel", "Erase"})); - return true; - } - return false; -} - -void DiagnosticsApp::update(uint32_t nowMs) { - if (nowMs - lastRefreshMs_ >= 1000) { - lastRefreshMs_ = nowMs; - requestRedraw(); - } -} - -void DiagnosticsApp::draw(Canvas& c) { - const auto& area = theme::kContent; - const auto& b = battery_.estimator(); - const auto s = storage_.state(); - std::vector lines; - char buf[64]; - snprintf(buf, sizeof(buf), "%s %s", kProductName, versionString()); - lines.push_back(buf); - snprintf(buf, sizeof(buf), "Battery %d%% %d mV (raw %d)", b.percent(), b.millivolts(), battery_.lastRawMillivolts()); - lines.push_back(buf); - if (storage_.formatting()) - lines.push_back("SD: formatting..."); - else if (s.present) { - snprintf(buf, sizeof(buf), "SD %d%% of %u MB logs:%s capt:%s", s.usedPercent, - static_cast(s.totalBytes >> 20), s.logsAllowed ? "y" : "n", s.capturesAllowed ? "y" : "n"); - lines.push_back(buf); - } else - lines.push_back("SD: no card"); - snprintf(buf, sizeof(buf), "Uptime %lus clock %s", millis() / 1000, clock_.displayTime().c_str()); - lines.push_back(buf); - snprintf(buf, sizeof(buf), "Heap %u KB free, min %u KB", ESP.getFreeHeap() / 1024, ESP.getMinFreeHeap() / 1024); - lines.push_back(buf); - snprintf(buf, sizeof(buf), "Bright %d%% dim %ds off %ds", static_cast(settings_.getInt(Setting::Brightness)), - static_cast(settings_.getInt(Setting::DimTimeoutS)), - static_cast(settings_.getInt(Setting::OffTimeoutS))); - lines.push_back(buf); - widgets::textLines(c, lines, 0, area); - - c.setFont(&fonts::small); - c.setTextColor(theme::kMuted); - c.drawString("+/- bright d: 5s/10s r: 30s/60s f: erase SD", 4, area.y + area.h - 10); - - if (formatDialog_) - widgets::dialog(c, "Erase SD card?", "Everything on the card is deleted. It becomes one FAT32 partition.", - *formatDialog_); -} - -} // namespace roro diff --git a/src/apps/diagnostics_app.h b/src/apps/diagnostics_app.h deleted file mode 100644 index 40cd74b..0000000 --- a/src/apps/diagnostics_app.h +++ /dev/null @@ -1,33 +0,0 @@ -#pragma once - -#include - -#include "app.h" -#include "dialog_model.h" -#include "services/battery_service.h" -#include "services/clock_service.h" -#include "services/power_service.h" -#include "services/storage_service.h" -#include "settings.h" - -namespace roro { - -// Live hardware values for bring-up; becomes the About screen in step 7. -class DiagnosticsApp : public App { - public: - DiagnosticsApp(Settings& settings, BatteryService& battery, StorageService& storage, ClockService& clock) - : settings_(settings), battery_(battery), storage_(storage), clock_(clock) {} - bool onKey(const KeyEvent& e) override; - void update(uint32_t nowMs) override; - void draw(Canvas& c) override; - - private: - Settings& settings_; - BatteryService& battery_; - StorageService& storage_; - ClockService& clock_; - std::unique_ptr formatDialog_; - uint32_t lastRefreshMs_ = 0; -}; - -} // namespace roro diff --git a/src/apps/settings_app.cpp b/src/apps/settings_app.cpp new file mode 100644 index 0000000..73efcd7 --- /dev/null +++ b/src/apps/settings_app.cpp @@ -0,0 +1,210 @@ +#include "settings_app.h" + +#include + +#include "platform/identity.h" +#include "ui/fonts.h" +#include "ui/widgets.h" +#include "version.h" + +namespace roro { + +using Kind = SettingsMenu::Kind; +using Row = SettingsMenu::Row; + +void SettingsApp::onEnter() { + page_ = Page::Menu; + eraseDialog_.reset(); + list_.setCount(menu_.count()); +} + +void SettingsApp::warn(const std::string& text) { + d_.bus.publish(Event::withText(EventType::Notification, text.c_str(), static_cast(NotificationLevel::Warning))); +} + +bool SettingsApp::onKey(const KeyEvent& e) { + requestRedraw(); + switch (page_) { + case Page::Menu: return onMenuKey(e); + case Page::Text: return onTextKey(e); + case Page::Choice: return onChoiceKey(e); + case Page::Storage: return onStorageKey(e); + case Page::About: return onAboutKey(e); + } + return false; +} + +bool SettingsApp::onMenuKey(const KeyEvent& e) { + int i = list_.selected(); + switch (e.key) { + case Key::Up: list_.up(); return true; + case Key::Down: list_.down(); return true; + case Key::Left: + case Key::Right: + if (menu_.kind(i) == Kind::Slider) menu_.adjust(i, e.key == Key::Right ? 1 : -1); + if (menu_.kind(i) == Kind::Toggle) menu_.toggle(i); + return true; + case Key::Select: break; + default: return false; // Back leaves Settings + } + editingRow_ = i; + switch (menu_.kind(i)) { + case Kind::Text: + editor_ = LineEditor(menu_.maxBytes(i)); + editor_.setText(menu_.value(i)); + page_ = Page::Text; + break; + case Kind::Choice: + choices_.setCount(static_cast(menu_.choices(i).size())); + choices_.select(menu_.currentChoice(i) < 0 ? 0 : menu_.currentChoice(i)); + page_ = Page::Choice; + break; + case Kind::Toggle: menu_.toggle(i); break; + case Kind::Slider: break; + case Kind::Page: page_ = menu_.row(i) == Row::Storage ? Page::Storage : Page::About; break; + } + return true; +} + +bool SettingsApp::onTextKey(const KeyEvent& e) { + switch (e.key) { + case Key::Char: editor_.insert(e.ch); break; + case Key::Delete: editor_.backspace(); break; + case Key::Left: editor_.left(); break; + case Key::Right: editor_.right(); break; + case Key::Select: { + std::string error = menu_.setText(editingRow_, editor_.text()); + if (error.empty()) page_ = Page::Menu; + else warn(error); + break; + } + case Key::Back: page_ = Page::Menu; break; // cancel + default: break; + } + return true; +} + +bool SettingsApp::onChoiceKey(const KeyEvent& e) { + switch (e.key) { + case Key::Up: choices_.up(); break; + case Key::Down: choices_.down(); break; + case Key::Select: { + std::string error = menu_.choose(editingRow_, choices_.selected()); + if (error.empty()) page_ = Page::Menu; + else warn(error); + break; + } + case Key::Back: page_ = Page::Menu; break; + default: break; + } + return true; +} + +bool SettingsApp::onStorageKey(const KeyEvent& e) { + if (eraseDialog_) { + eraseDialog_->onKey(e); + if (eraseDialog_->result() == 1 && !d_.storage.requestFormat()) warn("A format is already running"); + if (eraseDialog_->result() != DialogModel::kPending) eraseDialog_.reset(); + return true; + } + if (e.key == Key::Select) { + if (d_.storage.state().present) eraseDialog_.reset(new DialogModel({"Cancel", "Erase"})); + else warn("No SD card"); + } + if (e.key == Key::Back) page_ = Page::Menu; + return true; +} + +bool SettingsApp::onAboutKey(const KeyEvent& e) { + if (e.key == Key::Back) page_ = Page::Menu; + if (e.key == Key::Char && e.ch == 'w') d_.apps.open("demo"); // hidden widget demo + return true; +} + +void SettingsApp::update(uint32_t nowMs) { + // Live values on Storage and About. + if ((page_ == Page::Storage || page_ == Page::About) && nowMs - lastRefreshMs_ >= 1000) { + lastRefreshMs_ = nowMs; + requestRedraw(); + } +} + +std::vector SettingsApp::storageLines() const { + auto s = d_.storage.state(); + std::vector lines; + char buf[64]; + if (d_.storage.formatting()) { + lines.push_back("Erasing the SD card..."); + } else if (!s.present) { + lines.push_back("No SD card inserted."); + } else { + snprintf(buf, sizeof(buf), "SD card: %u MB, %d%% used", static_cast(s.totalBytes >> 20), s.usedPercent); + lines.push_back(buf); + lines.push_back(s.logsAllowed ? "Logs: recording" : "Logs: paused (over 90%)"); + lines.push_back(s.capturesAllowed ? "Captures: allowed" : "Captures: stopped (card full)"); + } + lines.push_back(""); + lines.push_back("Enter: erase SD card"); + return lines; +} + +std::vector SettingsApp::aboutLines() const { + std::vector lines; + char buf[64]; + snprintf(buf, sizeof(buf), "%s %s", kProductName, versionString()); + lines.push_back(buf); + lines.push_back("Node " + identity::nodeId()); + const auto& b = d_.battery.estimator(); + snprintf(buf, sizeof(buf), "Battery %d%% (%d mV)", b.percent(), b.millivolts()); + lines.push_back(buf); + snprintf(buf, sizeof(buf), "Memory %u KB free (min %u KB)", ESP.getFreeHeap() / 1024, ESP.getMinFreeHeap() / 1024); + lines.push_back(buf); + uint32_t up = millis() / 1000; + snprintf(buf, sizeof(buf), "Uptime %luh %02lum %02lus", (unsigned long)(up / 3600), (unsigned long)(up / 60 % 60), + (unsigned long)(up % 60)); + lines.push_back(buf); + lines.push_back("Clock " + d_.clock.displayTime()); + lines.push_back("License GPL-3.0"); + return lines; +} + +void SettingsApp::draw(Canvas& c) { + const auto& area = theme::kContent; + switch (page_) { + case Page::Menu: + widgets::list( + c, list_, area, [this](int i) { return menu_.label(i); }, + [this](int i) { + auto kind = menu_.kind(i); + if (kind == Kind::Page) return std::string(">"); + if (kind == Kind::Slider) return "< " + menu_.value(i) + " >"; + return menu_.value(i); + }); + break; + case Page::Text: + c.setFont(&fonts::body); + c.setTextColor(theme::kMuted); + c.drawString(menu_.label(editingRow_).c_str(), 4, area.y + 4); + widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0}); + c.drawString(("Enter: save `: cancel " + std::to_string(editor_.text().size()) + "/" + + std::to_string(editor_.maxBytes()) + " bytes") + .c_str(), + 4, area.y + 44); + break; + case Page::Choice: { + auto options = menu_.choices(editingRow_); + int current = menu_.currentChoice(editingRow_); + widgets::list(c, choices_, area, [&](int i) { return (i == current ? "* " : " ") + options[i]; }); + break; + } + case Page::Storage: + widgets::textLines(c, storageLines(), 0, area); + if (eraseDialog_) + widgets::dialog(c, "Erase SD card?", "Everything on the card is deleted. It becomes one FAT32 partition.", + *eraseDialog_); + break; + case Page::About: widgets::textLines(c, aboutLines(), 0, area); break; + } +} + +} // namespace roro diff --git a/src/apps/settings_app.h b/src/apps/settings_app.h new file mode 100644 index 0000000..6a0c450 --- /dev/null +++ b/src/apps/settings_app.h @@ -0,0 +1,62 @@ +#pragma once + +#include +#include +#include + +#include "app.h" +#include "app_manager.h" +#include "dialog_model.h" +#include "event_bus.h" +#include "line_editor.h" +#include "list_model.h" +#include "services/battery_service.h" +#include "services/clock_service.h" +#include "services/storage_service.h" +#include "settings_menu.h" +#include "ui/theme.h" + +namespace roro { + +struct SettingsAppDeps { + Settings& settings; + EventBus& bus; + AppManager& apps; + BatteryService& battery; + StorageService& storage; + ClockService& clock; +}; + +// Settings: every user-facing setting, plus the Storage and About pages. +class SettingsApp : public App { + public: + explicit SettingsApp(const SettingsAppDeps& deps) : d_(deps), menu_(deps.settings) {} + void onEnter() override; + bool onKey(const KeyEvent& e) override; + void update(uint32_t nowMs) override; + void draw(Canvas& c) override; + + private: + enum class Page { Menu, Text, Choice, Storage, About }; + + bool onMenuKey(const KeyEvent& e); + bool onTextKey(const KeyEvent& e); + bool onChoiceKey(const KeyEvent& e); + bool onStorageKey(const KeyEvent& e); + bool onAboutKey(const KeyEvent& e); + void warn(const std::string& text); + std::vector storageLines() const; + std::vector aboutLines() const; + + SettingsAppDeps d_; + SettingsMenu menu_; + Page page_ = Page::Menu; + ListModel list_{theme::kContent.h / theme::kLineHeight}; + ListModel choices_{theme::kContent.h / theme::kLineHeight}; + LineEditor editor_{39}; + std::unique_ptr eraseDialog_; + int editingRow_ = 0; + uint32_t lastRefreshMs_ = 0; +}; + +} // namespace roro diff --git a/src/apps/setup_app.cpp b/src/apps/setup_app.cpp new file mode 100644 index 0000000..f967fa9 --- /dev/null +++ b/src/apps/setup_app.cpp @@ -0,0 +1,99 @@ +#include "setup_app.h" + +#include "platform/identity.h" +#include "ui/fonts.h" +#include "ui/widgets.h" + +namespace roro { + +using Step = SetupWizard::Step; + +SetupApp::SetupApp(Settings& settings, AppManager& apps) + : apps_(apps), wizard_(settings, identity::defaultLongName(), identity::defaultShortName()) {} + +bool SetupApp::onKey(const KeyEvent& e) { + requestRedraw(); + bool choosing = wizard_.choiceCount() > 0; + bool typing = wizard_.step() == Step::LongName || wizard_.step() == Step::ShortName; + switch (e.key) { + case Key::Select: + if (choosing) wizard_.selectChoice(choices_.selected()); + if (wizard_.next()) { + if (wizard_.finished()) { + apps_.endModal(); + return true; + } + choices_.setCount(wizard_.choiceCount()); + if (wizard_.choiceCount()) choices_.select(wizard_.selectedChoice()); + } + break; + case Key::Back: + case Key::Home: + wizard_.back(); + choices_.setCount(wizard_.choiceCount()); + if (wizard_.choiceCount()) choices_.select(wizard_.selectedChoice()); + break; + case Key::Up: if (choosing) choices_.up(); break; + case Key::Down: if (choosing) choices_.down(); break; + case Key::Left: if (typing) wizard_.editor().left(); break; + case Key::Right: if (typing) wizard_.editor().right(); break; + case Key::Delete: if (typing) wizard_.editor().backspace(); break; + case Key::Char: if (typing) wizard_.editor().insert(e.ch); break; + default: break; + } + return true; +} + +void SetupApp::draw(Canvas& c) { + const auto& area = theme::kContent; + auto heading = [&](const char* text) { + c.setFont(&fonts::bold); + c.setTextColor(theme::kAccent); + c.drawString(text, 4, area.y + 3); + c.setFont(&fonts::body); + }; + auto note = [&](const std::string& text, int y, uint16_t color = theme::kMuted) { + auto lines = wrapText(text, area.w - 8, widgets::bodyMeasure(c)); + c.setTextColor(color); + for (size_t i = 0; i < lines.size(); i++) c.drawString(lines[i].c_str(), 4, y + i * theme::kLineHeight); + }; + + switch (wizard_.step()) { + case Step::Welcome: + heading("Welcome to roro9stack"); + note("Let's set up this device: your names on the mesh, your radio region and your timezone.", + area.y + 22, theme::kText); + note("Enter: continue", area.y + area.h - 14); + break; + case Step::LongName: + heading("Long name (1/4)"); + note("Shown to other mesh users. Up to 39 bytes.", area.y + 20); + widgets::lineEditor(c, wizard_.editor(), {4, area.y + 50, area.w - 8, 0}); + break; + case Step::ShortName: + heading("Short name (2/4)"); + note("Up to 4 characters, shown where space is tight.", area.y + 20); + widgets::lineEditor(c, wizard_.editor(), {4, area.y + 50, area.w - 8, 0}); + break; + case Step::Region: + heading("Radio region (3/4)"); + note("The radio only transmits once you confirm your region.", area.y + 18); + widgets::list(c, choices_, {0, area.y + 48, area.w, 4 * theme::kLineHeight}, + [this](int i) { return wizard_.choiceLabel(i); }); + note("Enter: I confirm I'm in this region", area.y + area.h - 14); + break; + case Step::Timezone: + heading("Timezone (4/4)"); + widgets::list(c, choices_, {0, area.y + 20, area.w, 4 * theme::kLineHeight}, + [this](int i) { return wizard_.choiceLabel(i); }); + break; + case Step::Done: + heading("All set"); + note("You can change all of this later in Settings.", area.y + 22, theme::kText); + note("Enter: start `: go back", area.y + area.h - 14); + break; + } + if (!wizard_.error().empty()) note(wizard_.error(), area.y + area.h - 14, theme::kWarning); +} + +} // namespace roro diff --git a/src/apps/setup_app.h b/src/apps/setup_app.h new file mode 100644 index 0000000..4235998 --- /dev/null +++ b/src/apps/setup_app.h @@ -0,0 +1,24 @@ +#pragma once + +#include "app.h" +#include "app_manager.h" +#include "list_model.h" +#include "setup_wizard.h" +#include "ui/theme.h" + +namespace roro { + +// First-boot setup, run as a modal App until finished. +class SetupApp : public App { + public: + SetupApp(Settings& settings, AppManager& apps); + bool onKey(const KeyEvent& e) override; + void draw(Canvas& c) override; + + private: + AppManager& apps_; + SetupWizard wizard_; + ListModel choices_{4}; +}; + +} // namespace roro diff --git a/src/main.cpp b/src/main.cpp index 73bf9c3..c8bc766 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -3,8 +3,9 @@ #include "app_manager.h" #include "apps/demo_app.h" -#include "apps/diagnostics_app.h" #include "apps/launcher_app.h" +#include "apps/settings_app.h" +#include "apps/setup_app.h" #include "event_bus.h" #include "key_mapper.h" #include "platform/nvs_store.h" @@ -54,8 +55,7 @@ static RawKeys readKeys() { static StatusInfo currentStatus() { StatusInfo s; s.title = kProductName; - for (auto* info : apps->visibleApps()) - if (info->app == &apps->foreground()) s.title = info->title; + if (apps->foregroundTitle()) s.title = apps->foregroundTitle(); if (battery->estimator().hasReading()) s.batteryPercent = battery->estimator().percent(); s.clock = clockService->displayTime(); auto storageState = storage->state(); @@ -87,15 +87,17 @@ void setup() { apps = new AppManager(launcher); launcher.setManager(*apps); - apps->registerApp({"demo", "Widget demo", false, new DemoApp(bus)}); - apps->registerApp({"diagnostics", "Diagnostics", false, - new DiagnosticsApp(settings, *battery, *storage, *clockService)}); + apps->registerApp({"settings", "Settings", false, + new SettingsApp({settings, bus, *apps, *battery, *storage, *clockService})}); + apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)}); + apps->registerApp({"setup", "Setup", true, new SetupApp(settings, *apps)}); bus.subscribe(EventType::Notification, [](const Event& e) { Serial.printf("notification: %s\n", e.text); }); if (!screen.begin()) Serial.println("frame buffer allocation failed"); services.startAll(millis()); apps->begin(); + if (!settings.getBool(Setting::SetupDone)) apps->openModal("setup"); Serial.printf("%s %s ready, free heap %u\n", kProductName, versionString(), ESP.getFreeHeap()); } diff --git a/src/platform/identity.h b/src/platform/identity.h new file mode 100644 index 0000000..e40a783 --- /dev/null +++ b/src/platform/identity.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +#include +#include + +namespace roro::identity { + +// Node number as Meshtastic derives it: the last four bytes of the chip's MAC address. +inline uint32_t nodeNum() { + uint64_t mac = ESP.getEfuseMac(); // byte 0 of the MAC is the lowest byte + auto byte = [mac](int i) { return static_cast((mac >> (8 * i)) & 0xFF); }; + return (byte(2) << 24) | (byte(3) << 16) | (byte(4) << 8) | byte(5); +} + +// "!2f762fa4" +inline std::string nodeId() { + char buf[12]; + std::snprintf(buf, sizeof(buf), "!%08lx", static_cast(nodeNum())); + return buf; +} + +// Suggested names for the setup wizard: "roro9stack 2fa4" and "2fa4". +inline std::string defaultShortName() { + char buf[8]; + std::snprintf(buf, sizeof(buf), "%04lx", static_cast(nodeNum() & 0xFFFF)); + return buf; +} +inline std::string defaultLongName() { return "roro9stack " + defaultShortName(); } + +} // namespace roro::identity diff --git a/src/ui/widgets.cpp b/src/ui/widgets.cpp index c826765..6d5c631 100644 --- a/src/ui/widgets.cpp +++ b/src/ui/widgets.cpp @@ -36,7 +36,8 @@ void statusBar(Canvas& c, const StatusInfo& info) { c.setTextDatum(top_left); } -void list(Canvas& c, const ListModel& model, Rect area, const std::function& label) { +void list(Canvas& c, const ListModel& model, Rect area, const std::function& label, + const std::function& value) { c.setFont(&fonts::body); c.setTextDatum(top_left); int rows = model.visibleRows(); @@ -48,6 +49,12 @@ void list(Canvas& c, const ListModel& model, Rect area, const std::function rows) { diff --git a/src/ui/widgets.h b/src/ui/widgets.h index de94868..93894da 100644 --- a/src/ui/widgets.h +++ b/src/ui/widgets.h @@ -36,8 +36,9 @@ MeasureText bodyMeasure(Canvas& canvas); void statusBar(Canvas& c, const StatusInfo& info); -// A selectable list; `label(i)` gives row i's text. -void list(Canvas& c, const ListModel& model, theme::Rect area, const std::function& label); +// A selectable list; `label(i)` gives row i's text and `value(i)`, if given, a right-aligned value. +void list(Canvas& c, const ListModel& model, theme::Rect area, const std::function& label, + const std::function& value = nullptr); // Pre-wrapped lines starting at `firstLine`, with a scroll indicator when they don't all fit. void textLines(Canvas& c, const std::vector& lines, int firstLine, theme::Rect area); diff --git a/test/memory_store.h b/test/memory_store.h new file mode 100644 index 0000000..3fc2039 --- /dev/null +++ b/test/memory_store.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +#include "key_value_store.h" + +// In-memory KeyValueStore for tests. +struct MemoryStore : roro::KeyValueStore { + std::map ints; + std::map strings; + bool getInt(const char* key, int32_t& out) override { + auto it = ints.find(key); + if (it == ints.end()) return false; + out = it->second; + return true; + } + bool getString(const char* key, std::string& out) override { + auto it = strings.find(key); + if (it == strings.end()) return false; + out = it->second; + return true; + } + void putInt(const char* key, int32_t v) override { ints[key] = v; } + void putString(const char* key, const std::string& v) override { strings[key] = v; } +}; diff --git a/test/test_app_manager/test_app_manager.cpp b/test/test_app_manager/test_app_manager.cpp index d412381..f2e869f 100644 --- a/test/test_app_manager/test_app_manager.cpp +++ b/test/test_app_manager/test_app_manager.cpp @@ -155,6 +155,32 @@ void test_update_reaches_only_the_foreground_app() { TEST_ASSERT_EQUAL(0, f.launcher.lastUpdate); } +void test_modal_app_keeps_home_and_back() { + Fixture f; + f.manager.openModal("demo"); + f.manager.handleKey(KeyEvent::of(Key::Home)); + f.manager.handleKey(KeyEvent::of(Key::Back)); + TEST_ASSERT_EQUAL_PTR(&f.demo, &f.manager.foreground()); + TEST_ASSERT_EQUAL(2, f.demo.keys.size()); // both reached the App +} + +void test_ending_the_modal_returns_to_launcher_and_unlocks_home() { + Fixture f; + f.manager.openModal("demo"); + f.manager.endModal(); + TEST_ASSERT_EQUAL_PTR(&f.launcher, &f.manager.foreground()); + f.manager.open("notes"); + f.manager.handleKey(KeyEvent::of(Key::Home)); + TEST_ASSERT_EQUAL_PTR(&f.launcher, &f.manager.foreground()); +} + +void test_modal_app_cannot_be_switched_away_by_open() { + Fixture f; + f.manager.openModal("demo"); + TEST_ASSERT_FALSE(f.manager.open("notes")); + TEST_ASSERT_EQUAL_PTR(&f.demo, &f.manager.foreground()); +} + int main() { UNITY_BEGIN(); RUN_TEST(test_launcher_is_in_foreground_after_begin); @@ -170,5 +196,8 @@ int main() { RUN_TEST(test_switching_apps_requests_a_redraw); RUN_TEST(test_app_can_request_its_own_redraw); RUN_TEST(test_update_reaches_only_the_foreground_app); + RUN_TEST(test_modal_app_keeps_home_and_back); + RUN_TEST(test_ending_the_modal_returns_to_launcher_and_unlocks_home); + RUN_TEST(test_modal_app_cannot_be_switched_away_by_open); return UNITY_END(); } diff --git a/test/test_settings/test_settings.cpp b/test/test_settings/test_settings.cpp index 49010c1..41b5bca 100644 --- a/test/test_settings/test_settings.cpp +++ b/test/test_settings/test_settings.cpp @@ -4,29 +4,12 @@ #include #include +#include "../memory_store.h" #include "event_bus.h" #include "settings.h" using namespace roro; -struct MemoryStore : KeyValueStore { - std::map ints; - std::map strings; - bool getInt(const char* key, int32_t& out) override { - auto it = ints.find(key); - if (it == ints.end()) return false; - out = it->second; - return true; - } - bool getString(const char* key, std::string& out) override { - auto it = strings.find(key); - if (it == strings.end()) return false; - out = it->second; - return true; - } - void putInt(const char* key, int32_t v) override { ints[key] = v; } - void putString(const char* key, const std::string& v) override { strings[key] = v; } -}; void setUp() {} void tearDown() {} diff --git a/test/test_settings_menu/test_settings_menu.cpp b/test/test_settings_menu/test_settings_menu.cpp new file mode 100644 index 0000000..3bc0326 --- /dev/null +++ b/test/test_settings_menu/test_settings_menu.cpp @@ -0,0 +1,113 @@ +#include + +#include "../memory_store.h" +#include "event_bus.h" +#include "settings_menu.h" + +using namespace roro; + +struct Fixture { + MemoryStore store; + EventBus bus; + Settings settings{store, bus}; + SettingsMenu menu{settings}; + Fixture() { settings.load(); } + int row(SettingsMenu::Row r) { + for (int i = 0; i < menu.count(); i++) + if (menu.row(i) == r) return i; + return -1; + } +}; + +void setUp() {} +void tearDown() {} + +void test_every_row_has_a_label() { + Fixture f; + TEST_ASSERT_TRUE(f.menu.count() > 0); + for (int i = 0; i < f.menu.count(); i++) TEST_ASSERT_FALSE(f.menu.label(i).empty()); +} + +void test_values_are_human_readable() { + Fixture f; + TEST_ASSERT_EQUAL_STRING("60%", f.menu.value(f.row(SettingsMenu::Row::Brightness)).c_str()); + TEST_ASSERT_EQUAL_STRING("30 s", f.menu.value(f.row(SettingsMenu::Row::DimTimeout)).c_str()); + TEST_ASSERT_EQUAL_STRING("1 min", f.menu.value(f.row(SettingsMenu::Row::OffTimeout)).c_str()); + TEST_ASSERT_EQUAL_STRING("On", f.menu.value(f.row(SettingsMenu::Row::Sound)).c_str()); + TEST_ASSERT_EQUAL_STRING("Brussels", f.menu.value(f.row(SettingsMenu::Row::Timezone)).c_str()); + TEST_ASSERT_EQUAL_STRING("Raw", f.menu.value(f.row(SettingsMenu::Row::ProbeMacs)).c_str()); +} + +void test_toggle_rows_flip_on_select() { + Fixture f; + int sound = f.row(SettingsMenu::Row::Sound); + TEST_ASSERT_EQUAL(static_cast(SettingsMenu::Kind::Toggle), static_cast(f.menu.kind(sound))); + f.menu.toggle(sound); + TEST_ASSERT_FALSE(f.settings.getBool(Setting::Sound)); +} + +void test_choice_rows_list_options_and_mark_the_current_one() { + Fixture f; + int tz = f.row(SettingsMenu::Row::Timezone); + TEST_ASSERT_EQUAL(static_cast(SettingsMenu::Kind::Choice), static_cast(f.menu.kind(tz))); + auto options = f.menu.choices(tz); + TEST_ASSERT_TRUE(options.size() >= 3); + TEST_ASSERT_EQUAL_STRING("Brussels", options[f.menu.currentChoice(tz)].c_str()); +} + +void test_choosing_applies_the_setting() { + Fixture f; + int tz = f.row(SettingsMenu::Row::Timezone); + auto options = f.menu.choices(tz); + int utc = -1; + for (size_t i = 0; i < options.size(); i++) + if (options[i] == "UTC") utc = static_cast(i); + TEST_ASSERT_TRUE(f.menu.choose(tz, utc).empty()); + TEST_ASSERT_EQUAL_STRING("UTC0", f.settings.getString(Setting::Timezone).c_str()); +} + +void test_choice_that_breaks_a_rule_is_refused_with_a_reason() { + Fixture f; + int dim = f.row(SettingsMenu::Row::DimTimeout); + auto options = f.menu.choices(dim); + int fiveMinutes = -1; + for (size_t i = 0; i < options.size(); i++) + if (options[i] == "5 min") fiveMinutes = static_cast(i); + TEST_ASSERT_TRUE(fiveMinutes >= 0); + std::string error = f.menu.choose(dim, fiveMinutes); // screen off is 1 min + TEST_ASSERT_FALSE(error.empty()); + TEST_ASSERT_EQUAL(30, f.settings.getInt(Setting::DimTimeoutS)); +} + +void test_brightness_steps_with_left_right_within_bounds() { + Fixture f; + int b = f.row(SettingsMenu::Row::Brightness); + TEST_ASSERT_EQUAL(static_cast(SettingsMenu::Kind::Slider), static_cast(f.menu.kind(b))); + f.menu.adjust(b, +1); + TEST_ASSERT_EQUAL(70, f.settings.getInt(Setting::Brightness)); + for (int i = 0; i < 10; i++) f.menu.adjust(b, -1); + TEST_ASSERT_EQUAL(10, f.settings.getInt(Setting::Brightness)); +} + +void test_names_are_text_rows_with_their_byte_limits() { + Fixture f; + int shortName = f.row(SettingsMenu::Row::ShortName); + TEST_ASSERT_EQUAL(static_cast(SettingsMenu::Kind::Text), static_cast(f.menu.kind(shortName))); + TEST_ASSERT_EQUAL(4, f.menu.maxBytes(shortName)); + TEST_ASSERT_FALSE(f.menu.setText(shortName, "TOOLONG").empty()); + TEST_ASSERT_TRUE(f.menu.setText(shortName, "RORO").empty()); + TEST_ASSERT_EQUAL_STRING("RORO", f.menu.value(shortName).c_str()); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_every_row_has_a_label); + RUN_TEST(test_values_are_human_readable); + RUN_TEST(test_toggle_rows_flip_on_select); + RUN_TEST(test_choice_rows_list_options_and_mark_the_current_one); + RUN_TEST(test_choosing_applies_the_setting); + RUN_TEST(test_choice_that_breaks_a_rule_is_refused_with_a_reason); + RUN_TEST(test_brightness_steps_with_left_right_within_bounds); + RUN_TEST(test_names_are_text_rows_with_their_byte_limits); + return UNITY_END(); +} diff --git a/test/test_setup_wizard/test_setup_wizard.cpp b/test/test_setup_wizard/test_setup_wizard.cpp new file mode 100644 index 0000000..cf2116f --- /dev/null +++ b/test/test_setup_wizard/test_setup_wizard.cpp @@ -0,0 +1,121 @@ +#include + +#include "../memory_store.h" +#include "event_bus.h" +#include "setup_wizard.h" + +using namespace roro; + +struct Fixture { + MemoryStore store; + EventBus bus; + Settings settings{store, bus}; + SetupWizard wizard{settings, "roro9stack 2fa4", "2fa4"}; + Fixture() { settings.load(); } +}; + +using Step = SetupWizard::Step; + +void setUp() {} +void tearDown() {} + +static void assertStep(Step expected, const SetupWizard& w) { + TEST_ASSERT_EQUAL(static_cast(expected), static_cast(w.step())); +} + +void test_walks_through_every_step_with_defaults() { + Fixture f; + assertStep(Step::Welcome, f.wizard); + TEST_ASSERT_TRUE(f.wizard.next()); + assertStep(Step::LongName, f.wizard); + TEST_ASSERT_EQUAL_STRING("roro9stack 2fa4", f.wizard.editor().text().c_str()); + TEST_ASSERT_TRUE(f.wizard.next()); + assertStep(Step::ShortName, f.wizard); + TEST_ASSERT_EQUAL_STRING("2fa4", f.wizard.editor().text().c_str()); + TEST_ASSERT_TRUE(f.wizard.next()); + assertStep(Step::Region, f.wizard); + TEST_ASSERT_TRUE(f.wizard.next()); + assertStep(Step::Timezone, f.wizard); + TEST_ASSERT_TRUE(f.wizard.next()); + assertStep(Step::Done, f.wizard); + TEST_ASSERT_FALSE(f.wizard.finished()); + TEST_ASSERT_TRUE(f.wizard.next()); + TEST_ASSERT_TRUE(f.wizard.finished()); +} + +void test_nothing_is_saved_until_the_end() { + Fixture f; + for (int i = 0; i < 5; i++) f.wizard.next(); + TEST_ASSERT_FALSE(f.settings.getBool(Setting::SetupDone)); + TEST_ASSERT_FALSE(f.settings.getBool(Setting::RegionConfirmed)); + TEST_ASSERT_TRUE(f.store.ints.empty() && f.store.strings.empty()); +} + +void test_finishing_saves_names_region_timezone_and_marks_setup_done() { + Fixture f; + f.wizard.next(); + f.wizard.editor().setText("Cl\xC3\xA9ment"); + f.wizard.next(); + f.wizard.editor().setText("CLEM"); + f.wizard.next(); + f.wizard.next(); // region + f.wizard.selectChoice(1); // second timezone + f.wizard.next(); + f.wizard.next(); + TEST_ASSERT_EQUAL_STRING("Cl\xC3\xA9ment", f.settings.getString(Setting::LongName).c_str()); + TEST_ASSERT_EQUAL_STRING("CLEM", f.settings.getString(Setting::ShortName).c_str()); + TEST_ASSERT_EQUAL_STRING("EU868", f.settings.getString(Setting::Region).c_str()); + TEST_ASSERT_TRUE(f.settings.getBool(Setting::RegionConfirmed)); + TEST_ASSERT_TRUE(f.settings.getBool(Setting::SetupDone)); + TEST_ASSERT_TRUE(f.settings.getString(Setting::Timezone) != "CET-1CEST,M3.5.0,M10.5.0/3"); +} + +void test_empty_name_cannot_advance_and_explains_why() { + Fixture f; + f.wizard.next(); + f.wizard.editor().setText(""); + TEST_ASSERT_FALSE(f.wizard.next()); + assertStep(Step::LongName, f.wizard); + TEST_ASSERT_FALSE(f.wizard.error().empty()); +} + +void test_short_name_editor_is_limited_to_four_bytes() { + Fixture f; + f.wizard.next(); + f.wizard.next(); + TEST_ASSERT_EQUAL(4, f.wizard.editor().maxBytes()); +} + +void test_back_returns_to_the_previous_step_keeping_input() { + Fixture f; + f.wizard.next(); + f.wizard.editor().setText("Alice"); + f.wizard.next(); + f.wizard.back(); + assertStep(Step::LongName, f.wizard); + TEST_ASSERT_EQUAL_STRING("Alice", f.wizard.editor().text().c_str()); + f.wizard.back(); + f.wizard.back(); + assertStep(Step::Welcome, f.wizard); +} + +void test_region_step_offers_eu868() { + Fixture f; + f.wizard.next(); + f.wizard.next(); + f.wizard.next(); + TEST_ASSERT_EQUAL(1, f.wizard.choiceCount()); + TEST_ASSERT_TRUE(f.wizard.choiceLabel(0).find("EU868") != std::string::npos); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_walks_through_every_step_with_defaults); + RUN_TEST(test_nothing_is_saved_until_the_end); + RUN_TEST(test_finishing_saves_names_region_timezone_and_marks_setup_done); + RUN_TEST(test_empty_name_cannot_advance_and_explains_why); + RUN_TEST(test_short_name_editor_is_limited_to_four_bytes); + RUN_TEST(test_back_returns_to_the_previous_step_keeping_input); + RUN_TEST(test_region_step_offers_eu868); + return UNITY_END(); +}