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;