diff --git a/CMakeLists.txt b/CMakeLists.txt index d0b590932e..b90b761958 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -37,6 +37,15 @@ set_property(CACHE TARGET_DEVICE PROPERTY STRINGS PINETIME MOY_TFK5 MOY_TIN5 MOY set(PROJECT_GIT_COMMIT_HASH "") +# Optional custom build tag (e.g. "om001"), appended to the on-watch version +# display (Settings > System info, and the post-flash validation screen) so +# a ClimbLogger dev build is unmistakable from an official InfiniTime +# release at a glance. Left empty for a plain "1.16.0"-style display when +# not set (e.g. simulator builds, or a manual cmake invocation) -- the +# top-level project's `make firmware` target sets this to an incrementing +# om value on every real hardware build. +set(CLIMBLOGGER_BUILD_TAG "" CACHE STRING "Custom build tag suffix shown on-watch (e.g. om001); empty for a plain official-style version") + execute_process(COMMAND git rev-parse --short HEAD WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} OUTPUT_VARIABLE PROJECT_GIT_COMMIT_HASH @@ -53,6 +62,9 @@ message(" * Mode : " ${CMAKE_BUILD_TYPE}) message(" * Version : " ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH}) message(" * Toolchain : " ${ARM_NONE_EABI_TOOLCHAIN_PATH}) message(" * GitRef(S) : " ${PROJECT_GIT_COMMIT_HASH}) +if(CLIMBLOGGER_BUILD_TAG) + message(" * ClimbLogger build tag : " ${CLIMBLOGGER_BUILD_TAG}) +endif() message(" * NRF52 SDK : " ${NRF5_SDK_PATH}) message(" * Target device : " ${TARGET_DEVICE}) if(BUILD_DFU) diff --git a/docker/build.sh b/docker/build.sh index 04963ea0ab..d82f1aefa5 100755 --- a/docker/build.sh +++ b/docker/build.sh @@ -80,6 +80,7 @@ CmakeGenerate() { CMAKE_ARGS=() [ -n "$ENABLE_USERAPPS" ] && CMAKE_ARGS+=("-DENABLE_USERAPPS=$ENABLE_USERAPPS") [ -n "$ENABLE_WATCHFACES" ] && CMAKE_ARGS+=("-DENABLE_WATCHFACES=$ENABLE_WATCHFACES") + [ -n "$CLIMBLOGGER_BUILD_TAG" ] && CMAKE_ARGS+=("-DCLIMBLOGGER_BUILD_TAG=$CLIMBLOGGER_BUILD_TAG") cmake -G "Unix Makefiles" \ -S "$SOURCES_DIR" \ diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index e4a354df64..7655738a56 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -396,6 +396,7 @@ list(APPEND SOURCE_FILES displayapp/screens/PassKey.cpp displayapp/screens/Error.cpp displayapp/screens/Alarm.cpp + displayapp/screens/ClimbLogger.cpp displayapp/screens/Styles.cpp displayapp/screens/WeatherSymbols.cpp displayapp/Colors.cpp diff --git a/src/Version.h.in b/src/Version.h.in index 0d6219c09a..3a5009be8c 100644 --- a/src/Version.h.in +++ b/src/Version.h.in @@ -12,11 +12,16 @@ namespace Pinetime { static constexpr uint32_t Patch() {return patch;} static constexpr const char* GitCommitHash() {return commitHash;} static constexpr const char* VersionString() {return versionString;} + // Empty ("") for an official-style build; a non-empty tag (e.g. + // "om001") marks a custom ClimbLogger dev build -- see CMakeLists.txt's + // CLIMBLOGGER_BUILD_TAG. + static constexpr const char* BuildTag() {return buildTag;} private: static constexpr uint32_t major = @PROJECT_VERSION_MAJOR@; static constexpr uint32_t minor = @PROJECT_VERSION_MINOR@; static constexpr uint32_t patch = @PROJECT_VERSION_PATCH@; static constexpr const char* commitHash = "@PROJECT_GIT_COMMIT_HASH@"; static constexpr const char* versionString = "@PROJECT_VERSION_MAJOR@.@PROJECT_VERSION_MINOR@.@PROJECT_VERSION_PATCH@"; + static constexpr const char* buildTag = "@CLIMBLOGGER_BUILD_TAG@"; }; } \ No newline at end of file diff --git a/src/displayapp/UserApps.h b/src/displayapp/UserApps.h index 25926edc40..8f2415464a 100644 --- a/src/displayapp/UserApps.h +++ b/src/displayapp/UserApps.h @@ -15,6 +15,8 @@ #include "displayapp/screens/WatchFacePineTimeStyle.h" #include "displayapp/screens/WatchFaceTerminal.h" #include "displayapp/screens/WatchFacePrideFlag.h" +#include "displayapp/screens/TapCounter.h" +#include "displayapp/screens/ClimbLogger.h" namespace Pinetime { namespace Applications { diff --git a/src/displayapp/apps/Apps.h.in b/src/displayapp/apps/Apps.h.in index d440b598d1..ad628af6bc 100644 --- a/src/displayapp/apps/Apps.h.in +++ b/src/displayapp/apps/Apps.h.in @@ -29,6 +29,8 @@ namespace Pinetime { Calculator, Steps, Dice, + TapCounter, + ClimbLogger, Weather, PassKey, QuickSettings, diff --git a/src/displayapp/fonts/fonts.json b/src/displayapp/fonts/fonts.json index 3221c2f171..99c7b7b61a 100644 --- a/src/displayapp/fonts/fonts.json +++ b/src/displayapp/fonts/fonts.json @@ -7,7 +7,7 @@ }, { "file": "FontAwesome5-Solid+Brands+Regular.woff", - "range": "0xf294, 0xf242, 0xf54b, 0xf21e, 0xf1e6, 0xf017, 0xf129, 0xf03a, 0xf185, 0xf560, 0xf001, 0xf3fd, 0xf1fc, 0xf45d, 0xf59f, 0xf5a0, 0xf027, 0xf028, 0xf6a9, 0xf04b, 0xf04c, 0xf048, 0xf051, 0xf095, 0xf3dd, 0xf04d, 0xf2f2, 0xf024, 0xf252, 0xf569, 0xf06e, 0xf015, 0xf00c, 0xf0f3, 0xf522, 0xf743, 0xf1ec, 0xf55a, 0xf3ed" + "range": "0xf294, 0xf242, 0xf54b, 0xf21e, 0xf1e6, 0xf017, 0xf129, 0xf03a, 0xf185, 0xf560, 0xf001, 0xf3fd, 0xf1fc, 0xf45d, 0xf59f, 0xf5a0, 0xf027, 0xf028, 0xf6a9, 0xf04b, 0xf04c, 0xf048, 0xf051, 0xf095, 0xf3dd, 0xf04d, 0xf2f2, 0xf024, 0xf252, 0xf569, 0xf06e, 0xf015, 0xf00c, 0xf0f3, 0xf522, 0xf743, 0xf1ec, 0xf55a, 0xf3ed, 0xf6fc" } ], "bpp": 1, diff --git a/src/displayapp/screens/ClimbLogger.cpp b/src/displayapp/screens/ClimbLogger.cpp new file mode 100644 index 0000000000..ea97cb4e75 --- /dev/null +++ b/src/displayapp/screens/ClimbLogger.cpp @@ -0,0 +1,318 @@ +#include "displayapp/screens/ClimbLogger.h" + +#include +#include +#include +#include +#include +#include +#include "components/motor/MotorController.h" +#include "components/datetime/DateTimeController.h" +#include "components/fs/FS.h" + +using namespace Pinetime::Applications::Screens; + +namespace { + // Placeholder option lists (Roadmap Step 2). Gyms/grade ladders are + // still open questions (see ROADMAP.md's cross-cutting open questions + // table) — these are enough to exercise the flow, not the final word. + constexpr const char* gymOptions[] = {"The Castle", "Climbing District", "Other"}; + constexpr const char* styleOptions[] = {"Boulder", "Top Rope", "Lead"}; + constexpr const char* typeOptions[] = {"Slab", "Overhang", "Vertical", "Mixed"}; + constexpr const char* vScaleGrades[] = {"V0", "V1", "V2", "V3", "V4", "V5", "V6", "V7"}; + constexpr const char* fontScaleGrades[] = {"5+", "6a", "6a+", "6b", "6b+", "6c", "6c+", "7a"}; + constexpr const char* attemptOptions[] = {"Send", "Did Not Finish"}; + + constexpr const char* kSelectionFile = "/climbs/last_selection.csv"; + + void ButtonMatrixEventHandler(lv_obj_t* obj, lv_event_t event) { + auto* screen = static_cast(obj->user_data); + screen->OnButtonMatrixEvent(obj, event); + } + + // Finds the literal pointer within `options` matching `text`, so callers + // can point straight at static storage (the same option-list literals + // ShowOptionsStep already compares against) instead of pointing into a + // buffer that won't outlive the read. Returns nullptr on no match (e.g. + // an option list changed since the remembered selection was saved). + const char* FindOption(const char* const* options, size_t count, const char* text) { + if (text == nullptr) { + return nullptr; + } + for (size_t i = 0; i < count; i++) { + if (std::strcmp(options[i], text) == 0) { + return options[i]; + } + } + return nullptr; + } +} + +ClimbLogger::ClimbLogger(Controllers::MotorController& motorController, + Controllers::DateTime& dateTimeController, + Controllers::FS& filesystem) + : motorController {motorController}, dateTimeController {dateTimeController}, filesystem {filesystem} { + LoadRememberedSelections(); + ShowStep(); +} + +ClimbLogger::~ClimbLogger() { + lv_obj_clean(lv_scr_act()); +} + +bool ClimbLogger::IsBoulderStyle() const { + return selectedStyle != nullptr && std::strcmp(selectedStyle, "Boulder") == 0; +} + +void ClimbLogger::ShowStep() { + lv_obj_clean(lv_scr_act()); + titleLabel = nullptr; + buttonMatrix = nullptr; + + switch (step) { + case Step::Gym: + ShowOptionsStep("Gym", gymOptions, std::size(gymOptions), selectedGym); + break; + case Step::Style: + ShowOptionsStep("Style", styleOptions, std::size(styleOptions), selectedStyle); + break; + case Step::Type: + ShowOptionsStep("Type", typeOptions, std::size(typeOptions), selectedType); + break; + case Step::Grade: + if (IsBoulderStyle()) { + ShowOptionsStep("Grade (V)", vScaleGrades, std::size(vScaleGrades), selectedGrade); + } else { + ShowOptionsStep("Grade (Font)", fontScaleGrades, std::size(fontScaleGrades), selectedGrade); + } + break; + case Step::Attempt: + ShowOptionsStep("Attempt", attemptOptions, std::size(attemptOptions), selectedAttempt); + break; + } +} + +void ClimbLogger::ShowOptionsStep(const char* title, const char* const* options, size_t count, const char* rememberedValue) { + titleLabel = lv_label_create(lv_scr_act(), nullptr); + lv_label_set_text_static(titleLabel, title); + lv_obj_align(titleLabel, nullptr, LV_ALIGN_IN_TOP_MID, 0, 10); + + // One button per row reads clearly for a few options; longer lists (the + // grade ladders) go two-per-row so buttons stay a reasonable tap size. + const size_t perRow = count > 4 ? 2 : 1; + + size_t mapIndex = 0; + int rememberedBtnIndex = -1; + for (size_t i = 0; i < count && mapIndex + 1 < optionsMap.size(); i++) { + if (rememberedValue != nullptr && std::strcmp(options[i], rememberedValue) == 0) { + rememberedBtnIndex = static_cast(i); + } + optionsMap[mapIndex++] = options[i]; + if ((i + 1) % perRow == 0 || i + 1 == count) { + optionsMap[mapIndex++] = "\n"; + } + } + if (mapIndex > 0) { + mapIndex--; // drop the trailing row separator before the terminator + } + optionsMap[mapIndex] = ""; + + buttonMatrix = lv_btnmatrix_create(lv_scr_act(), nullptr); + buttonMatrix->user_data = this; + lv_obj_set_event_cb(buttonMatrix, ButtonMatrixEventHandler); + lv_btnmatrix_set_map(buttonMatrix, optionsMap.data()); + lv_obj_set_size(buttonMatrix, 200, 180); + lv_obj_align(buttonMatrix, nullptr, LV_ALIGN_IN_BOTTOM_MID, 0, -5); + + // Row separators don't consume a button index, so options[i] lines up + // directly with button index i regardless of how rows were wrapped. + if (rememberedBtnIndex >= 0) { + lv_btnmatrix_set_one_check(buttonMatrix, true); + lv_btnmatrix_set_btn_ctrl(buttonMatrix, static_cast(rememberedBtnIndex), LV_BTNMATRIX_CTRL_CHECK_STATE); + } +} + +void ClimbLogger::OnButtonMatrixEvent(lv_obj_t* obj, lv_event_t event) { + // CLICKED matters here, not PRESSED or (perhaps counter-intuitively) + // VALUE_CHANGED. lv_btnmatrix's ctrl_bits default LV_BTNMATRIX_CTRL_CLICK_TRIG + // to off (see allocate_btn_areas_and_controls's memset), and with that bit + // off, lv_btnmatrix fires its own VALUE_CHANGED signal on *press*, same + // timing problem as PRESSED. CLICKED, in contrast, is sent generically by + // lv_indev.c itself only once a press/release gesture has fully resolved + // against the *current* object. This handler rebuilds the whole screen + // (destroying this exact button matrix) in response to a selection, so + // anything that can fire on press-down re-triggers against the + // freshly-created matrix at the same coordinates while the input device + // is still mid-gesture, cascading through several steps on one tap. + if (obj != buttonMatrix || event != LV_EVENT_CLICKED) { + return; + } + const char* text = lv_btnmatrix_get_active_btn_text(buttonMatrix); + if (text == nullptr) { + return; + } + OnOptionSelected(text); +} + +void ClimbLogger::OnOptionSelected(const char* text) { + switch (step) { + case Step::Gym: + selectedGym = text; + step = Step::Style; + break; + + case Step::Style: + selectedStyle = text; + step = Step::Type; + break; + + case Step::Type: + selectedType = text; + step = Step::Grade; + break; + + case Step::Grade: + selectedGrade = text; + step = Step::Attempt; + break; + + case Step::Attempt: + selectedAttempt = text; + LogAndReset(); + return; // LogAndReset() already redraws the next screen + } + ShowStep(); +} + +void ClimbLogger::LogAndReset() { + WriteLogEntry(); + SaveRememberedSelections(); + motorController.RunForDuration(30); + + // Deliberately not clearing selectedGym/selectedStyle/selectedType/ + // selectedGrade/selectedAttempt: they double as next log's remembered + // defaults, pre-checked in ShowOptionsStep. + step = Step::Gym; + ShowStep(); +} + +void ClimbLogger::WriteLogEntry() { + // UTC, ISO 8601 ("Z" = zero offset) -- deliberately not local time. + // dateTimeController.Year()/Hours()/etc. read `localTime`, adjusted by + // whatever timezone/DST offset the companion app last sent over BLE + // (0 if it never has, e.g. in the simulator); UTCDateTime() undoes that + // adjustment. Storing UTC keeps the log unambiguous regardless of + // where/whether that offset was ever set correctly -- local-time + // display, if wanted, is a host-side concern for whatever ingests + // log.csv later (Step 6), not something to bake into the file. + const std::time_t utcTimeT = std::chrono::system_clock::to_time_t(dateTimeController.UTCDateTime()); + const std::tm* utcTm = std::gmtime(&utcTimeT); + + // Same field order as CLAUDE.md's denormalised log record (timestamp, + // then a copy of each catalog-ish field so the row still means + // something after Gym/Style/Type/Grade option lists change later). + char line[128]; + int len = std::snprintf(line, + sizeof(line), + "%04d-%02d-%02dT%02d:%02d:%02dZ,%s,%s,%s,%s,%s", + utcTm->tm_year + 1900, + utcTm->tm_mon + 1, + utcTm->tm_mday, + utcTm->tm_hour, + utcTm->tm_min, + utcTm->tm_sec, + selectedGym, + selectedStyle, + selectedType, + selectedGrade, + selectedAttempt); + if (len <= 0) { + return; + } + const size_t writeLen = static_cast(len) < sizeof(line) ? static_cast(len) : sizeof(line) - 1; + + // Directory may not exist yet on a fresh filesystem — same + // open-then-create-on-failure idiom AlarmController uses for + // /.system before writing into it. + lfs_dir_t climbsDir; + if (filesystem.DirOpen("/climbs", &climbsDir) != LFS_ERR_OK) { + filesystem.DirCreate("/climbs"); + } + filesystem.DirClose(&climbsDir); + + lfs_file_t logFile; + if (filesystem.FileOpen(&logFile, "/climbs/log.csv", LFS_O_WRONLY | LFS_O_CREAT | LFS_O_APPEND) != LFS_ERR_OK) { + NRF_LOG_WARNING("[ClimbLogger] Failed to open log.csv for appending"); + return; + } + filesystem.FileWrite(&logFile, reinterpret_cast(line), writeLen); + filesystem.FileWrite(&logFile, reinterpret_cast("\n"), 1); + filesystem.FileClose(&logFile); + + // Host-visible confirmation that the write actually happened, on top of + // (not instead of) the real file write above -- helpful during sim + // development without needing a make pull-log round-trip every time. + NRF_LOG_INFO("ClimbLogger: logged %s", line); +} + +void ClimbLogger::SaveRememberedSelections() { + // LogAndReset() is the only caller, after all five steps are picked, so + // these should never be null here -- but guard anyway rather than write + // a line with a literal "(null)" in it if that assumption ever breaks. + if (selectedGym == nullptr || selectedStyle == nullptr || selectedType == nullptr || selectedGrade == nullptr || + selectedAttempt == nullptr) { + return; + } + + char line[96]; + int len = + std::snprintf(line, sizeof(line), "%s,%s,%s,%s,%s", selectedGym, selectedStyle, selectedType, selectedGrade, selectedAttempt); + if (len <= 0) { + return; + } + const size_t writeLen = static_cast(len) < sizeof(line) ? static_cast(len) : sizeof(line) - 1; + + // /climbs is guaranteed to already exist by the time this runs -- it's + // only ever called right after WriteLogEntry(), which creates it first. + lfs_file_t file; + if (filesystem.FileOpen(&file, kSelectionFile, LFS_O_WRONLY | LFS_O_CREAT | LFS_O_TRUNC) != LFS_ERR_OK) { + NRF_LOG_WARNING("[ClimbLogger] Failed to open last_selection.csv for writing"); + return; + } + filesystem.FileWrite(&file, reinterpret_cast(line), writeLen); + filesystem.FileClose(&file); +} + +void ClimbLogger::LoadRememberedSelections() { + lfs_file_t file; + if (filesystem.FileOpen(&file, kSelectionFile, LFS_O_RDONLY) != LFS_ERR_OK) { + return; // nothing remembered yet (fresh filesystem, or never logged) -- fine, steps just start blank + } + char buf[96]; + int readLen = filesystem.FileRead(&file, reinterpret_cast(buf), sizeof(buf) - 1); + filesystem.FileClose(&file); + if (readLen <= 0) { + return; + } + buf[readLen] = '\0'; + + // strtok, not strtok_r: this runs single-threaded on the display task, so + // the non-reentrant version's shared internal state isn't a concern here. + const char* gymText = std::strtok(buf, ",\n"); + const char* styleText = gymText != nullptr ? std::strtok(nullptr, ",\n") : nullptr; + const char* typeText = styleText != nullptr ? std::strtok(nullptr, ",\n") : nullptr; + const char* gradeText = typeText != nullptr ? std::strtok(nullptr, ",\n") : nullptr; + const char* attemptText = gradeText != nullptr ? std::strtok(nullptr, ",\n") : nullptr; + + // Look up each parsed token against the current option lists rather than + // pointing straight at `buf` (a local that won't outlive this call) -- + // also means a stale value from a since-changed option list quietly + // resolves to nullptr (no pre-check) instead of a dangling/bogus pointer. + selectedGym = FindOption(gymOptions, std::size(gymOptions), gymText); + selectedStyle = FindOption(styleOptions, std::size(styleOptions), styleText); + selectedType = FindOption(typeOptions, std::size(typeOptions), typeText); + // Grade ladder depends on style, already resolved above. + selectedGrade = IsBoulderStyle() ? FindOption(vScaleGrades, std::size(vScaleGrades), gradeText) + : FindOption(fontScaleGrades, std::size(fontScaleGrades), gradeText); + selectedAttempt = FindOption(attemptOptions, std::size(attemptOptions), attemptText); +} diff --git a/src/displayapp/screens/ClimbLogger.h b/src/displayapp/screens/ClimbLogger.h new file mode 100644 index 0000000000..152b7b4212 --- /dev/null +++ b/src/displayapp/screens/ClimbLogger.h @@ -0,0 +1,101 @@ +#pragma once + +#include +#include +#include "displayapp/apps/Apps.h" +#include "displayapp/screens/Screen.h" +#include "displayapp/Controllers.h" +#include "displayapp/screens/Symbols.h" +#include + +namespace Pinetime { + namespace Controllers { + class MotorController; + class DateTime; + } + + namespace Applications { + namespace Screens { + // Gym -> Style -> Type -> Grade -> Attempt, then logged immediately + // (no separate confirm step). A single Screen instance rebuilds its + // button matrix per step rather than pushing separate Screen + // objects, matching the pattern FirmwareUpdate.h uses for its own + // multi-step flow with an internal `enum class States`. + // + // Every step remembers the previous log's choice: the matching + // option is pre-checked when a step's screen opens, so repeating the + // same climb type is a single confirming tap per step. Remembered + // values are written to a small /climbs/last_selection.csv after + // each successful log and read back in the constructor, so they + // survive this Screen object being destroyed and recreated -- + // whether from exiting the app, screen timeout, or anything else -- + // rather than relying on in-memory state alone. See + // LoadRememberedSelections / SaveRememberedSelections. + class ClimbLogger : public Screen { + public: + ClimbLogger(Controllers::MotorController& motorController, + Controllers::DateTime& dateTimeController, + Controllers::FS& filesystem); + ~ClimbLogger() override; + + void OnButtonMatrixEvent(lv_obj_t* obj, lv_event_t event); + + // Max distinct options offered at any one step. Public so the free + // helper functions in ClimbLogger.cpp (which build those option + // lists ahead of a ClimbLogger method call) can size their buffers + // against it. + static constexpr size_t kMaxOptions = 8; + + private: + enum class Step { Gym, Style, Type, Grade, Attempt }; + + Controllers::MotorController& motorController; + Controllers::DateTime& dateTimeController; + Controllers::FS& filesystem; + + Step step = Step::Gym; + + // Current step's picks, and — since never cleared on log — also + // next time's remembered defaults. + const char* selectedGym = nullptr; + const char* selectedStyle = nullptr; + const char* selectedType = nullptr; + const char* selectedGrade = nullptr; + const char* selectedAttempt = nullptr; + + // Backing storage for the lv_btnmatrix map of the current step + // ("\n"-separated rows, "" terminator) — sized for kMaxOptions + // buttons plus one separator per row plus the terminator. + std::array optionsMap {}; + + lv_obj_t* titleLabel = nullptr; + lv_obj_t* buttonMatrix = nullptr; + + void ShowStep(); + void ShowOptionsStep(const char* title, const char* const* options, size_t count, const char* rememberedValue); + + void OnOptionSelected(const char* text); + void LogAndReset(); + void WriteLogEntry(); + void LoadRememberedSelections(); + void SaveRememberedSelections(); + + bool IsBoulderStyle() const; + }; + } + + template <> + struct AppTraits { + static constexpr Apps app = Apps::ClimbLogger; + static constexpr const char* icon = Screens::Symbols::mountain; + + static Screens::Screen* Create(AppControllers& controllers) { + return new Screens::ClimbLogger(controllers.motorController, controllers.dateTimeController, controllers.filesystem); + } + + static bool IsAvailable(Pinetime::Controllers::FS& /*filesystem*/) { + return true; + } + }; + } +} diff --git a/src/displayapp/screens/FirmwareValidation.cpp b/src/displayapp/screens/FirmwareValidation.cpp index 8fc9d251cb..0b1421525d 100644 --- a/src/displayapp/screens/FirmwareValidation.cpp +++ b/src/displayapp/screens/FirmwareValidation.cpp @@ -1,4 +1,6 @@ #include "displayapp/screens/FirmwareValidation.h" +#include +#include #include #include "Version.h" #include "components/firmwarevalidator/FirmwareValidator.h" @@ -29,12 +31,24 @@ FirmwareValidation::FirmwareValidation(Pinetime::Controllers::FirmwareValidator& labelVersion = lv_label_create(lv_scr_act(), nullptr); lv_label_set_recolor(labelVersion, true); + + char versionStr[24]; + if (Version::BuildTag()[0] != '\0') { + snprintf(versionStr, + sizeof(versionStr), + "%" PRIu32 ".%" PRIu32 ".%" PRIu32 ".%s", + Version::Major(), + Version::Minor(), + Version::Patch(), + Version::BuildTag()); + } else { + snprintf(versionStr, sizeof(versionStr), "%" PRIu32 ".%" PRIu32 ".%" PRIu32, Version::Major(), Version::Minor(), Version::Patch()); + } + lv_label_set_text_fmt(labelVersion, - "#808080 Version# %lu.%lu.%lu\n" + "#808080 Version# %s\n" "#808080 Short Ref# %s\n", - Version::Major(), - Version::Minor(), - Version::Patch(), + versionStr, Version::GitCommitHash()); lv_obj_align(labelVersion, nullptr, LV_ALIGN_CENTER, 0, -40); lv_label_set_align(labelVersion, LV_LABEL_ALIGN_CENTER); diff --git a/src/displayapp/screens/Symbols.h b/src/displayapp/screens/Symbols.h index 058b2d06e9..18b5190370 100644 --- a/src/displayapp/screens/Symbols.h +++ b/src/displayapp/screens/Symbols.h @@ -42,6 +42,7 @@ namespace Pinetime { static constexpr const char* sleep = "\xEE\xBD\x84"; static constexpr const char* calculator = "\xEF\x87\xAC"; static constexpr const char* backspace = "\xEF\x95\x9A"; + static constexpr const char* mountain = "\xEF\x9B\xBC"; // fontawesome_weathericons.c // static constexpr const char* sun = "\xEF\x86\x85"; diff --git a/src/displayapp/screens/SystemInfo.cpp b/src/displayapp/screens/SystemInfo.cpp index 2392f3be3e..4efbddb8ee 100644 --- a/src/displayapp/screens/SystemInfo.cpp +++ b/src/displayapp/screens/SystemInfo.cpp @@ -1,5 +1,7 @@ #include #include +#include +#include #include #include "displayapp/screens/SystemInfo.h" #include @@ -79,17 +81,29 @@ bool SystemInfo::OnTouchEvent(Pinetime::Applications::TouchEvents event) { std::unique_ptr SystemInfo::CreateScreen1() { lv_obj_t* label = lv_label_create(lv_scr_act(), nullptr); lv_label_set_recolor(label, true); + + char versionStr[24]; + if (Version::BuildTag()[0] != '\0') { + snprintf(versionStr, + sizeof(versionStr), + "%" PRIu32 ".%" PRIu32 ".%" PRIu32 ".%s", + Version::Major(), + Version::Minor(), + Version::Patch(), + Version::BuildTag()); + } else { + snprintf(versionStr, sizeof(versionStr), "%" PRIu32 ".%" PRIu32 ".%" PRIu32, Version::Major(), Version::Minor(), Version::Patch()); + } + lv_label_set_text_fmt(label, "#FFFF00 InfiniTime#\n\n" - "#808080 Version# %ld.%ld.%ld\n" + "#808080 Version# %s\n" "#808080 Short Ref# %s\n" "#808080 Build date#\n" "%s\n" "%s\n\n" "#808080 Bootloader# %s", - Version::Major(), - Version::Minor(), - Version::Patch(), + versionStr, Version::GitCommitHash(), __DATE__, __TIME__, diff --git a/src/displayapp/screens/TapCounter.cpp b/src/displayapp/screens/TapCounter.cpp new file mode 100644 index 0000000000..0a37d9ee79 --- /dev/null +++ b/src/displayapp/screens/TapCounter.cpp @@ -0,0 +1,45 @@ +#include "displayapp/screens/TapCounter.h" + +using namespace Pinetime::Applications::Screens; + +namespace { + // LVGL is a C library, so events arrive via a plain function pointer. + // The standard InfiniTime pattern: stash `this` in the widget's + // user_data, then bounce the event back into a member function. + void ButtonEventHandler(lv_obj_t* obj, lv_event_t event) { + auto* screen = static_cast(obj->user_data); + if (event == LV_EVENT_CLICKED) { + screen->OnButtonClicked(); + } + } +} + +TapCounter::TapCounter() { + // The constructor builds the UI. lv_scr_act() is the active screen; + // every widget is parented to it (or to another widget). + + counterLabel = lv_label_create(lv_scr_act(), nullptr); + lv_label_set_text_fmt(counterLabel, "Count: %d", count); + lv_label_set_align(counterLabel, LV_LABEL_ALIGN_CENTER); + lv_obj_align(counterLabel, lv_scr_act(), LV_ALIGN_IN_TOP_MID, 0, 60); + + button = lv_btn_create(lv_scr_act(), nullptr); + button->user_data = this; + lv_obj_set_event_cb(button, ButtonEventHandler); + lv_obj_set_size(button, 200, 80); + lv_obj_align(button, lv_scr_act(), LV_ALIGN_IN_BOTTOM_MID, 0, -20); + + buttonLabel = lv_label_create(button, nullptr); + lv_label_set_text_static(buttonLabel, "Tap me"); +} + +TapCounter::~TapCounter() { + // Destructor must clean up everything LVGL created, otherwise the + // widgets would leak into whatever screen is shown next. + lv_obj_clean(lv_scr_act()); +} + +void TapCounter::OnButtonClicked() { + count++; + lv_label_set_text_fmt(counterLabel, "Count: %d", count); +} diff --git a/src/displayapp/screens/TapCounter.h b/src/displayapp/screens/TapCounter.h new file mode 100644 index 0000000000..3adc2188dd --- /dev/null +++ b/src/displayapp/screens/TapCounter.h @@ -0,0 +1,54 @@ +#pragma once + +#include "displayapp/apps/Apps.h" +#include "displayapp/screens/Screen.h" +#include "displayapp/Controllers.h" +#include "displayapp/screens/Symbols.h" +#include + +namespace Pinetime { + namespace Applications { + namespace Screens { + + // A minimal user app: one button, one counter label. + // An instance is created when the app is opened from the launcher, + // and destroyed when you leave the app (state is NOT preserved). + class TapCounter : public Screen { + public: + TapCounter(); + ~TapCounter() override; + + // Called from the LVGL event callback when the button is clicked + void OnButtonClicked(); + + private: + int count = 0; + + lv_obj_t* counterLabel = nullptr; + lv_obj_t* button = nullptr; + lv_obj_t* buttonLabel = nullptr; + }; + } + + // AppTraits is how InfiniTime's build-time app registry learns about + // this app: its enum id, its launcher icon, and how to construct it. + // CreateAppDescriptions() in UserApps.h reads this at compile time for + // every app listed in ENABLE_USERAPPS. + template <> + struct AppTraits { + static constexpr Apps app = Apps::TapCounter; + // Any constant from displayapp/screens/Symbols.h works here + static constexpr const char* icon = Screens::Symbols::check; + + static Screens::Screen* Create(AppControllers& /*controllers*/) { + // If your app later needs the date, heart rate, settings, etc., + // pass references from `controllers` into your constructor here. + return new Screens::TapCounter(); + } + + static bool IsAvailable(Pinetime::Controllers::FS& /*filesystem*/) { + return true; + } + }; + } +}