From add52d34f5e85f1d0ba04e64320e8925e56ef77d Mon Sep 17 00:00:00 2001 From: mrsqr Date: Wed, 19 Aug 2026 23:12:12 +0100 Subject: [PATCH] feat(ui): set time settings on a two-column minutes-and-seconds roller The preset pickers could not reach most of the values their own fields hold. Average lap offered eleven choices stopping at 3:00 against a field validated to 59:59, so a circuit with a longer lap could not be configured at all; session and rest duration stopped at 60 minutes against a validated 24 hours. The lists could not simply be extended either, since kValueChoiceCapacity is twelve. Both columns are live at once. The vertical gesture acts on whichever column it starts over, so minutes and seconds are adjusted without a focus to switch between them. Columns wrap within themselves and never carry: a carry is unpredictable when the two are targeted independently, and a flick that rolls seconds several times round would otherwise drag minutes with it. A discrete swipe per step would have needed 59 gestures to cross a column, which is worse than the single press it replaces, so the screen tracks the pointer continuously and a flick coasts with exponential decay. That needed a continuous path through the input layer, which until now deliberately reduced touch to named actions. The discrete contract is untouched and everything else still uses it. Saving is a measured 1200 ms hold rather than LVGL's 400 ms long press. On the device that threshold committed a value 1.4 seconds after the screen opened, before anything had been rolled. The hold is cancelled if the finger wanders more than 14 px, and a bar fills as it counts, because a longer hold with no feedback reads as an unresponsive screen rather than a deliberate one. Cancelling is a horizontal swipe: down now drives the digits and can no longer mean back. Durations are stored in seconds, which needed a settings v5 and a migration, since 1439 minutes of seconds does not fit the uint16 they used to occupy. Everything before v5 held whole minutes, so migrating is exact. The log record mirrors those fields, so kLogFormatVersion goes to 2 as well and older logs are rejected by version rather than silently misread as very short sessions; that consumes the bump #137 was waiting for. Fifteen host tests cover the ranges, wrap without carry, drag accumulation, flick decay and settling, catching a coasting column, clamping to a field's floor on commit, the zero-means-unset coupling to lower display, and the hold threshold and its wander cancel. Verified on the panel across a 175-sample editing session: column targeting follows the touch, minutes wrapped 28 to 0 while seconds held at :05, and the session ended in exactly one deliberate save with no accidental commits. Closes #149 Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 3 + CHANGELOG.md | 8 + Makefile | 34 +- firmware/components/logger/formats.cpp | 8 +- .../include/track_timer/logger/formats.hpp | 9 +- firmware/components/settings/component.cpp | 85 +++-- .../include/track_timer/settings/settings.hpp | 16 +- firmware/components/ui/CMakeLists.txt | 2 +- firmware/components/ui/active_session.cpp | 2 +- firmware/components/ui/gesture_input.cpp | 85 +++++ .../include/track_timer/ui/active_session.hpp | 2 +- .../include/track_timer/ui/gesture_input.hpp | 22 ++ .../ui/include/track_timer/ui/input.hpp | 1 + .../ui/include/track_timer/ui/presenter.hpp | 4 +- .../ui/include/track_timer/ui/time_roller.hpp | 120 +++++++ .../track_timer/ui/time_roller_screen.hpp | 67 ++++ firmware/components/ui/input.cpp | 2 + firmware/components/ui/presenter.cpp | 13 +- firmware/components/ui/settings_editor.cpp | 18 +- firmware/components/ui/shell_navigation.cpp | 3 + firmware/components/ui/time_roller.cpp | 269 ++++++++++++++++ firmware/components/ui/time_roller_screen.cpp | 259 +++++++++++++++ firmware/components/ui/value_picker.cpp | 54 +--- firmware/main/screen_router.cpp | 171 +++++++++- simulator/CMakeLists.txt | 1 + simulator/src/main.cpp | 12 +- tests/cpp/test_application_screen.cpp | 6 +- tests/cpp/test_device_mode.cpp | 56 ++-- tests/cpp/test_log_formats.cpp | 8 +- tests/cpp/test_navigation.cpp | 9 +- tests/cpp/test_settings.cpp | 25 +- tests/cpp/test_settings_editor.cpp | 18 +- tests/cpp/test_time_roller.cpp | 304 ++++++++++++++++++ tests/cpp/test_track_catalog.cpp | 4 +- 34 files changed, 1528 insertions(+), 172 deletions(-) create mode 100644 firmware/components/ui/include/track_timer/ui/time_roller.hpp create mode 100644 firmware/components/ui/include/track_timer/ui/time_roller_screen.hpp create mode 100644 firmware/components/ui/time_roller.cpp create mode 100644 firmware/components/ui/time_roller_screen.cpp create mode 100644 tests/cpp/test_time_roller.cpp diff --git a/.gitignore b/.gitignore index 0e0ce73..c868732 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ Thumbs.db *.swp *.swo *~ + +# Core dumps, which a failing host test binary drops in the repository root. +core.* diff --git a/CHANGELOG.md b/CHANGELOG.md index 68c3526..66adab3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ The project follows Semantic Versioning once the first firmware release is tagge ### Fixed +- time settings could not reach most of their own range: the average lap picker stopped at + 3:00 against a field holding 59:59, and the durations at 60 minutes against 24 hours +- saving on the roller used LVGL's 400 ms long press, which committed a value while it was + still being chosen + - the G meter subtracted a gravity reference frozen at calibration, so every later change of tilt read as acceleration at sin(angle) - 0.17 g on a 10 degree banked corner - and peak-hold latched those artefacts rather than averaging them away; attitude is now @@ -45,6 +50,9 @@ The project follows Semantic Versioning once the first firmware release is tagge - vertical G alongside the lateral and longitudinal pair, for kerbs and compressions - gyroscope zero-rate offset measured at rest and removed, 4.4 dps on this board - device settings persisted in NVS, so Mode survives a reboot +- two-column minutes-and-seconds roller for average lap, session and rest duration, with + drag, flick momentum, and a deliberate hold to save +- session and rest durations stored as seconds rather than whole minutes - Track Day running-session screen: countdown, estimated laps as a float, and a decaying session bar - five-band countdown colour ramp blending proportional and absolute thresholds diff --git a/Makefile b/Makefile index 39fa622..f104aa8 100644 --- a/Makefile +++ b/Makefile @@ -41,15 +41,16 @@ SHELL_NAVIGATION_TEST_BINARY := build/host/shell_navigation_test DEVICE_MODE_TEST_BINARY := build/host/device_mode_test IMU_CALIBRATION_TEST_BINARY := build/host/imu_calibration_test SESSION_URGENCY_TEST_BINARY := build/host/session_urgency_test +TIME_ROLLER_TEST_BINARY := build/host/time_roller_test GATE_CAPTURE_TEST_BINARY := build/host/gate_capture_test GATE_SESSION_AUTOMATION_TEST_BINARY := build/host/gate_session_automation_test SIMULATOR_BUILD_DIR ?= build/simulator SIMULATOR_IMAGE ?= track-session-timer-simulator:lvgl-9.5.0 CMAKE ?= cmake -.PHONY: check test track-validate uk-track-pack track-pack-test simulator-track-catalog-test simulator-fixture-validate repo-check host-test simulator-model-test session-state-test settings-test settings-editor-test projection-test intersection-test crossing-validation-test crossing-time-test lap-state-machine-test timing-engine-test gate-event-engine-test track-definition-test track-capture-test track-matching-test track-selection-test log-format-test async-logger-test session-review-test diagnostics-test active-session-test display-policy-test imu-meter-test rest-session-test track-catalog-test shell-navigation-test device-mode-test imu-calibration-test session-urgency-test gate-capture-test gate-session-automation-test ui-foundation-test navigation-test simulator-configure simulator-build simulator-test simulator-run simulator-container-image simulator-container-test simulator-clean firmware-build firmware-container-build firmware-container-flash firmware-container-monitor firmware-container-flash-monitor firmware-container-erase firmware-device-info firmware-clean issue-preview label-preview +.PHONY: check test track-validate uk-track-pack track-pack-test simulator-track-catalog-test simulator-fixture-validate repo-check host-test simulator-model-test session-state-test settings-test settings-editor-test projection-test intersection-test crossing-validation-test crossing-time-test lap-state-machine-test timing-engine-test gate-event-engine-test track-definition-test track-capture-test track-matching-test track-selection-test log-format-test async-logger-test session-review-test diagnostics-test active-session-test display-policy-test imu-meter-test rest-session-test track-catalog-test shell-navigation-test device-mode-test imu-calibration-test session-urgency-test time-roller-test gate-capture-test gate-session-automation-test ui-foundation-test navigation-test simulator-configure simulator-build simulator-test simulator-run simulator-container-image simulator-container-test simulator-clean firmware-build firmware-container-build firmware-container-flash firmware-container-monitor firmware-container-flash-monitor firmware-container-erase firmware-device-info firmware-clean issue-preview label-preview -check: test track-validate track-pack-test simulator-track-catalog-test simulator-fixture-validate repo-check host-test simulator-model-test session-state-test settings-test settings-editor-test projection-test intersection-test crossing-validation-test crossing-time-test lap-state-machine-test timing-engine-test gate-event-engine-test track-definition-test track-capture-test track-matching-test track-selection-test log-format-test async-logger-test session-review-test diagnostics-test active-session-test display-policy-test imu-meter-test rest-session-test track-catalog-test shell-navigation-test device-mode-test imu-calibration-test session-urgency-test gate-capture-test gate-session-automation-test ui-foundation-test navigation-test +check: test track-validate track-pack-test simulator-track-catalog-test simulator-fixture-validate repo-check host-test simulator-model-test session-state-test settings-test settings-editor-test projection-test intersection-test crossing-validation-test crossing-time-test lap-state-machine-test timing-engine-test gate-event-engine-test track-definition-test track-capture-test track-matching-test track-selection-test log-format-test async-logger-test session-review-test diagnostics-test active-session-test display-policy-test imu-meter-test rest-session-test track-catalog-test shell-navigation-test device-mode-test imu-calibration-test session-urgency-test time-roller-test gate-capture-test gate-session-automation-test ui-foundation-test navigation-test test: $(PYTHON) -B -m unittest discover -s tests -p 'test_*.py' @@ -99,11 +100,12 @@ simulator-model-test: -Ifirmware/components/board/include \ -Ifirmware/components/domain/include \ -Ifirmware/components/logger/include \ + -Ifirmware/components/settings/include \ -Ifirmware/components/ui/include \ -Isimulator/include \ firmware/components/logger/async_logger.cpp \ firmware/components/ui/foundation.cpp firmware/components/ui/navigation.cpp \ - firmware/components/ui/presenter.cpp \ + firmware/components/ui/presenter.cpp firmware/components/ui/time_roller.cpp \ simulator/src/device_backends.cpp \ simulator/src/fixed_cell_text.cpp \ simulator/src/scenario.cpp \ @@ -135,7 +137,7 @@ settings-editor-test: -Ifirmware/components/settings/include \ -Ifirmware/components/ui/include \ firmware/components/settings/component.cpp \ - firmware/components/ui/settings_editor.cpp \ + firmware/components/ui/settings_editor.cpp firmware/components/ui/time_roller.cpp \ tests/cpp/test_settings_editor.cpp -o $(SETTINGS_EDITOR_TEST_BINARY) $(SETTINGS_EDITOR_TEST_BINARY) @@ -314,10 +316,11 @@ active-session-test: mkdir -p build/host $(CXX) -std=c++17 -Wall -Wextra -Werror -pedantic \ -Ifirmware/components/domain/include \ + -Ifirmware/components/settings/include \ -Ifirmware/components/ui/include \ firmware/components/ui/active_session.cpp \ firmware/components/ui/foundation.cpp \ - firmware/components/ui/presenter.cpp \ + firmware/components/ui/presenter.cpp firmware/components/ui/time_roller.cpp \ tests/cpp/test_active_session.cpp -o $(ACTIVE_SESSION_TEST_BINARY) $(ACTIVE_SESSION_TEST_BINARY) @@ -420,14 +423,25 @@ gate-session-automation-test: -o $(GATE_SESSION_AUTOMATION_TEST_BINARY) $(GATE_SESSION_AUTOMATION_TEST_BINARY) +time-roller-test: + mkdir -p build/host + $(CXX) -std=c++17 -Wall -Wextra -Werror -pedantic \ + -Ifirmware/components/settings/include \ + -Ifirmware/components/ui/include \ + firmware/components/settings/component.cpp \ + firmware/components/ui/time_roller.cpp \ + tests/cpp/test_time_roller.cpp -o $(TIME_ROLLER_TEST_BINARY) + $(TIME_ROLLER_TEST_BINARY) + session-urgency-test: mkdir -p build/host $(CXX) -std=c++17 -Wall -Wextra -Werror -pedantic \ -Ifirmware/components/domain/include \ + -Ifirmware/components/settings/include \ -Ifirmware/components/ui/include \ firmware/components/ui/active_session.cpp \ firmware/components/ui/foundation.cpp \ - firmware/components/ui/presenter.cpp \ + firmware/components/ui/presenter.cpp firmware/components/ui/time_roller.cpp \ tests/cpp/test_session_urgency.cpp -o $(SESSION_URGENCY_TEST_BINARY) $(SESSION_URGENCY_TEST_BINARY) @@ -449,7 +463,7 @@ device-mode-test: -Ifirmware/components/ui/include \ firmware/components/settings/component.cpp \ firmware/components/ui/device_mode.cpp \ - firmware/components/ui/value_picker.cpp \ + firmware/components/ui/value_picker.cpp firmware/components/ui/time_roller.cpp \ tests/cpp/test_device_mode.cpp -o $(DEVICE_MODE_TEST_BINARY) $(DEVICE_MODE_TEST_BINARY) @@ -468,9 +482,10 @@ ui-foundation-test: mkdir -p build/host $(CXX) -std=c++17 -Wall -Wextra -Werror -pedantic \ -Ifirmware/components/domain/include \ + -Ifirmware/components/settings/include \ -Ifirmware/components/ui/include \ firmware/components/ui/foundation.cpp \ - firmware/components/ui/presenter.cpp \ + firmware/components/ui/presenter.cpp firmware/components/ui/time_roller.cpp \ tests/cpp/test_ui_foundation.cpp -o $(UI_FOUNDATION_TEST_BINARY) $(UI_FOUNDATION_TEST_BINARY) @@ -478,10 +493,11 @@ navigation-test: mkdir -p build/host $(CXX) -std=c++17 -Wall -Wextra -Werror -pedantic \ -Ifirmware/components/domain/include \ + -Ifirmware/components/settings/include \ -Ifirmware/components/ui/include \ firmware/components/ui/foundation.cpp \ firmware/components/ui/navigation.cpp \ - firmware/components/ui/presenter.cpp \ + firmware/components/ui/presenter.cpp firmware/components/ui/time_roller.cpp \ tests/cpp/test_navigation.cpp -o $(NAVIGATION_TEST_BINARY) $(NAVIGATION_TEST_BINARY) diff --git a/firmware/components/logger/formats.cpp b/firmware/components/logger/formats.cpp index f945ca4..9f5793e 100644 --- a/firmware/components/logger/formats.cpp +++ b/firmware/components/logger/formats.cpp @@ -114,8 +114,8 @@ bool valid_settings_record(const SessionSettingsV1& record) noexcept } settings::DeviceSettings source{}; - source.session_duration_minutes = record.session_duration_minutes; - source.rest_duration_minutes = record.rest_duration_minutes; + source.session_duration_seconds = record.session_duration_seconds; + source.rest_duration_seconds = record.rest_duration_seconds; source.launch_sensitivity_milli_g = record.launch_sensitivity_milli_g; source.average_lap_seconds = record.average_lap_seconds; source.day_brightness_percent = record.day_brightness_percent; @@ -137,8 +137,8 @@ bool valid_settings_record(const SessionSettingsV1& record) noexcept SessionSettingsV1 make_session_settings(const settings::DeviceSettings& source) noexcept { SessionSettingsV1 result{}; - result.session_duration_minutes = source.session_duration_minutes; - result.rest_duration_minutes = source.rest_duration_minutes; + result.session_duration_seconds = source.session_duration_seconds; + result.rest_duration_seconds = source.rest_duration_seconds; result.launch_sensitivity_milli_g = source.launch_sensitivity_milli_g; result.average_lap_seconds = source.average_lap_seconds; result.day_brightness_percent = source.day_brightness_percent; diff --git a/firmware/components/logger/include/track_timer/logger/formats.hpp b/firmware/components/logger/include/track_timer/logger/formats.hpp index e543fb8..3d53d2a 100644 --- a/firmware/components/logger/include/track_timer/logger/formats.hpp +++ b/firmware/components/logger/include/track_timer/logger/formats.hpp @@ -11,7 +11,10 @@ namespace track_timer::logger { -inline constexpr std::uint16_t kLogFormatVersion = 1; +// 2: session and rest durations widened from uint16 minutes to uint32 seconds, which +// changes both the meaning and the size of every meta record. Logs written before this +// are rejected by their version rather than silently misread as very short sessions. +inline constexpr std::uint16_t kLogFormatVersion = 2; inline constexpr std::int64_t kUnavailableUtcNs = -1; inline constexpr std::size_t kSessionIdentifierCapacity = 32; inline constexpr std::size_t kFirmwareCommitCapacity = 41; @@ -79,8 +82,8 @@ enum DegradedSubsystemFlag : std::uint32_t { struct SessionSettingsV1 { std::uint16_t settings_schema_version{settings::kCurrentSettingsVersion}; - std::uint16_t session_duration_minutes{0}; - std::uint16_t rest_duration_minutes{0}; + std::uint32_t session_duration_seconds{0}; + std::uint32_t rest_duration_seconds{0}; std::uint16_t launch_sensitivity_milli_g{0}; std::uint16_t average_lap_seconds{0}; std::uint8_t day_brightness_percent{0}; diff --git a/firmware/components/settings/component.cpp b/firmware/components/settings/component.cpp index 1e5b246..3f96e66 100644 --- a/firmware/components/settings/component.cpp +++ b/firmware/components/settings/component.cpp @@ -12,7 +12,14 @@ constexpr std::size_t kHeaderSize = 12; constexpr std::size_t kLegacyV1PayloadSize = 7; constexpr std::size_t kLegacyV2PayloadSize = 62; constexpr std::size_t kLegacyV3PayloadSize = 63; -constexpr std::size_t kCurrentPayloadSize = 66; +constexpr std::size_t kLegacyV4PayloadSize = 66; +// v5 appends the durations as seconds. The minutes still written at offsets 0-3 are +// vestigial: they keep the shared prefix that every earlier version's decoder reads, and +// v5 ignores them in favour of the appended values. +constexpr std::size_t kCurrentPayloadSize = 74; + +inline constexpr std::uint32_t kMaximumDurationSeconds = 24U * 60U * 60U; +inline constexpr std::uint32_t kMinimumSessionSeconds = 60U; bool valid_brightness(const std::uint8_t percent) noexcept { @@ -118,9 +125,9 @@ bool header_valid(const SettingsBlob& blob, std::uint16_t& version, bool valid_legacy_settings(const LegacySettingsV1& settings) noexcept { - return settings.session_duration_minutes >= 1 && - settings.session_duration_minutes <= 24 * 60 && - settings.rest_duration_minutes <= 24 * 60 && + return settings.session_duration_seconds >= 1 && + settings.session_duration_seconds <= 24 * 60 && + settings.rest_duration_seconds <= 24 * 60 && valid_brightness(settings.brightness_percent) && settings.orientation <= OrientationMode::automatic; } @@ -129,9 +136,9 @@ bool valid_legacy_settings(const LegacySettingsV1& settings) noexcept bool valid_settings(const DeviceSettings& settings) noexcept { - return settings.session_duration_minutes >= 1 && - settings.session_duration_minutes <= 24 * 60 && - settings.rest_duration_minutes <= 24 * 60 && + return settings.session_duration_seconds >= kMinimumSessionSeconds && + settings.session_duration_seconds <= kMaximumDurationSeconds && + settings.rest_duration_seconds <= kMaximumDurationSeconds && valid_launch_sensitivity(settings.launch_sensitivity_milli_g) && settings.average_lap_seconds <= 59 * 60 + 59 && valid_brightness(settings.day_brightness_percent) && @@ -147,8 +154,8 @@ bool valid_settings(const DeviceSettings& settings) noexcept bool settings_equal(const DeviceSettings& left, const DeviceSettings& right) noexcept { - return left.session_duration_minutes == right.session_duration_minutes && - left.rest_duration_minutes == right.rest_duration_minutes && + return left.session_duration_seconds == right.session_duration_seconds && + left.rest_duration_seconds == right.rest_duration_seconds && left.launch_sensitivity_milli_g == right.launch_sensitivity_milli_g && left.average_lap_seconds == right.average_lap_seconds && left.day_brightness_percent == right.day_brightness_percent && @@ -170,8 +177,12 @@ SettingsBlob encode_legacy_settings_v2(const DeviceSettings& settings) noexcept } std::array payload{}; - put_u16(payload.data(), settings.session_duration_minutes); - put_u16(payload.data() + 2, settings.rest_duration_minutes); + // Older formats only ever held minutes, so a value with seconds in it necessarily + // loses them here. v5 carries the exact seconds separately. + put_u16(payload.data(), + static_cast(settings.session_duration_seconds / 60U)); + put_u16(payload.data() + 2, + static_cast(settings.rest_duration_seconds / 60U)); put_u16(payload.data() + 4, settings.launch_sensitivity_milli_g); put_u16(payload.data() + 6, settings.average_lap_seconds); payload[8] = settings.day_brightness_percent; @@ -197,17 +208,30 @@ SettingsBlob encode_legacy_settings_v3(const DeviceSettings& settings) noexcept return make_blob(3, payload.data(), payload.size()); } -SettingsBlob encode_settings(const DeviceSettings& settings) noexcept +SettingsBlob encode_legacy_settings_v4(const DeviceSettings& settings) noexcept { const auto legacy = encode_legacy_settings_v3(settings); if (legacy.size == 0) { return {}; } - std::array payload{}; + std::array payload{}; std::copy_n(legacy.bytes.data() + kHeaderSize, kLegacyV3PayloadSize, payload.data()); payload[63] = static_cast(settings.lap_boundary); payload[64] = settings.pit_exit_auto_start_enabled ? 1U : 0U; payload[65] = settings.pit_entry_auto_stop_enabled ? 1U : 0U; + return make_blob(4, payload.data(), payload.size()); +} + +SettingsBlob encode_settings(const DeviceSettings& settings) noexcept +{ + const auto legacy = encode_legacy_settings_v4(settings); + if (legacy.size == 0) { + return {}; + } + std::array payload{}; + std::copy_n(legacy.bytes.data() + kHeaderSize, kLegacyV4PayloadSize, payload.data()); + put_u32(payload.data() + 66, settings.session_duration_seconds); + put_u32(payload.data() + 70, settings.rest_duration_seconds); return make_blob(kCurrentSettingsVersion, payload.data(), payload.size()); } @@ -217,8 +241,8 @@ SettingsBlob encode_legacy_settings_v1(const LegacySettingsV1& settings) noexcep return {}; } std::array payload{}; - put_u16(payload.data(), settings.session_duration_minutes); - put_u16(payload.data() + 2, settings.rest_duration_minutes); + put_u16(payload.data(), settings.session_duration_seconds); + put_u16(payload.data() + 2, settings.rest_duration_seconds); payload[4] = settings.brightness_percent; payload[5] = static_cast(settings.orientation); payload[6] = settings.auto_dim_enabled ? 1U : 0U; @@ -239,8 +263,8 @@ DecodeResult decode_settings(const SettingsBlob& blob, DeviceSettings& settings) if (payload_size != kLegacyV1PayloadSize) { return DecodeResult::corrupt; } - candidate.session_duration_minutes = get_u16(payload); - candidate.rest_duration_minutes = get_u16(payload + 2); + candidate.session_duration_seconds = get_u16(payload) * 60U; + candidate.rest_duration_seconds = get_u16(payload + 2) * 60U; candidate.day_brightness_percent = payload[4]; candidate.orientation = static_cast(payload[5]); candidate.auto_dim_enabled = payload[6] != 0; @@ -250,18 +274,22 @@ DecodeResult decode_settings(const SettingsBlob& blob, DeviceSettings& settings) settings = candidate; return DecodeResult::migrated_v1; } - if (version != 2 && version != 3 && version != kCurrentSettingsVersion) { + if (version != 2 && version != 3 && version != 4 && + version != kCurrentSettingsVersion) { return DecodeResult::unsupported_version; } const auto expected_payload_size = - version == 2 ? kLegacyV2PayloadSize - : version == 3 ? kLegacyV3PayloadSize : kCurrentPayloadSize; + version == 2 ? kLegacyV2PayloadSize + : version == 3 ? kLegacyV3PayloadSize + : version == 4 ? kLegacyV4PayloadSize + : kCurrentPayloadSize; if (payload_size != expected_payload_size) { return DecodeResult::corrupt; } - candidate.session_duration_minutes = get_u16(payload); - candidate.rest_duration_minutes = get_u16(payload + 2); + // Every format before v5 stored whole minutes, so a migrated session is exact. + candidate.session_duration_seconds = get_u16(payload) * 60U; + candidate.rest_duration_seconds = get_u16(payload + 2) * 60U; candidate.launch_sensitivity_milli_g = get_u16(payload + 4); candidate.average_lap_seconds = get_u16(payload + 6); candidate.day_brightness_percent = payload[8]; @@ -275,14 +303,19 @@ DecodeResult decode_settings(const SettingsBlob& blob, DeviceSettings& settings) if (version >= 3) { candidate.trackday_mode_enabled = payload[62] != 0; } - if (version == kCurrentSettingsVersion) { + if (version >= 4) { candidate.lap_boundary = static_cast(payload[63]); candidate.pit_exit_auto_start_enabled = payload[64] != 0; candidate.pit_entry_auto_stop_enabled = payload[65] != 0; } + if (version == kCurrentSettingsVersion) { + // The authoritative durations, which the vestigial minutes above cannot express. + candidate.session_duration_seconds = get_u32(payload + 66); + candidate.rest_duration_seconds = get_u32(payload + 70); + } if (payload[12] > 1 || (version >= 3 && payload[62] > 1) || - (version == kCurrentSettingsVersion && + (version >= 4 && (payload[63] > static_cast(LapBoundaryMode::finish) || payload[64] > 1 || payload[65] > 1)) || !valid_settings(candidate)) { @@ -291,6 +324,7 @@ DecodeResult decode_settings(const SettingsBlob& blob, DeviceSettings& settings) settings = candidate; return version == 2 ? DecodeResult::migrated_v2 : version == 3 ? DecodeResult::migrated_v3 + : version == 4 ? DecodeResult::migrated_v4 : DecodeResult::current; } @@ -338,6 +372,9 @@ SettingsLoadReport SettingsManager::load() noexcept case DecodeResult::migrated_v3: current_ = decoded; return {SettingsSource::migrated_v3, persist(current_)}; + case DecodeResult::migrated_v4: + current_ = decoded; + return {SettingsSource::migrated_v4, persist(current_)}; case DecodeResult::corrupt: current_ = {}; return {SettingsSource::defaults_corrupt, persist(current_)}; diff --git a/firmware/components/settings/include/track_timer/settings/settings.hpp b/firmware/components/settings/include/track_timer/settings/settings.hpp index 21f5fdd..6600d30 100644 --- a/firmware/components/settings/include/track_timer/settings/settings.hpp +++ b/firmware/components/settings/include/track_timer/settings/settings.hpp @@ -7,7 +7,7 @@ namespace track_timer::settings { -inline constexpr std::uint16_t kCurrentSettingsVersion = 4; +inline constexpr std::uint16_t kCurrentSettingsVersion = 5; inline constexpr std::size_t kSettingsBlobCapacity = 128; inline constexpr std::size_t kTrackIdentifierCapacity = 48; @@ -35,8 +35,10 @@ enum class LapBoundaryMode : std::uint8_t { }; struct DeviceSettings { - std::uint16_t session_duration_minutes{20}; - std::uint16_t rest_duration_minutes{20}; + // Seconds, not minutes: the roller sets these as minutes and seconds, and 24 hours of + // seconds does not fit a uint16. + std::uint32_t session_duration_seconds{20 * 60}; + std::uint32_t rest_duration_seconds{20 * 60}; std::uint16_t launch_sensitivity_milli_g{0}; std::uint16_t average_lap_seconds{0}; std::uint8_t day_brightness_percent{100}; @@ -53,8 +55,8 @@ struct DeviceSettings { }; struct LegacySettingsV1 { - std::uint16_t session_duration_minutes{20}; - std::uint16_t rest_duration_minutes{20}; + std::uint16_t session_duration_seconds{20}; + std::uint16_t rest_duration_seconds{20}; std::uint8_t brightness_percent{100}; OrientationMode orientation{OrientationMode::fixed_0}; bool auto_dim_enabled{false}; @@ -83,6 +85,7 @@ enum class DecodeResult : std::uint8_t { migrated_v1, migrated_v2, migrated_v3, + migrated_v4, corrupt, unsupported_version, }; @@ -92,6 +95,7 @@ enum class SettingsSource : std::uint8_t { migrated_v1, migrated_v2, migrated_v3, + migrated_v4, defaults_missing, defaults_corrupt, defaults_unsupported, @@ -142,6 +146,8 @@ struct FeatureAvailability { const LegacySettingsV1& settings) noexcept; [[nodiscard]] SettingsBlob encode_legacy_settings_v2( const DeviceSettings& settings) noexcept; +[[nodiscard]] SettingsBlob encode_legacy_settings_v4( + const DeviceSettings& settings) noexcept; [[nodiscard]] SettingsBlob encode_legacy_settings_v3( const DeviceSettings& settings) noexcept; [[nodiscard]] DecodeResult decode_settings(const SettingsBlob& blob, diff --git a/firmware/components/ui/CMakeLists.txt b/firmware/components/ui/CMakeLists.txt index 1ae10a4..3632a64 100644 --- a/firmware/components/ui/CMakeLists.txt +++ b/firmware/components/ui/CMakeLists.txt @@ -1,5 +1,5 @@ idf_component_register( - SRCS "component.cpp" "active_session.cpp" "input.cpp" "shell_navigation.cpp" "device_mode.cpp" "value_picker.cpp" "gesture_input.cpp" "carousel_screen.cpp" "trackday_screen.cpp" "timer_font.cpp" "diagnostics.cpp" "diagnostics_screen.cpp" + SRCS "component.cpp" "active_session.cpp" "input.cpp" "shell_navigation.cpp" "device_mode.cpp" "value_picker.cpp" "gesture_input.cpp" "carousel_screen.cpp" "time_roller.cpp" "time_roller_screen.cpp" "trackday_screen.cpp" "timer_font.cpp" "diagnostics.cpp" "diagnostics_screen.cpp" "display_policy.cpp" "foundation.cpp" "gate_capture.cpp" "gate_capture_screen.cpp" "g_meter_screen.cpp" "g_radar_screen.cpp" "imu_meter.cpp" "lvgl_visual_system.cpp" "navigation.cpp" "presenter.cpp" "ready_screen.cpp" "rest_session.cpp" "session_review.cpp" "session_review_screen.cpp" diff --git a/firmware/components/ui/active_session.cpp b/firmware/components/ui/active_session.cpp index a6b9a1e..ef42d60 100644 --- a/firmware/components/ui/active_session.cpp +++ b/firmware/components/ui/active_session.cpp @@ -186,7 +186,7 @@ void ActiveSessionController::update(const domain::UiSnapshot& snapshot, format_estimated_laps(view_.trackday, snapshot.session_remaining_ms, display.average_lap_seconds); const auto total_ms = - static_cast(display.session_duration_minutes) * 60'000; + static_cast(display.session_duration_seconds) * 1'000; view_.trackday.remaining_ratio = session_remaining_ratio(snapshot.session_remaining_ms, total_ms); view_.trackday.urgency = diff --git a/firmware/components/ui/gesture_input.cpp b/firmware/components/ui/gesture_input.cpp index de7987b..c50448c 100644 --- a/firmware/components/ui/gesture_input.cpp +++ b/firmware/components/ui/gesture_input.cpp @@ -45,6 +45,9 @@ void handle(lv_event_t* event) noexcept else if (direction == LV_DIR_BOTTOM) { action = InputAction::swipe_down; } + else if (direction == LV_DIR_TOP) { + action = InputAction::swipe_up; + } if (action != InputAction::none) { binding->handled_this_touch = true; ++counters.dispatched; @@ -77,8 +80,90 @@ void handle(lv_event_t* event) noexcept } } +struct DragBinding { + DragCallback callback{nullptr}; + void* context{nullptr}; + lv_point_t previous{}; + std::uint32_t previous_ms{0}; + bool active{false}; +}; + +constexpr std::size_t kMaximumDragBindings = 4; +DragBinding drag_bindings[kMaximumDragBindings]{}; +std::size_t drag_binding_count = 0; + +void handle_drag(lv_event_t* event) noexcept +{ + auto* binding = static_cast(lv_event_get_user_data(event)); + if (binding == nullptr || binding->callback == nullptr) { + return; + } + auto* indev = lv_indev_active(); + if (indev == nullptr) { + return; + } + lv_point_t point{}; + lv_indev_get_point(indev, &point); + const auto now_ms = lv_tick_get(); + + DragSample sample{}; + sample.x = static_cast(point.x); + sample.y = static_cast(point.y); + + switch (lv_event_get_code(event)) { + case LV_EVENT_PRESSED: + binding->previous = point; + binding->previous_ms = now_ms; + binding->active = true; + sample.phase = DragPhase::began; + break; + case LV_EVENT_PRESSING: + if (!binding->active) { + return; + } + sample.phase = DragPhase::moved; + sample.dy = static_cast(point.y - binding->previous.y); + sample.elapsed_ms = now_ms - binding->previous_ms; + binding->previous = point; + binding->previous_ms = now_ms; + // A sample with no travel says nothing and would only dilute the speed estimate. + if (sample.dy == 0) { + return; + } + break; + case LV_EVENT_RELEASED: + if (!binding->active) { + return; + } + binding->active = false; + sample.phase = DragPhase::ended; + break; + default: + return; + } + binding->callback(sample, binding->context); +} + } // namespace +void attach_drag_input(lv_obj_t* const target, const DragCallback callback, + void* const context) noexcept +{ + if (target == nullptr || callback == nullptr || + drag_binding_count >= kMaximumDragBindings) { + return; + } + auto& binding = drag_bindings[drag_binding_count++]; + binding.callback = callback; + binding.context = context; + binding.active = false; + + lv_obj_add_flag(target, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(target, handle_drag, LV_EVENT_PRESSED, &binding); + lv_obj_add_event_cb(target, handle_drag, LV_EVENT_PRESSING, &binding); + lv_obj_add_event_cb(target, handle_drag, LV_EVENT_RELEASED, &binding); +} + void attach_gesture_input(lv_obj_t* const target, const InputCallback callback, void* const context) noexcept { diff --git a/firmware/components/ui/include/track_timer/ui/active_session.hpp b/firmware/components/ui/include/track_timer/ui/active_session.hpp index 8d20ea7..b73ac65 100644 --- a/firmware/components/ui/include/track_timer/ui/active_session.hpp +++ b/firmware/components/ui/include/track_timer/ui/active_session.hpp @@ -52,7 +52,7 @@ struct ActiveSessionDisplayConfig { // adding a field in the middle silently reassigns every positional initialiser. // Needed for the decaying bar and the proportional part of the colour ramp, since // remaining time alone cannot say what fraction of the session is left. - std::uint16_t session_duration_minutes{0}; + std::uint32_t session_duration_seconds{0}; }; // Urgency bands for the countdown. Named rather than raw colours so the thresholds are diff --git a/firmware/components/ui/include/track_timer/ui/gesture_input.hpp b/firmware/components/ui/include/track_timer/ui/gesture_input.hpp index bbfb7ae..4ffb65c 100644 --- a/firmware/components/ui/include/track_timer/ui/gesture_input.hpp +++ b/firmware/components/ui/include/track_timer/ui/gesture_input.hpp @@ -24,6 +24,28 @@ void attach_gesture_input(lv_obj_t* target, InputCallback callback, // swipe starting anywhere still reaches the screen's handler. void bubble_gestures_to_parent(lv_obj_t* child) noexcept; +enum class DragPhase : std::uint8_t { + began, + moved, + ended, +}; + +struct DragSample { + DragPhase phase{DragPhase::began}; + std::int16_t x{0}; // screen coordinates, so a screen can tell which column was touched + std::int16_t y{0}; + std::int16_t dy{0}; // travel since the previous sample, positive downward + std::uint32_t elapsed_ms{0}; +}; + +using DragCallback = void (*)(const DragSample& sample, void* context) noexcept; + +// Continuous pointer travel, which the InputAction contract deliberately discards when it +// reduces touch to named gestures. A rolling selector needs it: one discrete swipe per +// step would take 59 gestures to cross a column, which is worse than the single press it +// replaces. Everything else should keep using attach_gesture_input. +void attach_drag_input(lv_obj_t* target, DragCallback callback, void* context) noexcept; + // Raw counts of what LVGL actually delivered, so a passive log can distinguish "no // events arrive" from "events arrive but are suppressed" from "actions are dispatched // but the screen does not follow". diff --git a/firmware/components/ui/include/track_timer/ui/input.hpp b/firmware/components/ui/include/track_timer/ui/input.hpp index 2ef1c67..0ca93b8 100644 --- a/firmware/components/ui/include/track_timer/ui/input.hpp +++ b/firmware/components/ui/include/track_timer/ui/input.hpp @@ -17,6 +17,7 @@ enum class InputAction : std::uint8_t { swipe_left, // move to the next item in a carousel swipe_right, // move to the previous item in a carousel swipe_down, // back one level + swipe_up, // only meaningful where a screen drives a value with vertical gestures }; using InputCallback = void (*)(InputAction action, void* context) noexcept; diff --git a/firmware/components/ui/include/track_timer/ui/presenter.hpp b/firmware/components/ui/include/track_timer/ui/presenter.hpp index 4d8c48c..bb1e776 100644 --- a/firmware/components/ui/include/track_timer/ui/presenter.hpp +++ b/firmware/components/ui/include/track_timer/ui/presenter.hpp @@ -25,8 +25,8 @@ enum class ReadyTrackState : std::uint8_t { struct ReadySnapshot { std::array selected_track{}; - std::uint16_t session_duration_minutes{20}; - std::uint16_t rest_duration_minutes{20}; + std::uint32_t session_duration_seconds{20 * 60}; + std::uint32_t rest_duration_seconds{20 * 60}; domain::GnssHealth gnss_health{domain::GnssHealth::unavailable}; Readiness storage{Readiness::unavailable}; Readiness imu{Readiness::unavailable}; diff --git a/firmware/components/ui/include/track_timer/ui/time_roller.hpp b/firmware/components/ui/include/track_timer/ui/time_roller.hpp new file mode 100644 index 0000000..095509d --- /dev/null +++ b/firmware/components/ui/include/track_timer/ui/time_roller.hpp @@ -0,0 +1,120 @@ +#pragma once + +#include "track_timer/settings/settings.hpp" +#include "track_timer/ui/settings_editor.hpp" + +#include +#include +#include + +namespace track_timer::ui { + +enum class RollerColumn : std::uint8_t { + minutes, + seconds, +}; + +// What one field allows. Minutes are not capped at 59 for the durations: "minutes and +// seconds" describes the units, not a two-digit limit, and a 90 minute session reads +// naturally as 90:00. Capping at 59 would cut the validated 24 hour range to 59:59. +struct TimeFieldSpec { + std::uint16_t maximum_minutes{59}; + std::uint32_t minimum_seconds{0}; + std::uint32_t maximum_seconds{59 * 60 + 59}; +}; + +[[nodiscard]] bool is_time_field(SettingsField field) noexcept; +[[nodiscard]] TimeFieldSpec time_field_spec(SettingsField field) noexcept; +[[nodiscard]] std::uint32_t time_field_seconds(SettingsField field, + const settings::DeviceSettings& settings) noexcept; +// Writes a committed value into `draft`. False if the field is not a time field. +[[nodiscard]] bool apply_time_field(SettingsField field, std::uint32_t seconds, + settings::DeviceSettings& draft) noexcept; + +// "20 MIN" for a whole number of minutes, "20:30" otherwise. Whole minutes stay in the +// wording the device has always used, because that is what most values still are. +void format_duration_value(char* output, std::size_t size, std::uint32_t seconds) noexcept; + +// How far the finger travels to advance one step, and how a flick decays. Tuned against +// the 450 px panel: a full-height drag is about thirteen steps. +inline constexpr float kRollerPixelsPerStep = 34.0F; +// Below this a release is a stop, not a flick, so a slow drag ends where it is let go. +inline constexpr float kRollerFlickMinimumStepsPerSecond = 3.0F; +inline constexpr float kRollerFlickDecayPerSecond = 2.6F; +inline constexpr float kRollerFlickCeilingStepsPerSecond = 90.0F; + +// LVGL reports a long press at 400 ms, which is far too eager for a save: a driver +// pausing mid-roll with a finger still down committed the half-rolled value. Saving is +// the one irreversible thing this screen does, so it asks for a deliberate hold. +inline constexpr std::uint32_t kRollerHoldToSaveMs = 1200; +// A hold that wanders is a drag that paused, not a hold. Generous enough for a gloved +// finger resting on a moving car. +inline constexpr std::int32_t kRollerHoldTravelLimitPx = 14; + +// Tracks whether a touch has become a deliberate hold. Separate from TimeRoller because +// it is about the finger, not the value, and separate from the screen so it can be tested +// without LVGL. +class HoldToSave { + public: + void begin(std::uint32_t now_ms) noexcept; + // Cumulative travel cancels the hold once the finger has clearly moved. + void travel(std::int32_t delta_pixels) noexcept; + void end() noexcept; + + [[nodiscard]] bool active() const noexcept; + // 0 to 1, for showing the driver that the hold is being counted. + [[nodiscard]] float progress(std::uint32_t now_ms) const noexcept; + [[nodiscard]] bool complete(std::uint32_t now_ms) const noexcept; + + private: + std::uint32_t started_ms_{0}; + std::int32_t travel_{0}; + bool active_{false}; +}; + +// The interaction model behind the two-column selector, with no LVGL in it. +// +// Columns wrap within themselves and never carry: rolling seconds past 59 returns to 00 +// and leaves minutes alone. A carry is unpredictable when the two columns are targeted +// independently, and a flick that rolls seconds several times round would otherwise drag +// minutes with it. +class TimeRoller { + public: + void reset(std::uint32_t total_seconds, TimeFieldSpec spec) noexcept; + + // A touch begins on a column. Any coast in progress stops, so a finger down catches + // a spinning column rather than fighting it. + void begin(RollerColumn column) noexcept; + // Finger moved. Positive pixels are downward, which decreases the value: the column + // follows the finger like a physical wheel, so pulling down brings lower values up. + void drag(float delta_pixels, std::uint32_t elapsed_ms) noexcept; + void release() noexcept; + // Advances a flick. Returns true while still coasting, so the caller knows to redraw. + bool advance(std::uint32_t elapsed_ms) noexcept; + // One discrete step, for a swipe rather than a drag. + void step(RollerColumn column, std::int32_t delta) noexcept; + + [[nodiscard]] std::uint32_t total_seconds() const noexcept; + // Clamped into the field's validated range. A roller can sit below a field's floor - + // session duration has a one minute minimum - so commit clamps rather than refusing. + [[nodiscard]] std::uint32_t committed_seconds() const noexcept; + [[nodiscard]] std::uint16_t minutes() const noexcept; + [[nodiscard]] std::uint8_t seconds() const noexcept; + [[nodiscard]] RollerColumn active() const noexcept; + [[nodiscard]] bool coasting() const noexcept; + + private: + void apply_steps(RollerColumn column, std::int32_t steps) noexcept; + + TimeFieldSpec spec_{}; + std::int32_t minutes_{0}; + std::int32_t seconds_{0}; + RollerColumn active_{RollerColumn::minutes}; + float residue_{0.0F}; + float velocity_{0.0F}; + float coast_residue_{0.0F}; +}; + +static_assert(std::is_trivially_copyable_v); + +} // namespace track_timer::ui diff --git a/firmware/components/ui/include/track_timer/ui/time_roller_screen.hpp b/firmware/components/ui/include/track_timer/ui/time_roller_screen.hpp new file mode 100644 index 0000000..57ad6ed --- /dev/null +++ b/firmware/components/ui/include/track_timer/ui/time_roller_screen.hpp @@ -0,0 +1,67 @@ +#pragma once + +#include "track_timer/ui/gesture_input.hpp" +#include "track_timer/ui/time_roller.hpp" +#include "track_timer/ui/timer_font.hpp" + +#include + +#include +#include + +namespace track_timer::ui { + +// Two columns of minutes and seconds, both live, rolled with the finger. +// +// The columns are targeted by where the touch starts rather than by a focus that has to be +// switched, so minutes and seconds are adjusted without a mode change between them. +// +// Digits sit in fixed cells, as UI_FOUNDATION requires: an auto-sizing label re-lays-out +// whenever the text changes, so the field would visibly shift while it rolled. Minutes are +// right-aligned in a field wide enough for the largest value the setting allows, with +// unused leading cells left blank rather than zero-padded - "1439" and "20" both belong in +// a duration, but "0020" does not read as a time. +class TimeRollerScreen { + public: + explicit TimeRollerScreen(lv_obj_t* root) noexcept; + + // Points the screen at a field and loads its current value. + void configure(const char* title, SettingsField field, std::uint32_t seconds) noexcept; + // Redraws from the roller's state. + void refresh() noexcept; + // Shows how far through a hold-to-save the finger is, 0 to 1. Without this a longer + // hold reads as an unresponsive screen rather than a deliberate one. + void set_hold_progress(float fraction) noexcept; + + [[nodiscard]] TimeRoller& roller() noexcept; + // Which column a touch at this x belongs to. The split is the midpoint between them. + [[nodiscard]] RollerColumn column_at(std::int16_t x) const noexcept; + [[nodiscard]] lv_obj_t* root() const noexcept; + + TimeRollerScreen(const TimeRollerScreen&) = delete; + TimeRollerScreen& operator=(const TimeRollerScreen&) = delete; + + private: + static constexpr std::size_t kMaximumMinuteCells = 4; + + void build_cells(std::int32_t font_px, std::size_t minute_digits) noexcept; + void write_column(bool minutes) noexcept; + + lv_obj_t* root_{nullptr}; + lv_obj_t* title_{nullptr}; + lv_obj_t* colon_{nullptr}; + lv_obj_t* minutes_unit_{nullptr}; + lv_obj_t* seconds_unit_{nullptr}; + lv_obj_t* hint_{nullptr}; + lv_obj_t* hold_bar_{nullptr}; + std::array minute_cells_{}; + std::array second_cells_{}; + + TimeRoller roller_{}; + SettingsField field_{SettingsField::average_lap}; + std::size_t minute_digits_{2}; + std::int32_t split_x_{300}; + std::int32_t font_px_{0}; +}; + +} // namespace track_timer::ui diff --git a/firmware/components/ui/input.cpp b/firmware/components/ui/input.cpp index b7ff3af..7fc430a 100644 --- a/firmware/components/ui/input.cpp +++ b/firmware/components/ui/input.cpp @@ -17,6 +17,8 @@ const char* input_action_name(const InputAction action) noexcept return "swipe-right"; case InputAction::swipe_down: return "swipe-down"; + case InputAction::swipe_up: + return "swipe-up"; } return "unknown"; } diff --git a/firmware/components/ui/presenter.cpp b/firmware/components/ui/presenter.cpp index ccf5d80..e5e4614 100644 --- a/firmware/components/ui/presenter.cpp +++ b/firmware/components/ui/presenter.cpp @@ -1,5 +1,7 @@ #include "track_timer/ui/presenter.hpp" +#include "track_timer/ui/time_roller.hpp" + #include "track_timer/ui/foundation.hpp" #include @@ -134,10 +136,13 @@ ReadyViewModel present_ready(const ReadySnapshot& snapshot) noexcept std::snprintf(model.selected_track.data(), model.selected_track.size(), "%.47s", snapshot.selected_track[0] == '\0' ? "NO TRACK SELECTED" : snapshot.selected_track.data()); - std::snprintf(model.session_duration.data(), model.session_duration.size(), "%u MIN SESSION", - static_cast(snapshot.session_duration_minutes)); - std::snprintf(model.rest_duration.data(), model.rest_duration.size(), "%u MIN REST", - static_cast(snapshot.rest_duration_minutes)); + std::array duration{}; + format_duration_value(duration.data(), duration.size(), snapshot.session_duration_seconds); + std::snprintf(model.session_duration.data(), model.session_duration.size(), + "%s SESSION", duration.data()); + format_duration_value(duration.data(), duration.size(), snapshot.rest_duration_seconds); + std::snprintf(model.rest_duration.data(), model.rest_duration.size(), "%s REST", + duration.data()); std::snprintf(model.gnss.text.data(), model.gnss.text.size(), "%s", gnss_label(snapshot.gnss_health)); diff --git a/firmware/components/ui/settings_editor.cpp b/firmware/components/ui/settings_editor.cpp index 064cc71..1b2311d 100644 --- a/firmware/components/ui/settings_editor.cpp +++ b/firmware/components/ui/settings_editor.cpp @@ -1,5 +1,7 @@ #include "track_timer/ui/settings_editor.hpp" +#include "track_timer/ui/time_roller.hpp" + #include #include #include @@ -7,8 +9,10 @@ namespace track_timer::ui { namespace { -constexpr std::array kDurationMinutes{1, 5, 10, 15, 20, - 25, 30, 40, 50, 60}; +// Seconds now, since the durations are stored in seconds. The stepping editor keeps its +// coarse preset ladder; the roller in the gated menu is what reaches every value. +constexpr std::array kDurationSeconds{60, 300, 600, 900, 1200, + 1500, 1800, 2400, 3000, 3600}; constexpr std::array kLaunchMilliG{0, 500, 1'000, 1'250, 1'500, 1'750, 2'000, 2'500, 3'500, 4'000}; constexpr std::array kBrightnessPercent{25, 50, 75, 100}; @@ -85,12 +89,10 @@ void format_value(const SettingsField field, const settings::DeviceSettings& set { switch (field) { case SettingsField::session_duration: - std::snprintf(output.data(), output.size(), "%u MIN", - static_cast(settings.session_duration_minutes)); + format_duration_value(output.data(), output.size(), settings.session_duration_seconds); break; case SettingsField::rest_duration: - std::snprintf(output.data(), output.size(), "%u MIN", - static_cast(settings.rest_duration_minutes)); + format_duration_value(output.data(), output.size(), settings.rest_duration_seconds); break; case SettingsField::launch_sensitivity: if (settings.launch_sensitivity_milli_g == 0) { @@ -312,10 +314,10 @@ bool SettingsEditor::adjust(const bool forward) noexcept bool adjusted = false; switch (field_) { case SettingsField::session_duration: - adjusted = step_choice(draft_.session_duration_minutes, kDurationMinutes, forward); + adjusted = step_choice(draft_.session_duration_seconds, kDurationSeconds, forward); break; case SettingsField::rest_duration: - adjusted = step_choice(draft_.rest_duration_minutes, kDurationMinutes, forward); + adjusted = step_choice(draft_.rest_duration_seconds, kDurationSeconds, forward); break; case SettingsField::launch_sensitivity: adjusted = step_choice(draft_.launch_sensitivity_milli_g, kLaunchMilliG, forward); diff --git a/firmware/components/ui/shell_navigation.cpp b/firmware/components/ui/shell_navigation.cpp index d5b690f..a113833 100644 --- a/firmware/components/ui/shell_navigation.cpp +++ b/firmware/components/ui/shell_navigation.cpp @@ -192,6 +192,9 @@ ShellResult ShellNavigation::dispatch(const InputAction action) noexcept close(); result.outcome = ShellOutcome::exited; break; + case InputAction::swipe_up: + // Vertical gestures belong to the screen that draws a value, not to navigation. + // The shell has nothing above the current level to move to. case InputAction::none: break; } diff --git a/firmware/components/ui/time_roller.cpp b/firmware/components/ui/time_roller.cpp new file mode 100644 index 0000000..33d13b9 --- /dev/null +++ b/firmware/components/ui/time_roller.cpp @@ -0,0 +1,269 @@ +#include "track_timer/ui/time_roller.hpp" + +#include +#include + +namespace track_timer::ui { +namespace { + +inline constexpr std::uint32_t kMaximumDurationSeconds = 24U * 60U * 60U; +inline constexpr std::uint32_t kMinimumSessionSeconds = 60U; + +[[nodiscard]] std::int32_t wrapped(const std::int32_t value, const std::int32_t limit) noexcept +{ + const auto span = limit + 1; + auto result = value % span; + if (result < 0) { + result += span; + } + return result; +} + +} // namespace + +bool is_time_field(const SettingsField field) noexcept +{ + return field == SettingsField::session_duration || + field == SettingsField::rest_duration || field == SettingsField::average_lap; +} + +TimeFieldSpec time_field_spec(const SettingsField field) noexcept +{ + switch (field) { + case SettingsField::session_duration: + return {1439, kMinimumSessionSeconds, kMaximumDurationSeconds}; + case SettingsField::rest_duration: + return {1439, 0, kMaximumDurationSeconds}; + case SettingsField::average_lap: + // A lap over an hour is not a thing, and zero means "not set", which is what the + // laps-remaining estimate tests for. + return {59, 0, 59 * 60 + 59}; + default: + return {}; + } +} + +std::uint32_t time_field_seconds(const SettingsField field, + const settings::DeviceSettings& settings) noexcept +{ + switch (field) { + case SettingsField::session_duration: + return settings.session_duration_seconds; + case SettingsField::rest_duration: + return settings.rest_duration_seconds; + case SettingsField::average_lap: + return settings.average_lap_seconds; + default: + return 0; + } +} + +bool apply_time_field(const SettingsField field, const std::uint32_t seconds, + settings::DeviceSettings& draft) noexcept +{ + switch (field) { + case SettingsField::session_duration: + draft.session_duration_seconds = seconds; + return true; + case SettingsField::rest_duration: + draft.rest_duration_seconds = seconds; + return true; + case SettingsField::average_lap: + draft.average_lap_seconds = static_cast(seconds); + if (seconds == 0) { + // Laps remaining cannot be computed without an average, so clearing the + // average has to take the lower display back to elapsed with it. + draft.lower_display = settings::LowerDisplayMode::elapsed; + } + return true; + default: + return false; + } +} + +void format_duration_value(char* const output, const std::size_t size, + const std::uint32_t seconds) noexcept +{ + if (output == nullptr || size == 0) { + return; + } + const auto minutes = seconds / 60U; + const auto remainder = seconds % 60U; + if (remainder == 0U) { + std::snprintf(output, size, "%u MIN", static_cast(minutes)); + return; + } + std::snprintf(output, size, "%u:%02u", static_cast(minutes), + static_cast(remainder)); +} + +void HoldToSave::begin(const std::uint32_t now_ms) noexcept +{ + started_ms_ = now_ms; + travel_ = 0; + active_ = true; +} + +void HoldToSave::travel(const std::int32_t delta_pixels) noexcept +{ + if (!active_) { + return; + } + travel_ += delta_pixels < 0 ? -delta_pixels : delta_pixels; + if (travel_ > kRollerHoldTravelLimitPx) { + active_ = false; + } +} + +void HoldToSave::end() noexcept +{ + active_ = false; + travel_ = 0; +} + +bool HoldToSave::active() const noexcept { return active_; } + +float HoldToSave::progress(const std::uint32_t now_ms) const noexcept +{ + if (!active_ || now_ms <= started_ms_) { + return 0.0F; + } + const auto held = now_ms - started_ms_; + if (held >= kRollerHoldToSaveMs) { + return 1.0F; + } + return static_cast(held) / static_cast(kRollerHoldToSaveMs); +} + +bool HoldToSave::complete(const std::uint32_t now_ms) const noexcept +{ + return active_ && now_ms >= started_ms_ + kRollerHoldToSaveMs; +} + +void TimeRoller::reset(const std::uint32_t total_seconds, const TimeFieldSpec spec) noexcept +{ + spec_ = spec; + const auto clamped = total_seconds > spec.maximum_seconds ? spec.maximum_seconds + : total_seconds; + minutes_ = static_cast(clamped / 60U); + seconds_ = static_cast(clamped % 60U); + if (minutes_ > static_cast(spec_.maximum_minutes)) { + minutes_ = spec_.maximum_minutes; + } + active_ = RollerColumn::minutes; + residue_ = 0.0F; + velocity_ = 0.0F; + coast_residue_ = 0.0F; +} + +void TimeRoller::begin(const RollerColumn column) noexcept +{ + active_ = column; + // Touching a spinning column stops it, rather than fighting the coast. + velocity_ = 0.0F; + coast_residue_ = 0.0F; + residue_ = 0.0F; +} + +void TimeRoller::drag(const float delta_pixels, const std::uint32_t elapsed_ms) noexcept +{ + // Down is positive and decreases the value: the column follows the finger. + const auto steps = -delta_pixels / kRollerPixelsPerStep; + residue_ += steps; + const auto whole = static_cast(residue_); + if (whole != 0) { + apply_steps(active_, whole); + residue_ -= static_cast(whole); + } + if (elapsed_ms > 0) { + const auto instant = steps * 1000.0F / static_cast(elapsed_ms); + // Smoothed, so one jittery sample cannot launch a flick on its own. + velocity_ = velocity_ * 0.6F + instant * 0.4F; + } +} + +void TimeRoller::release() noexcept +{ + if (std::fabs(velocity_) < kRollerFlickMinimumStepsPerSecond) { + velocity_ = 0.0F; + coast_residue_ = 0.0F; + return; + } + if (velocity_ > kRollerFlickCeilingStepsPerSecond) { + velocity_ = kRollerFlickCeilingStepsPerSecond; + } + else if (velocity_ < -kRollerFlickCeilingStepsPerSecond) { + velocity_ = -kRollerFlickCeilingStepsPerSecond; + } +} + +bool TimeRoller::advance(const std::uint32_t elapsed_ms) noexcept +{ + if (velocity_ == 0.0F || elapsed_ms == 0) { + return velocity_ != 0.0F; + } + const auto elapsed_s = static_cast(elapsed_ms) / 1000.0F; + coast_residue_ += velocity_ * elapsed_s; + const auto whole = static_cast(coast_residue_); + if (whole != 0) { + apply_steps(active_, whole); + coast_residue_ -= static_cast(whole); + } + velocity_ *= std::exp(-kRollerFlickDecayPerSecond * elapsed_s); + if (std::fabs(velocity_) < 1.0F) { + velocity_ = 0.0F; + coast_residue_ = 0.0F; + return false; + } + return true; +} + +void TimeRoller::step(const RollerColumn column, const std::int32_t delta) noexcept +{ + active_ = column; + velocity_ = 0.0F; + coast_residue_ = 0.0F; + apply_steps(column, delta); +} + +void TimeRoller::apply_steps(const RollerColumn column, const std::int32_t steps) noexcept +{ + if (column == RollerColumn::minutes) { + minutes_ = wrapped(minutes_ + steps, static_cast(spec_.maximum_minutes)); + return; + } + seconds_ = wrapped(seconds_ + steps, 59); +} + +std::uint32_t TimeRoller::total_seconds() const noexcept +{ + return static_cast(minutes_) * 60U + static_cast(seconds_); +} + +std::uint32_t TimeRoller::committed_seconds() const noexcept +{ + const auto total = total_seconds(); + if (total < spec_.minimum_seconds) { + return spec_.minimum_seconds; + } + if (total > spec_.maximum_seconds) { + return spec_.maximum_seconds; + } + return total; +} + +std::uint16_t TimeRoller::minutes() const noexcept +{ + return static_cast(minutes_); +} + +std::uint8_t TimeRoller::seconds() const noexcept +{ + return static_cast(seconds_); +} + +RollerColumn TimeRoller::active() const noexcept { return active_; } + +bool TimeRoller::coasting() const noexcept { return velocity_ != 0.0F; } + +} // namespace track_timer::ui diff --git a/firmware/components/ui/time_roller_screen.cpp b/firmware/components/ui/time_roller_screen.cpp new file mode 100644 index 0000000..ab3e422 --- /dev/null +++ b/firmware/components/ui/time_roller_screen.cpp @@ -0,0 +1,259 @@ +#include "track_timer/ui/time_roller_screen.hpp" + +#include "track_timer/ui/lvgl_visual_system.hpp" + +#include + +namespace track_timer::ui { +namespace { + +constexpr std::int32_t kScreenWidth = 600; +constexpr std::int32_t kSideMargin = 24; +constexpr std::int32_t kAvailableWidth = kScreenWidth - 2 * kSideMargin; +constexpr std::int32_t kDigitsTop = 118; + +// The same face and metrics the countdown uses: the widest numeral advances 689/1000 em, +// so a cell is that plus a little side bearing, and the colon is much narrower. +constexpr std::int32_t cell_width_for(const std::int32_t font_px) noexcept +{ + return font_px * 7235 / 10000; +} +constexpr std::int32_t colon_width_for(const std::int32_t font_px) noexcept +{ + return font_px * 288 / 1000; +} +constexpr std::int32_t cell_height_for(const std::int32_t font_px) noexcept +{ + return font_px * 1218 / 1000; +} + +// Digits needed for the largest minute value the field allows: 59 needs two, 1439 needs +// four, and a duration column has to be built for its own ceiling rather than a shared one. +std::size_t minute_digits_for(const std::uint16_t maximum_minutes) noexcept +{ + std::size_t digits = 1; + for (auto value = maximum_minutes; value >= 10; value /= 10) { + ++digits; + } + return digits < 2 ? 2 : digits; +} + +// The largest size that fits the width, capped so the wheel still has room above and below +// on a 450 px panel. Average lap gets 140 px, about 8 mm; the durations need four minute +// digits so they settle lower. +std::int32_t font_px_for(const std::size_t minute_digits) noexcept +{ + for (const std::int32_t candidate : {140, 130, 120, 115, 100, 90}) { + const auto total = + cell_width_for(candidate) * (static_cast(minute_digits) + 2) + + colon_width_for(candidate); + if (total <= kAvailableWidth) { + return candidate; + } + } + return 90; +} + +constexpr std::uint32_t kBackground = 0x000000; +constexpr std::uint32_t kActive = 0xF2F5F8; +constexpr std::uint32_t kInactive = 0x5C6675; +constexpr std::uint32_t kMuted = 0x7C8899; +constexpr std::uint32_t kAccent = 0x39B6FF; + +} // namespace + +TimeRollerScreen::TimeRollerScreen(lv_obj_t* const root) noexcept : root_(root) +{ + style_screen(root_); + lv_obj_set_style_bg_color(root_, lv_color_hex(kBackground), 0); + lv_obj_set_style_bg_opa(root_, LV_OPA_COVER, 0); + + title_ = create_label(root_, Typography::heading, kAccent); + lv_obj_align(title_, LV_ALIGN_TOP_MID, 0, 18); + + minutes_unit_ = create_label(root_, Typography::caption, kMuted); + seconds_unit_ = create_label(root_, Typography::caption, kMuted); + lv_label_set_text(minutes_unit_, "MIN"); + lv_label_set_text(seconds_unit_, "SEC"); + + // Fills as a save is held. Sits directly under the hint it is confirming. + hold_bar_ = lv_obj_create(root_); + lv_obj_remove_style_all(hold_bar_); + lv_obj_set_size(hold_bar_, 0, 6); + lv_obj_set_style_bg_color(hold_bar_, lv_color_hex(kAccent), 0); + lv_obj_set_style_bg_opa(hold_bar_, LV_OPA_COVER, 0); + lv_obj_set_style_radius(hold_bar_, 3, 0); + lv_obj_align(hold_bar_, LV_ALIGN_BOTTOM_MID, 0, -6); + bubble_gestures_to_parent(hold_bar_); + + hint_ = create_label(root_, Typography::caption, kMuted); + lv_label_set_text(hint_, "HOLD TO SAVE SWIPE LEFT OR RIGHT TO CANCEL"); + lv_obj_align(hint_, LV_ALIGN_BOTTOM_MID, 0, -18); +} + +void TimeRollerScreen::build_cells(const std::int32_t font_px, + const std::size_t minute_digits) noexcept +{ + if (font_px == font_px_ && minute_digits == minute_digits_) { + return; + } + for (auto*& cell : minute_cells_) { + if (cell != nullptr) { + lv_obj_delete(cell); + cell = nullptr; + } + } + for (auto*& cell : second_cells_) { + if (cell != nullptr) { + lv_obj_delete(cell); + cell = nullptr; + } + } + if (colon_ != nullptr) { + lv_obj_delete(colon_); + colon_ = nullptr; + } + + font_px_ = font_px; + minute_digits_ = minute_digits; + + const auto* font = countdown_font(font_px); + const auto cell = cell_width_for(font_px); + const auto colon = colon_width_for(font_px); + const auto height = cell_height_for(font_px); + const auto total = + cell * (static_cast(minute_digits) + 2) + colon; + auto x = (kScreenWidth - total) / 2; + + const auto make_cell = [&](const std::int32_t width, const char* text) { + auto* label = lv_label_create(root_); + if (font != nullptr) { + lv_obj_set_style_text_font(label, font, 0); + } + lv_obj_set_size(label, width, height); + lv_obj_set_pos(label, x, kDigitsTop); + lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_text(label, text); + // A clickable child would swallow gestures that begin on it, and every touch here + // begins on a digit. + bubble_gestures_to_parent(label); + x += width; + return label; + }; + + for (std::size_t index = 0; index < minute_digits; ++index) { + minute_cells_[index] = make_cell(cell, " "); + } + const auto minutes_right = x; + colon_ = make_cell(colon, ":"); + const auto seconds_left = x; + for (auto& slot : second_cells_) { + slot = make_cell(cell, "0"); + } + + // The split sits in the colon, so a touch anywhere over a column's digits picks it. + split_x_ = (minutes_right + seconds_left) / 2; + + lv_obj_align(minutes_unit_, LV_ALIGN_TOP_LEFT, + (kScreenWidth - total) / 2 + + cell * static_cast(minute_digits) / 2 - 20, + kDigitsTop + height + 10); + lv_obj_align(seconds_unit_, LV_ALIGN_TOP_LEFT, seconds_left + cell - 20, + kDigitsTop + height + 10); +} + +void TimeRollerScreen::configure(const char* const title, const SettingsField field, + const std::uint32_t seconds) noexcept +{ + field_ = field; + const auto spec = time_field_spec(field); + const auto digits = minute_digits_for(spec.maximum_minutes); + build_cells(font_px_for(digits), digits); + + lv_label_set_text(title_, title == nullptr ? "" : title); + roller_.reset(seconds, spec); + refresh(); +} + +void TimeRollerScreen::write_column(const bool minutes) noexcept +{ + const auto active = (roller_.active() == RollerColumn::minutes) == minutes; + const auto colour = lv_color_hex(active ? kActive : kInactive); + + if (minutes) { + // Right-aligned with blank leading cells: the field never changes width, but a + // twenty minute session still reads as "20" rather than "0020". + auto value = static_cast(roller_.minutes()); + for (std::size_t index = minute_digits_; index-- > 0;) { + auto* label = minute_cells_[index]; + if (label == nullptr) { + continue; + } + char text[2] = {' ', '\0'}; + const auto rendered = index == minute_digits_ - 1 || value > 0; + if (rendered) { + text[0] = static_cast('0' + (value % 10U)); + } + value /= 10U; + const auto* current = lv_label_get_text(label); + if (current == nullptr || current[0] != text[0]) { + lv_label_set_text(label, text); + } + lv_obj_set_style_text_color(label, colour, 0); + } + return; + } + + const auto value = static_cast(roller_.seconds()); + const char tens = static_cast('0' + (value / 10U)); + const char units = static_cast('0' + (value % 10U)); + const char digits[2] = {tens, units}; + for (std::size_t index = 0; index < second_cells_.size(); ++index) { + auto* label = second_cells_[index]; + if (label == nullptr) { + continue; + } + const char text[2] = {digits[index], '\0'}; + const auto* current = lv_label_get_text(label); + if (current == nullptr || current[0] != text[0]) { + lv_label_set_text(label, text); + } + lv_obj_set_style_text_color(label, colour, 0); + } +} + +void TimeRollerScreen::refresh() noexcept +{ + if (root_ == nullptr) { + return; + } + write_column(true); + write_column(false); + if (colon_ != nullptr) { + lv_obj_set_style_text_color(colon_, lv_color_hex(kInactive), 0); + } +} + +void TimeRollerScreen::set_hold_progress(const float fraction) noexcept +{ + if (hold_bar_ == nullptr) { + return; + } + const auto clamped = fraction < 0.0F ? 0.0F : (fraction > 1.0F ? 1.0F : fraction); + const auto width = static_cast(clamped * static_cast(kAvailableWidth)); + if (width != lv_obj_get_width(hold_bar_)) { + lv_obj_set_width(hold_bar_, width); + lv_obj_align(hold_bar_, LV_ALIGN_BOTTOM_MID, 0, -6); + } +} + +TimeRoller& TimeRollerScreen::roller() noexcept { return roller_; } + +RollerColumn TimeRollerScreen::column_at(const std::int16_t x) const noexcept +{ + return x < split_x_ ? RollerColumn::minutes : RollerColumn::seconds; +} + +lv_obj_t* TimeRollerScreen::root() const noexcept { return root_; } + +} // namespace track_timer::ui diff --git a/firmware/components/ui/value_picker.cpp b/firmware/components/ui/value_picker.cpp index c41f431..f7c0382 100644 --- a/firmware/components/ui/value_picker.cpp +++ b/firmware/components/ui/value_picker.cpp @@ -6,17 +6,10 @@ namespace track_timer::ui { namespace { -constexpr std::array kDurationMinutes{1, 5, 10, 15, 20, - 25, 30, 40, 50, 60}; constexpr std::array kLaunchMilliG{0, 500, 1'000, 1'250, 1'500, 1'750, 2'000, 2'500, 3'500, 4'000}; constexpr std::array kBrightnessPercent{25, 50, 75, 100}; -// Average lap only feeds the laps-remaining estimate, so second-level precision buys -// nothing. A curated list spanning real circuit lap times is one press per value, where -// stepping ±1 s across 0-3599 needed up to 150. -constexpr std::array kAverageLapSeconds{ - 0, 30, 45, 60, 75, 90, 105, 120, 135, 150, 180}; void set_text(ValueChoice& choice, const char* text) noexcept { @@ -55,16 +48,6 @@ ValueChoiceList from_labels(const char* const* labels, const std::size_t count, return list; } -void format_lap_seconds(ValueChoice& choice, const std::uint16_t seconds) noexcept -{ - if (seconds == 0) { - set_text(choice, "OFF"); - return; - } - std::snprintf(choice.text.data(), choice.text.size(), "%u:%02u", seconds / 60U, - seconds % 60U); -} - } // namespace ValueChoiceList choices_for(const SettingsField field, @@ -76,19 +59,13 @@ ValueChoiceList choices_for(const SettingsField field, static const char* const kOrientation[] = {"0", "90", "180", "270", "AUTO"}; switch (field) { + // The time fields have no choice list. A curated ladder could not reach their values + // at all - average lap stopped at 3:00 against a 59:59 range - so they are edited on + // the two-column roller instead, and an empty list is what tells the caller that. case SettingsField::session_duration: - return from_list(kDurationMinutes, current.session_duration_minutes, - [](ValueChoice& c, std::uint16_t v) { - set_number(c, "%u MIN", static_cast(v)); - }); case SettingsField::rest_duration: - return from_list(kDurationMinutes, current.rest_duration_minutes, - [](ValueChoice& c, std::uint16_t v) { - set_number(c, "%u MIN", static_cast(v)); - }); case SettingsField::average_lap: - return from_list(kAverageLapSeconds, current.average_lap_seconds, - [](ValueChoice& c, std::uint16_t v) { format_lap_seconds(c, v); }); + return {}; case SettingsField::launch_sensitivity: return from_list(kLaunchMilliG, current.launch_sensitivity_milli_g, [](ValueChoice& c, std::uint16_t v) { @@ -135,29 +112,12 @@ bool apply_choice(const SettingsField field, const std::size_t index, settings::DeviceSettings& draft) noexcept { switch (field) { + // Edited on the roller, not from a list, so there is no index to apply. Returning + // false keeps a stale value screen from writing a value the field no longer offers. case SettingsField::session_duration: - if (index >= kDurationMinutes.size()) { - return false; - } - draft.session_duration_minutes = kDurationMinutes[index]; - return true; case SettingsField::rest_duration: - if (index >= kDurationMinutes.size()) { - return false; - } - draft.rest_duration_minutes = kDurationMinutes[index]; - return true; case SettingsField::average_lap: - if (index >= kAverageLapSeconds.size()) { - return false; - } - draft.average_lap_seconds = kAverageLapSeconds[index]; - // The laps-remaining readout is meaningless without an average lap, so turning - // it off has to take the dependent setting with it. - if (draft.average_lap_seconds == 0) { - draft.lower_display = settings::LowerDisplayMode::elapsed; - } - return true; + return false; case SettingsField::launch_sensitivity: if (index >= kLaunchMilliG.size()) { return false; diff --git a/firmware/main/screen_router.cpp b/firmware/main/screen_router.cpp index abbda61..85bd48f 100644 --- a/firmware/main/screen_router.cpp +++ b/firmware/main/screen_router.cpp @@ -27,6 +27,7 @@ #include "track_timer/ui/session_review_screen.hpp" #include "track_timer/ui/setup_menu_screen.hpp" #include "track_timer/ui/shell_navigation.hpp" +#include "track_timer/ui/time_roller_screen.hpp" #include "track_timer/ui/trackday_screen.hpp" #include "track_timer/session/controller.hpp" #include "track_timer/settings/nvs_store.hpp" @@ -100,8 +101,8 @@ ui::ReadySnapshot ready_snapshot() noexcept ui::ReadySnapshot snapshot{}; std::strncpy(snapshot.selected_track.data(), "No track selected", snapshot.selected_track.size() - 1); - snapshot.session_duration_minutes = 20; - snapshot.rest_duration_minutes = 20; + snapshot.session_duration_seconds = 20 * 60; + snapshot.rest_duration_seconds = 20 * 60; snapshot.gnss_health = domain::GnssHealth::unavailable; snapshot.storage = ui::Readiness::unavailable; snapshot.imu = ui::Readiness::unavailable; @@ -226,7 +227,7 @@ class ScreenRouter { ui::ActiveSessionDisplayConfig display{}; display.average_lap_seconds = settings_.average_lap_seconds; display.trackday_mode_enabled = true; - display.session_duration_minutes = settings_.session_duration_minutes; + display.session_duration_seconds = settings_.session_duration_seconds; active_session_.update(ui_snapshot, static_cast(now_ms), display); trackday_->update(active_session_.view_model().trackday); @@ -234,6 +235,13 @@ class ScreenRouter { // Pulls a sample and refreshes the radar. Called from the LVGL task so LVGL is only // ever touched by its owner. + // One entry point for everything the LVGL service tick drives. + void service() noexcept + { + service_imu(); + service_roller(); + } + void service_imu() noexcept { board::ImuSample sample{}; @@ -327,6 +335,18 @@ class ScreenRouter { // is only true because nothing on it consumes presses. ui::attach_gesture_input(radar_root_, on_input, this); + // The time fields are edited on their own screen: no list of twelve choices can + // span 0 to 59:59, so they roll rather than pick. + roller_root_ = lv_obj_create(nullptr); + if (roller_root_ == nullptr) { + return false; + } + roller_ = new (roller_storage_) ui::TimeRollerScreen(roller_root_); + ui::attach_gesture_input(roller_root_, on_input, this); + // Vertical travel drives the columns, so this screen needs the pointer itself + // rather than the discrete gestures everything else consumes. + ui::attach_drag_input(roller_root_, on_drag, this); + carousel_ = new (carousel_storage_) ui::CarouselScreen(carousel_root_, on_input, this); carousel_->set_entries(kMenuEntries, 4); @@ -401,18 +421,17 @@ class ScreenRouter { const auto now_ms = static_cast(esp_timer_get_time() / 1000); session::SessionConfiguration configuration{}; configuration.session_duration_ms = - static_cast(settings_.session_duration_minutes) * 60'000; + static_cast(settings_.session_duration_seconds) * 1'000; configuration.rest_duration_ms = - static_cast(settings_.rest_duration_minutes) * 60'000; + static_cast(settings_.rest_duration_seconds) * 1'000; if (session_.save_configuration(configuration, now_ms) != session::TransitionResult::accepted) { (void)session_.enter_configuration(now_ms); (void)session_.save_configuration(configuration, now_ms); } const auto started = session_.start(now_ms); - ESP_LOGI("track_timer", "session start: %d over %u min", - static_cast(started), - static_cast(settings_.session_duration_minutes)); + ESP_LOGI("track_timer", "session start: %d over %u s", static_cast(started), + static_cast(settings_.session_duration_seconds)); if (trackday_ != nullptr) { trackday_->set_track_name(active_track_.name[0] != '\0' ? active_track_.name.data() @@ -451,8 +470,8 @@ class ScreenRouter { [[nodiscard]] ui::ReadySnapshot ready_snapshot() const noexcept { ui::ReadySnapshot snapshot{}; - snapshot.session_duration_minutes = settings_.session_duration_minutes; - snapshot.rest_duration_minutes = settings_.rest_duration_minutes; + snapshot.session_duration_seconds = settings_.session_duration_seconds; + snapshot.rest_duration_seconds = settings_.rest_duration_seconds; snapshot.gnss_health = domain::GnssHealth::unavailable; snapshot.storage = storage::mounted() ? ui::Readiness::ready : ui::Readiness::unavailable; @@ -638,6 +657,18 @@ class ScreenRouter { void show_values(const ui::ShellState& state, const bool reset_to_current) noexcept { const auto field = ui::kPickerFields[state.field_index]; + if (ui::is_time_field(field)) { + if (roller_ != nullptr) { + roller_->configure(ui::picker_field_label(field), field, + ui::time_field_seconds(field, settings_)); + roller_field_ = field; + roller_active_ = true; + roller_serviced_ms_ = lv_tick_get(); + lv_screen_load(roller_root_); + } + return; + } + roller_active_ = false; const auto list = ui::choices_for(field, settings_); for (std::size_t index = 0; index < list.count; ++index) { value_text_[index] = list.choices[index].text; @@ -652,8 +683,118 @@ class ScreenRouter { } } + static void on_drag(const ui::DragSample& sample, void* const context) noexcept + { + static_cast(context)->handle_drag(sample); + } + + void handle_drag(const ui::DragSample& sample) noexcept + { + if (!roller_active_ || roller_ == nullptr) { + return; + } + switch (sample.phase) { + case ui::DragPhase::began: + // The column is chosen by where the touch lands, so minutes and seconds are + // both adjustable without switching focus between them. + roller_->roller().begin(roller_->column_at(sample.x)); + hold_.begin(lv_tick_get()); + roller_->set_hold_progress(0.0F); + break; + case ui::DragPhase::moved: + roller_->roller().drag(static_cast(sample.dy), sample.elapsed_ms); + hold_.travel(sample.dy); + roller_->refresh(); + break; + case ui::DragPhase::ended: + roller_->roller().release(); + hold_.end(); + roller_->set_hold_progress(0.0F); + roller_serviced_ms_ = lv_tick_get(); + break; + } + } + + // Carries a flick after the finger has gone. Driven from the service tick rather than + // a timer of its own, so it cannot outlive the screen that owns it. + void service_roller() noexcept + { + if (!roller_active_ || roller_ == nullptr) { + return; + } + // A save is a hold measured here rather than LVGL's 400 ms long press, which was + // eager enough to commit a value the driver was still rolling. + if (hold_.active()) { + const auto now = lv_tick_get(); + roller_->set_hold_progress(hold_.progress(now)); + if (hold_.complete(now)) { + hold_.end(); + roller_->set_hold_progress(0.0F); + leave_roller(true); + return; + } + } + if (!roller_->roller().coasting()) { + return; + } + const auto now_ms = lv_tick_get(); + const auto elapsed = now_ms - roller_serviced_ms_; + roller_serviced_ms_ = now_ms; + (void)roller_->roller().advance(elapsed); + roller_->refresh(); + } + + // Leaves the roller for the field list, saving first when asked. Cancelling is a + // horizontal swipe because down now drives the digits and can no longer mean back. + void leave_roller(const bool commit) noexcept + { + if (roller_ != nullptr && commit) { + if (ui::apply_time_field(roller_field_, roller_->roller().committed_seconds(), + settings_)) { + persist_settings(); + ESP_LOGI("track_timer", "set %s to %u s", + ui::picker_field_label(roller_field_), + static_cast( + ui::time_field_seconds(roller_field_, settings_))); + } + } + roller_active_ = false; + hold_.end(); + const auto result = shell_.dispatch(ui::InputAction::swipe_down); + if (result.state.level == ui::ShellLevel::field) { + show_fields(result.state); + lv_screen_load(carousel_root_); + return; + } + lv_screen_load(home_screen()); + } + void handle_input(const ui::InputAction action) noexcept { + if (roller_active_ && roller_ != nullptr) { + switch (action) { + case ui::InputAction::long_press: + // Saving is driven by the measured hold in service_roller, not by LVGL's + // 400 ms threshold. + return; + case ui::InputAction::swipe_left: + case ui::InputAction::swipe_right: + leave_roller(false); + return; + case ui::InputAction::swipe_up: + roller_->roller().step(roller_->roller().active(), 1); + roller_->refresh(); + return; + case ui::InputAction::swipe_down: + roller_->roller().step(roller_->roller().active(), -1); + roller_->refresh(); + return; + default: + // A tap changes nothing here: saving is deliberate, and a stray press + // must not commit a half-rolled value. + return; + } + } const auto result = shell_.dispatch(action); switch (result.outcome) { case ui::ShellOutcome::menu_opened: @@ -820,6 +961,12 @@ class ScreenRouter { lv_obj_t* radar_root_{nullptr}; ui::TrackdayScreen* trackday_{nullptr}; lv_obj_t* trackday_root_{nullptr}; + ui::TimeRollerScreen* roller_{nullptr}; + ui::SettingsField roller_field_{ui::SettingsField::average_lap}; + lv_obj_t* roller_root_{nullptr}; + bool roller_active_{false}; + ui::HoldToSave hold_{}; + std::uint32_t roller_serviced_ms_{0}; session::SessionController session_{}; ui::ActiveSessionController active_session_{}; bool session_was_active_{false}; @@ -844,6 +991,8 @@ class ScreenRouter { alignas(ui::CarouselScreen) std::byte carousel_storage_[sizeof(ui::CarouselScreen)]{}; alignas(ui::GRadarScreen) std::byte radar_storage_[sizeof(ui::GRadarScreen)]{}; alignas(ui::TrackdayScreen) std::byte trackday_storage_[sizeof(ui::TrackdayScreen)]{}; + alignas(ui::TimeRollerScreen) std::byte + roller_storage_[sizeof(ui::TimeRollerScreen)]{}; }; ScreenRouter router{}; @@ -851,7 +1000,7 @@ ScreenRouter router{}; } // namespace // Exposed so the LVGL task can drive the radar without the router owning a task. -void service_screen_router() noexcept { router.service_imu(); } +void service_screen_router() noexcept { router.service(); } bool start_screen_router() noexcept { diff --git a/simulator/CMakeLists.txt b/simulator/CMakeLists.txt index ac6a271..325df10 100644 --- a/simulator/CMakeLists.txt +++ b/simulator/CMakeLists.txt @@ -63,6 +63,7 @@ add_library( ../firmware/components/ui/input.cpp ../firmware/components/ui/shell_navigation.cpp ../firmware/components/ui/presenter.cpp + ../firmware/components/ui/time_roller.cpp ../firmware/components/ui/rest_session.cpp ../firmware/components/ui/session_review.cpp ../firmware/components/ui/settings_editor.cpp diff --git a/simulator/src/main.cpp b/simulator/src/main.cpp index 73a2daf..31745c1 100644 --- a/simulator/src/main.cpp +++ b/simulator/src/main.cpp @@ -378,10 +378,10 @@ void update_screen(ApplicationContext& context) context.player.reset(track_timer::simulator::ScenarioId::active); context.workflow_time_offset_ms = workflow_now; context.lifecycle = track_timer::session::SessionController{{ - static_cast(context.settings->current().session_duration_minutes) * - 60'000, - static_cast(context.settings->current().rest_duration_minutes) * - 60'000}}; + static_cast(context.settings->current().session_duration_seconds) * + 1'000, + static_cast(context.settings->current().rest_duration_seconds) * + 1'000}}; (void)context.lifecycle.start(workflow_now); context.workflow_active = true; } @@ -458,8 +458,8 @@ void update_screen(ApplicationContext& context) break; } } - ready.session_duration_minutes = context.settings->current().session_duration_minutes; - ready.rest_duration_minutes = context.settings->current().rest_duration_minutes; + ready.session_duration_seconds = context.settings->current().session_duration_seconds; + ready.rest_duration_seconds = context.settings->current().rest_duration_seconds; ready.gnss_health = active_snapshot.gnss_health; const auto storage_health = context.player.device().storage().status().health; ready.storage = storage_health == track_timer::board::StorageHealth::ready diff --git a/tests/cpp/test_application_screen.cpp b/tests/cpp/test_application_screen.cpp index 753cc07..222af0d 100644 --- a/tests/cpp/test_application_screen.cpp +++ b/tests/cpp/test_application_screen.cpp @@ -145,13 +145,13 @@ int main() assert(lv_obj_get_height(button) >= 56); } click(screen.settings_screen().button_for(ui::SettingsScreenAction::increment)); - assert(screen.settings_editor().draft().session_duration_minutes == 25); + assert(screen.settings_editor().draft().session_duration_seconds == 25 * 60); click(screen.settings_screen().button_for(ui::SettingsScreenAction::restore_defaults)); assert(screen.settings_editor().status() == ui::SettingsEditorStatus::confirm_defaults); click(screen.settings_screen().button_for(ui::SettingsScreenAction::cancel_defaults)); - assert(screen.settings_editor().draft().session_duration_minutes == 25); + assert(screen.settings_editor().draft().session_duration_seconds == 25 * 60); click(screen.settings_screen().button_for(ui::SettingsScreenAction::save)); - assert(settings_manager.current().session_duration_minutes == 25); + assert(settings_manager.current().session_duration_seconds == 25 * 60); click(screen.settings_screen().button_for(ui::SettingsScreenAction::cancel)); assert(screen.destination() == ui::Destination::setup); assert(screen.setup_page() == simulator::SetupPage::menu); diff --git a/tests/cpp/test_device_mode.cpp b/tests/cpp/test_device_mode.cpp index 8d5874f..e76be96 100644 --- a/tests/cpp/test_device_mode.cpp +++ b/tests/cpp/test_device_mode.cpp @@ -1,4 +1,5 @@ #include "track_timer/ui/device_mode.hpp" +#include "track_timer/ui/time_roller.hpp" #include "track_timer/ui/value_picker.hpp" #include @@ -12,8 +13,8 @@ using namespace track_timer; settings::DeviceSettings base() { settings::DeviceSettings value{}; - value.session_duration_minutes = 20; - value.rest_duration_minutes = 20; + value.session_duration_seconds = 20; + value.rest_duration_seconds = 20; return value; } @@ -83,42 +84,50 @@ void g_only_shows_nothing_but_the_meter() assert(!visibility.lap_delta); } -// The point of the rework: every value is one press away, never a stepping run. +// The point of the rework: every value is one press away, never a stepping run. The time +// fields are the exception, and deliberately so - no list of twelve can span 0 to 59:59, +// so they are edited on the roller and report no choices at all. void every_field_offers_its_values_directly() { const auto settings = base(); for (const auto field : ui::kPickerFields) { const auto list = ui::choices_for(field, settings); + assert(std::strlen(ui::picker_field_label(field)) > 0); + if (ui::is_time_field(field)) { + assert(list.count == 0); + continue; + } assert(list.count > 0); assert(list.count <= ui::kValueChoiceCapacity); assert(list.selected < list.count); assert(std::strlen(list.choices[0].text.data()) > 0); - assert(std::strlen(ui::picker_field_label(field)) > 0); } } -// The old editor needed up to 150 presses to reach a 2:30 average lap by stepping. -void average_lap_is_reachable_in_one_press() +// The preset ladder stopped at 3:00 against a field that holds 59:59, so a circuit with a +// longer lap could not be configured at all. The roller reaches every second of the range. +void any_average_lap_is_reachable_on_the_roller() { auto settings = base(); - const auto list = ui::choices_for(ui::SettingsField::average_lap, settings); - std::size_t target = list.count; - for (std::size_t index = 0; index < list.count; ++index) { - if (std::strcmp(list.choices[index].text.data(), "2:30") == 0) { - target = index; - } - } - assert(target < list.count); - assert(ui::apply_choice(ui::SettingsField::average_lap, target, settings)); - assert(settings.average_lap_seconds == 150); + const auto spec = ui::time_field_spec(ui::SettingsField::average_lap); + assert(spec.maximum_seconds == 59 * 60 + 59); + + ui::TimeRoller roller{}; + roller.reset(0, spec); + roller.step(ui::RollerColumn::minutes, 8); + roller.step(ui::RollerColumn::seconds, 17); + assert(roller.total_seconds() == 8 * 60 + 17); + assert(ui::apply_time_field(ui::SettingsField::average_lap, roller.committed_seconds(), + settings)); + assert(settings.average_lap_seconds == 8 * 60 + 17); } void the_selected_index_tracks_the_current_value() { auto settings = base(); - settings.session_duration_minutes = 40; - const auto list = ui::choices_for(ui::SettingsField::session_duration, settings); - assert(std::strcmp(list.choices[list.selected].text.data(), "40 MIN") == 0); + settings.day_brightness_percent = 75; + const auto list = ui::choices_for(ui::SettingsField::day_brightness, settings); + assert(std::strcmp(list.choices[list.selected].text.data(), "75%") == 0); } // A stale screen must not be able to write a value the field does not have. @@ -128,7 +137,7 @@ void out_of_range_choices_are_refused() assert(!ui::apply_choice(ui::SettingsField::session_duration, 99, settings)); assert(!ui::apply_choice(ui::SettingsField::auto_dim, 2, settings)); assert(!ui::apply_choice(ui::SettingsField::orientation, 5, settings)); - assert(settings.session_duration_minutes == 20); + assert(settings.session_duration_seconds == 20); } // Dependent settings must stay consistent, matching the existing editor's rule. @@ -138,9 +147,8 @@ void turning_off_average_lap_resets_the_dependent_field() settings.average_lap_seconds = 90; settings.lower_display = settings::LowerDisplayMode::laps_remaining; - const auto list = ui::choices_for(ui::SettingsField::average_lap, settings); - assert(std::strcmp(list.choices[0].text.data(), "OFF") == 0); - assert(ui::apply_choice(ui::SettingsField::average_lap, 0, settings)); + // Rolling both columns back to 00:00 is now how the average lap is cleared. + assert(ui::apply_time_field(ui::SettingsField::average_lap, 0, settings)); assert(settings.average_lap_seconds == 0); assert(settings.lower_display == settings::LowerDisplayMode::elapsed); @@ -168,7 +176,7 @@ int main() race_shows_laps_delta_and_remaining(); g_only_shows_nothing_but_the_meter(); every_field_offers_its_values_directly(); - average_lap_is_reachable_in_one_press(); + any_average_lap_is_reachable_on_the_roller(); the_selected_index_tracks_the_current_value(); out_of_range_choices_are_refused(); turning_off_average_lap_resets_the_dependent_field(); diff --git a/tests/cpp/test_log_formats.cpp b/tests/cpp/test_log_formats.cpp index 00c24f0..3169663 100644 --- a/tests/cpp/test_log_formats.cpp +++ b/tests/cpp/test_log_formats.cpp @@ -23,8 +23,8 @@ int main() using namespace track_timer; settings::DeviceSettings settings{}; - settings.session_duration_minutes = 30; - settings.rest_duration_minutes = 10; + settings.session_duration_seconds = 30 * 60; + settings.rest_duration_seconds = 10 * 60; settings.trackday_mode_enabled = true; settings.lap_boundary = settings::LapBoundaryMode::start; settings.pit_exit_auto_start_enabled = true; @@ -52,7 +52,9 @@ int main() assert(meta.settings.pit_entry_auto_stop_enabled); auto invalid_meta = meta; - invalid_meta.schema_version = 2; + // Relative to the current version, so bumping the format does not turn this sentinel + // into the valid value, as it did when the version moved to 2. + invalid_meta.schema_version = logger::kLogFormatVersion + 1; assert(!logger::valid_meta(invalid_meta)); invalid_meta = meta; copy_text(invalid_meta.track_fingerprint, "too-short"); diff --git a/tests/cpp/test_navigation.cpp b/tests/cpp/test_navigation.cpp index 81ac2a9..7d2cbdd 100644 --- a/tests/cpp/test_navigation.cpp +++ b/tests/cpp/test_navigation.cpp @@ -52,8 +52,8 @@ int main() ReadySnapshot ready{}; std::strcpy(ready.selected_track.data(), "Synthetic Test Loop"); - ready.session_duration_minutes = 30; - ready.rest_duration_minutes = 15; + ready.session_duration_seconds = 30 * 60; + ready.rest_duration_seconds = 15 * 60; ready.gnss_health = track_timer::domain::GnssHealth::searching; ready.storage = Readiness::degraded; ready.imu = Readiness::unavailable; @@ -64,6 +64,11 @@ int main() assert(std::strcmp(degraded.rest_duration.data(), "15 MIN REST") == 0); assert(std::strcmp(degraded.timing_mode.data(), "TIMER ONLY - GPS UNAVAILABLE") == 0); assert(std::strcmp(degraded.storage.text.data(), "STORAGE DEGRADED") == 0); + + // A duration carrying seconds reads as minutes and seconds rather than being rounded + // away, which is the whole point of storing seconds. + ready.session_duration_seconds = 20 * 60 + 30; + assert(std::strcmp(present_ready(ready).session_duration.data(), "20:30 SESSION") == 0); assert(std::strcmp(degraded.imu.text.data(), "NO IMU") == 0); assert(std::strcmp(degraded.logging.text.data(), "NO LOGGING") == 0); assert(degraded.start_enabled); diff --git a/tests/cpp/test_settings.cpp b/tests/cpp/test_settings.cpp index 265a2fa..6f089cb 100644 --- a/tests/cpp/test_settings.cpp +++ b/tests/cpp/test_settings.cpp @@ -45,8 +45,8 @@ class MemorySettingsStore final : public SettingsStore { DeviceSettings customized_settings() { DeviceSettings settings{}; - settings.session_duration_minutes = 30; - settings.rest_duration_minutes = 10; + settings.session_duration_seconds = 30 * 60 + 30; // exercises the seconds v5 added + settings.rest_duration_seconds = 10 * 60; settings.launch_sensitivity_milli_g = 1'250; settings.average_lap_seconds = 103; settings.day_brightness_percent = 75; @@ -74,7 +74,7 @@ void test_validation_and_codec() assert(settings_equal(settings, decoded)); auto invalid = settings; - invalid.session_duration_minutes = 0; + invalid.session_duration_seconds = 0; assert(!valid_settings(invalid)); invalid = settings; invalid.launch_sensitivity_milli_g = 777; @@ -125,7 +125,9 @@ void test_migration_corruption_and_storage_errors() const auto migration = migrated.load(); assert(migration.source == SettingsSource::migrated_v1); assert(migration.current_format_persisted); - assert(migrated.current().session_duration_minutes == 25); + // v1 held whole minutes, so the migrated value is exact. + assert(migrated.current().session_duration_seconds == 25 * 60); + assert(migrated.current().rest_duration_seconds == 5 * 60); assert(migrated.current().day_brightness_percent == 75); assert(migrated.current().night_brightness_percent == 50); assert(migrated.current().orientation == OrientationMode::fixed_90); @@ -160,6 +162,21 @@ void test_migration_corruption_and_storage_errors() assert(!version_three.current().pit_exit_auto_start_enabled); assert(!version_three.current().pit_entry_auto_stop_enabled); + MemorySettingsStore version_four_store; + version_four_store.found = true; + version_four_store.blob = encode_legacy_settings_v4(customized_settings()); + SettingsManager version_four{version_four_store}; + const auto version_four_migration = version_four.load(); + assert(version_four_migration.source == SettingsSource::migrated_v4); + assert(version_four_migration.current_format_persisted); + // v4 stored minutes, so the seconds in the source value cannot have survived it: 30:30 + // comes back as 30:00. Everything v4 could express is preserved exactly. + assert(version_four.current().session_duration_seconds == 30 * 60); + assert(version_four.current().rest_duration_seconds == 10 * 60); + assert(version_four.current().trackday_mode_enabled); + assert(version_four.current().pit_exit_auto_start_enabled); + assert(version_four.current().average_lap_seconds == 103); + MemorySettingsStore corrupt_store; corrupt_store.found = true; corrupt_store.blob = encode_settings(customized_settings()); diff --git a/tests/cpp/test_settings_editor.cpp b/tests/cpp/test_settings_editor.cpp index f4e6a8c..52c00d5 100644 --- a/tests/cpp/test_settings_editor.cpp +++ b/tests/cpp/test_settings_editor.cpp @@ -61,12 +61,14 @@ int main() assert(!editor.increment()); assert(editor.begin(manager.current(), false)); - constexpr std::array durations{1, 5, 10, 15, 20, - 25, 30, 40, 50, 60}; + // Seconds since v5. The stepping editor's ladder is unchanged in what it means, only + // in the unit it is expressed in. + constexpr std::array durations{60, 300, 600, 900, 1200, + 1500, 1800, 2400, 3000, 3600}; while (editor.decrement()) { } for (const auto expected : durations) { - assert(editor.draft().session_duration_minutes == expected); + assert(editor.draft().session_duration_seconds == expected); if (expected != durations.back()) { assert(editor.increment()); } @@ -74,20 +76,20 @@ int main() assert(!editor.increment()); auto extended_duration = manager.current(); - extended_duration.session_duration_minutes = 120; + extended_duration.session_duration_seconds = 120 * 60; assert(editor.begin(extended_duration, false)); assert(!editor.increment()); assert(editor.decrement()); - assert(editor.draft().session_duration_minutes == 60); + assert(editor.draft().session_duration_seconds == 3600); assert(editor.begin(manager.current(), false)); select_field(editor, ui::SettingsField::rest_duration); while (editor.decrement()) { } - assert(editor.draft().rest_duration_minutes == durations.front()); + assert(editor.draft().rest_duration_seconds == durations.front()); while (editor.increment()) { } - assert(editor.draft().rest_duration_minutes == durations.back()); + assert(editor.draft().rest_duration_seconds == durations.back()); select_field(editor, ui::SettingsField::launch_sensitivity); constexpr std::array launch_values{0, 500, 1'000, 1'250, 1'500, @@ -196,7 +198,7 @@ int main() assert(editor.draft().trackday_mode_enabled); editor.request_restore_defaults(); editor.resolve_restore_defaults(true); - assert(editor.draft().session_duration_minutes == 20); + assert(editor.draft().session_duration_seconds == 20 * 60); assert(!editor.draft().trackday_mode_enabled); assert(std::strcmp(editor.draft().selected_track_id.data(), "silverstone-gp") == 0); diff --git a/tests/cpp/test_time_roller.cpp b/tests/cpp/test_time_roller.cpp new file mode 100644 index 0000000..9553d35 --- /dev/null +++ b/tests/cpp/test_time_roller.cpp @@ -0,0 +1,304 @@ +#include "track_timer/ui/time_roller.hpp" + +#include +#include +#include + +namespace { + +using namespace track_timer; + +constexpr std::uint32_t kTick = 50; // the 20 Hz service tick the device drives this from + +ui::TimeRoller rolled(const ui::SettingsField field, const std::uint32_t seconds) +{ + ui::TimeRoller roller{}; + roller.reset(seconds, ui::time_field_spec(field)); + return roller; +} + +// One upward flick: several fast drags, then a release. +void flick_up(ui::TimeRoller& roller) +{ + for (int index = 0; index < 5; ++index) { + roller.drag(-2.0F * ui::kRollerPixelsPerStep, kTick); + } + roller.release(); +} + +// The defect that prompted this: the preset ladder stopped at 3:00 for a field holding +// 59:59, and at 60 minutes for durations validated to 24 hours. +void the_specs_span_the_whole_validated_range() +{ + assert(ui::time_field_spec(ui::SettingsField::average_lap).maximum_seconds == 59 * 60 + 59); + assert(ui::time_field_spec(ui::SettingsField::session_duration).maximum_seconds == + 24 * 60 * 60); + assert(ui::time_field_spec(ui::SettingsField::rest_duration).maximum_seconds == + 24 * 60 * 60); + // Session duration cannot be zero, so its floor is a minute. + assert(ui::time_field_spec(ui::SettingsField::session_duration).minimum_seconds == 60); + assert(ui::time_field_spec(ui::SettingsField::rest_duration).minimum_seconds == 0); + + assert(ui::is_time_field(ui::SettingsField::average_lap)); + assert(!ui::is_time_field(ui::SettingsField::day_brightness)); +} + +// A lap over three minutes was unreachable before. Every second of the range now is. +void every_second_of_the_range_is_reachable() +{ + const auto spec = ui::time_field_spec(ui::SettingsField::average_lap); + for (const std::uint32_t target : {0U, 1U, 59U, 60U, 107U, 500U, 3599U}) { + ui::TimeRoller roller{}; + roller.reset(target, spec); + assert(roller.total_seconds() == target); + assert(roller.minutes() == target / 60U); + assert(roller.seconds() == target % 60U); + } +} + +// Columns are targeted independently, so a carry would make a fast roll unpredictable and +// would drag minutes along behind a seconds flick. +void columns_wrap_without_carrying() +{ + auto roller = rolled(ui::SettingsField::average_lap, 5 * 60 + 59); + roller.step(ui::RollerColumn::seconds, 1); + assert(roller.seconds() == 0); + assert(roller.minutes() == 5); // untouched + + roller.step(ui::RollerColumn::seconds, -1); + assert(roller.seconds() == 59); + assert(roller.minutes() == 5); + + // Minutes wrap at the field's own ceiling, which differs per field. + roller = rolled(ui::SettingsField::average_lap, 59 * 60); + roller.step(ui::RollerColumn::minutes, 1); + assert(roller.minutes() == 0); + + roller = rolled(ui::SettingsField::session_duration, 1439 * 60); + roller.step(ui::RollerColumn::minutes, 1); + assert(roller.minutes() == 0); +} + +// The column follows the finger like a physical wheel: pulling down brings lower values up. +void dragging_follows_the_finger() +{ + auto roller = rolled(ui::SettingsField::average_lap, 10 * 60 + 30); + roller.begin(ui::RollerColumn::seconds); + + roller.drag(3.0F * ui::kRollerPixelsPerStep, kTick); + assert(roller.seconds() == 27); + roller.drag(-5.0F * ui::kRollerPixelsPerStep, kTick); + assert(roller.seconds() == 32); + assert(roller.minutes() == 10); + + // Sub-step movement accumulates rather than being lost or over-counted. + auto fine = rolled(ui::SettingsField::average_lap, 60); + fine.begin(ui::RollerColumn::seconds); + for (int index = 0; index < 4; ++index) { + fine.drag(-0.3F * ui::kRollerPixelsPerStep, kTick); + } + assert(fine.seconds() == 1); +} + +// A discrete swipe still has to work, and 59 of them is why the drag path exists. +void a_swipe_moves_one_step() +{ + auto roller = rolled(ui::SettingsField::average_lap, 90); + roller.step(ui::RollerColumn::minutes, 1); + assert(roller.total_seconds() == 150); + roller.step(ui::RollerColumn::seconds, -1); + assert(roller.total_seconds() == 149); +} + +// The long roll for durations is only usable because a flick carries on after release. +void a_flick_keeps_rolling_and_settles() +{ + auto roller = rolled(ui::SettingsField::session_duration, 20 * 60); + roller.begin(ui::RollerColumn::minutes); + const auto before_release = roller.minutes(); + flick_up(roller); + assert(roller.coasting()); + + std::uint32_t elapsed_ms = 0; + while (roller.advance(kTick)) { + elapsed_ms += kTick; + assert(elapsed_ms < 10'000); // must settle, not spin forever + } + assert(!roller.coasting()); + // The coast carried it well beyond where the finger let go. + assert(roller.minutes() > before_release + 12); + // And a settled roller stays settled. + assert(!roller.advance(kTick)); +} + +// A deliberate slow drag must end exactly where it is released, or a value cannot be set. +void a_slow_release_does_not_flick() +{ + auto roller = rolled(ui::SettingsField::average_lap, 100); + roller.begin(ui::RollerColumn::seconds); + for (int index = 0; index < 4; ++index) { + roller.drag(-0.1F * ui::kRollerPixelsPerStep, 400); + } + roller.release(); + assert(!roller.coasting()); + const auto settled = roller.total_seconds(); + assert(!roller.advance(kTick)); + assert(roller.total_seconds() == settled); +} + +// Catching a spinning column is how a driver stops one, so a touch must not fight it. +void touching_a_coasting_column_stops_it() +{ + auto roller = rolled(ui::SettingsField::session_duration, 20 * 60); + roller.begin(ui::RollerColumn::minutes); + flick_up(roller); + assert(roller.coasting()); + (void)roller.advance(kTick); + + roller.begin(ui::RollerColumn::minutes); + assert(!roller.coasting()); + const auto caught = roller.minutes(); + assert(!roller.advance(kTick)); + assert(roller.minutes() == caught); +} + +// A roller can sit below a field's floor, so commit clamps rather than writing something +// the settings validator would reject. +void committing_clamps_into_the_validated_range() +{ + ui::TimeRoller roller{}; + roller.reset(0, ui::time_field_spec(ui::SettingsField::session_duration)); + assert(roller.total_seconds() == 0); + assert(roller.committed_seconds() == 60); + + // Rest has no floor, so zero is a legitimate value there. + auto rest = rolled(ui::SettingsField::rest_duration, 0); + assert(rest.committed_seconds() == 0); + + // And a stored value beyond a field's ceiling is pulled back on reset. + ui::TimeRoller lap{}; + lap.reset(9'999, ui::time_field_spec(ui::SettingsField::average_lap)); + assert(lap.total_seconds() <= 59 * 60 + 59); +} + +// Laps remaining cannot be computed without an average, so clearing one clears the other. +void clearing_the_average_lap_resets_the_lower_display() +{ + settings::DeviceSettings draft{}; + draft.average_lap_seconds = 105; + draft.lower_display = settings::LowerDisplayMode::laps_remaining; + + assert(ui::apply_time_field(ui::SettingsField::average_lap, 0, draft)); + assert(draft.average_lap_seconds == 0); + assert(draft.lower_display == settings::LowerDisplayMode::elapsed); + + // A real value leaves the dependent setting alone. + draft.lower_display = settings::LowerDisplayMode::laps_remaining; + assert(ui::apply_time_field(ui::SettingsField::average_lap, 107, draft)); + assert(draft.lower_display == settings::LowerDisplayMode::laps_remaining); + + // Non-time fields are refused outright, so a stale screen cannot write through here. + assert(!ui::apply_time_field(ui::SettingsField::day_brightness, 30, draft)); +} + +void durations_round_trip_through_the_settings() +{ + settings::DeviceSettings draft{}; + assert(ui::apply_time_field(ui::SettingsField::session_duration, 25 * 60 + 30, draft)); + assert(draft.session_duration_seconds == 25 * 60 + 30); + assert(ui::time_field_seconds(ui::SettingsField::session_duration, draft) == 25 * 60 + 30); + + assert(ui::apply_time_field(ui::SettingsField::rest_duration, 90 * 60, draft)); + assert(ui::time_field_seconds(ui::SettingsField::rest_duration, draft) == 90 * 60); +} + +// Whole minutes keep the wording the device has always used, because most values still are. +void formatting_keeps_whole_minutes_readable() +{ + std::array text{}; + ui::format_duration_value(text.data(), text.size(), 20 * 60); + assert(std::strcmp(text.data(), "20 MIN") == 0); + ui::format_duration_value(text.data(), text.size(), 20 * 60 + 30); + assert(std::strcmp(text.data(), "20:30") == 0); + ui::format_duration_value(text.data(), text.size(), 107); + assert(std::strcmp(text.data(), "1:47") == 0); + ui::format_duration_value(text.data(), text.size(), 0); + assert(std::strcmp(text.data(), "0 MIN") == 0); + ui::format_duration_value(text.data(), text.size(), 5); + assert(std::strcmp(text.data(), "0:05") == 0); +} + +// LVGL calls a press "long" at 400 ms, which committed a value while the driver was still +// deciding. Saving is the only irreversible thing this screen does, so it asks for more. +void a_save_needs_a_deliberate_hold() +{ + assert(ui::kRollerHoldToSaveMs > 400); + + ui::HoldToSave hold{}; + hold.begin(1'000); + assert(hold.active()); + assert(!hold.complete(1'000 + 400)); // LVGL would have fired here + assert(hold.complete(1'000 + ui::kRollerHoldToSaveMs)); + assert(hold.complete(1'000 + ui::kRollerHoldToSaveMs + 500)); +} + +// A hold that wanders is a drag that paused, so it must not save what it rolled past. +void a_hold_that_moves_is_not_a_save() +{ + ui::HoldToSave hold{}; + hold.begin(0); + hold.travel(ui::kRollerHoldTravelLimitPx + 1); + assert(!hold.active()); + assert(!hold.complete(ui::kRollerHoldToSaveMs * 2)); + + // Small movement is tolerated: a finger on a moving car is never perfectly still. + ui::HoldToSave steady{}; + steady.begin(0); + steady.travel(2); + steady.travel(-3); + assert(steady.active()); + assert(steady.complete(ui::kRollerHoldToSaveMs)); +} + +// Without feedback a longer hold reads as an unresponsive screen. +void hold_progress_ramps_from_nothing_to_full() +{ + ui::HoldToSave hold{}; + assert(hold.progress(0) == 0.0F); + hold.begin(500); + assert(hold.progress(500) == 0.0F); + + const auto half = hold.progress(500 + ui::kRollerHoldToSaveMs / 2); + assert(half > 0.4F && half < 0.6F); + assert(hold.progress(500 + ui::kRollerHoldToSaveMs) == 1.0F); + assert(hold.progress(500 + ui::kRollerHoldToSaveMs * 3) == 1.0F); + + hold.end(); + assert(!hold.active()); + assert(hold.progress(500 + ui::kRollerHoldToSaveMs) == 0.0F); +} + +} // namespace + +int main() +{ + the_specs_span_the_whole_validated_range(); + every_second_of_the_range_is_reachable(); + columns_wrap_without_carrying(); + dragging_follows_the_finger(); + a_swipe_moves_one_step(); + a_flick_keeps_rolling_and_settles(); + a_slow_release_does_not_flick(); + touching_a_coasting_column_stops_it(); + committing_clamps_into_the_validated_range(); + clearing_the_average_lap_resets_the_lower_display(); + durations_round_trip_through_the_settings(); + formatting_keeps_whole_minutes_readable(); + a_save_needs_a_deliberate_hold(); + a_hold_that_moves_is_not_a_save(); + hold_progress_ramps_from_nothing_to_full(); + + std::cout << "Time roller: full-range columns, wrap without carry, drag and flick, " + "clamped commit, and deliberate hold-to-save passed\n"; + return 0; +} diff --git a/tests/cpp/test_track_catalog.cpp b/tests/cpp/test_track_catalog.cpp index c9a0576..9cb781a 100644 --- a/tests/cpp/test_track_catalog.cpp +++ b/tests/cpp/test_track_catalog.cpp @@ -43,8 +43,8 @@ std::string read_file(const std::filesystem::path& path) settings::DeviceSettings default_settings() { settings::DeviceSettings value{}; - value.session_duration_minutes = 20; - value.rest_duration_minutes = 20; + value.session_duration_seconds = 20; + value.rest_duration_seconds = 20; value.average_lap_seconds = 0; value.lower_display = settings::LowerDisplayMode::elapsed; return value;