From 0ab30d4b6e5f5fd350746bb72a91dba035c341e5 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Fri, 16 Oct 2026 00:36:03 +0200 Subject: [PATCH] Setup improvements --- Documentation/ideas.md | 1 + .../include/location/location.h | 16 + .../private/location/ip_provider.h | 4 +- .../location-module/source/ip_provider.cpp | 36 ++- Modules/location-module/source/module.cpp | 1 + .../tests/source/ip_provider_test.cpp | 20 +- .../include/lvgl/widgets/step_indicator.h | 36 +++ Modules/lvgl-module/source/symbols.c | 5 + .../source/themes/lv_theme_material.c | 52 +++- .../source/themes/lv_theme_material_mono.c | 40 ++- .../source/widgets/step_indicator.cpp | 82 +++++ Modules/lvgl-module/themes/material.lss | 54 +++- Modules/lvgl-module/themes/material_mono.lss | 40 ++- .../Tactility/settings/RadioSettings.h | 8 + .../Tactility/app/timezone/TimeZoneView.h | 81 +++++ Tactility/Source/app/setup/Setup.cpp | 242 ++++++++++---- Tactility/Source/app/timezone/TimeZone.cpp | 209 ++----------- .../Source/app/timezone/TimeZoneView.cpp | 294 ++++++++++++++++++ Tactility/Source/settings/RadioSettings.cpp | 59 ++++ Tactility/Source/settings/time.cpp | 12 + 20 files changed, 1048 insertions(+), 244 deletions(-) create mode 100644 Modules/lvgl-module/include/lvgl/widgets/step_indicator.h create mode 100644 Modules/lvgl-module/source/widgets/step_indicator.cpp create mode 100644 Tactility/Private/Tactility/app/timezone/TimeZoneView.h create mode 100644 Tactility/Source/app/timezone/TimeZoneView.cpp diff --git a/Documentation/ideas.md b/Documentation/ideas.md index ee5280a25..a3efcc896 100644 --- a/Documentation/ideas.md +++ b/Documentation/ideas.md @@ -7,6 +7,7 @@ ## Higher Priority +- Remove screenshot app and replace with tt.py + screenshot.py: "tt screenshot [filepath]". if filepath isn't specified, save as screenshot-[yyyy-mm-dd].png - BlueTooth as radio backend for MeshCore/Meshtastic? - ESP-NOW as radio backend. Use it for MeshCore/Meshtastic? - Find out why the first build of an app with a clean build cache folder always fails diff --git a/Modules/location-module/include/location/location.h b/Modules/location-module/include/location/location.h index dd2fdfea0..00e84217c 100644 --- a/Modules/location-module/include/location/location.h +++ b/Modules/location-module/include/location/location.h @@ -2,6 +2,7 @@ #pragma once #include +#include #include #include @@ -20,6 +21,9 @@ extern "C" { #define LOCATION_PROVIDER_ID_MAX_LENGTH 15 +/** Maximum length of an IANA time zone name from location_lookup_time_zone(), excluding the NUL terminator */ +#define LOCATION_TIME_ZONE_MAX_LENGTH 63 + /** Horizontal accuracy in meters of a fix with LOCATION_PRECISION_APPROXIMATE */ #define LOCATION_APPROXIMATE_ACCURACY_METERS 3000.0f @@ -115,6 +119,18 @@ error_t location_unsubscribe(struct LocationSubscription* sub); /** @return true when location services are enabled and there is at least one subscription */ bool location_is_in_use(void); +/** + * Look up the IANA time zone name (e.g. "Europe/Brussels") of the device's public IP address with ip-api.com. + * Blocks for up to about 10 seconds, so call it from an app's own task. + * @param[out] out_name the time zone name + * @param[in] out_size the size of @a out_name, at least LOCATION_TIME_ZONE_MAX_LENGTH + 1 for any name + * @retval ERROR_INVALID_STATE location services are disabled + * @retval ERROR_RESOURCE the request failed, e.g. without network + * @retval ERROR_NOT_FOUND ip-api.com returned no time zone + * @retval ERROR_BUFFER_OVERFLOW the name doesn't fit in @a out_name + */ +error_t location_lookup_time_zone(char* out_name, size_t out_size); + #ifdef __cplusplus } #endif diff --git a/Modules/location-module/private/location/ip_provider.h b/Modules/location-module/private/location/ip_provider.h index c2d67f2f6..881bb071c 100644 --- a/Modules/location-module/private/location/ip_provider.h +++ b/Modules/location-module/private/location/ip_provider.h @@ -31,10 +31,12 @@ struct IpApiResponse { char message[IP_API_MESSAGE_MAX_LENGTH + 1]; double latitude; double longitude; + /** IANA time zone name, empty when unknown */ + char time_zone[LOCATION_TIME_ZONE_MAX_LENGTH + 1]; }; /** - * Parse an HTTP response of the newline separated ip-api endpoint, requested with fields "status,message,lat,lon" + * Parse an HTTP response of the newline separated ip-api endpoint, requested with fields "status,message,lat,lon,timezone" * @param[in] response the NUL-terminated response, including the status line and headers * @return false when the response is malformed */ diff --git a/Modules/location-module/source/ip_provider.cpp b/Modules/location-module/source/ip_provider.cpp index 4a685d8db..c25b77b13 100644 --- a/Modules/location-module/source/ip_provider.cpp +++ b/Modules/location-module/source/ip_provider.cpp @@ -32,7 +32,7 @@ namespace { // The free ip-api endpoint only supports plain HTTP constexpr auto* API_HOST = "ip-api.com"; -constexpr auto* API_PATH = "/line/?fields=status,message,lat,lon"; +constexpr auto* API_PATH = "/line/?fields=status,message,lat,lon,timezone"; constexpr uint64_t RETRY_INTERVAL_MS = 60 * 1000; // Bounds the request rate when the network connects repeatedly constexpr uint64_t MIN_INTERVAL_MS = 10 * 1000; @@ -337,6 +337,12 @@ bool waitStopped(void* context, TickType_t timeout) { return true; } +/** The characters of IANA time zone names, e.g. "America/Port-au-Prince" or "Etc/GMT+5" */ +bool isTimeZoneCharacter(char character) { + return (character >= 'A' && character <= 'Z') || (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') || + character == '_' || character == '+' || character == '-' || character == '/'; +} + std::string_view nextLine(std::string_view& text) { const size_t end = text.find('\n'); std::string_view line = text.substr(0, end); @@ -369,6 +375,27 @@ void ip_provider_untrack_network(void) { } } +error_t location_lookup_time_zone(char* out_name, size_t out_size) { + if (!location_is_enabled()) { + return ERROR_INVALID_STATE; + } + tt::std::string response(RESPONSE_MAX_LENGTH, '\0'); + IpApiResponse parsed; + if (!httpGet(API_HOST, API_PATH, response.data(), response.size()) || !ip_api_parse_response(response.c_str(), &parsed) || + parsed.status_code != 200 || !parsed.success) { + return ERROR_RESOURCE; + } + const size_t length = strlen(parsed.time_zone); + if (length == 0) { + return ERROR_NOT_FOUND; + } + if (length >= out_size) { + return ERROR_BUFFER_OVERFLOW; + } + memcpy(out_name, parsed.time_zone, length + 1); + return ERROR_NONE; +} + bool ip_api_parse_response(const char* response, IpApiResponse* out) { *out = IpApiResponse { .status_code = 0, @@ -378,6 +405,7 @@ bool ip_api_parse_response(const char* response, IpApiResponse* out) { .message = {}, .latitude = NAN, .longitude = NAN, + .time_zone = {}, }; std::string_view text = response; @@ -427,6 +455,12 @@ bool ip_api_parse_response(const char* response, IpApiResponse* out) { std::fabs(out->latitude) > 90.0 || std::fabs(out->longitude) > 180.0) { return false; } + const std::string_view timeZone = nextLine(text); + if (timeZone.size() > LOCATION_TIME_ZONE_MAX_LENGTH || !std::ranges::all_of(timeZone, isTimeZoneCharacter)) { + return false; + } + memcpy(out->time_zone, timeZone.data(), timeZone.size()); + out->time_zone[timeZone.size()] = '\0'; out->success = true; } else if (status == "fail") { const std::string_view message = nextLine(text).substr(0, IP_API_MESSAGE_MAX_LENGTH); diff --git a/Modules/location-module/source/module.cpp b/Modules/location-module/source/module.cpp index f66755808..62c956733 100644 --- a/Modules/location-module/source/module.cpp +++ b/Modules/location-module/source/module.cpp @@ -51,6 +51,7 @@ static const ModuleSymbol location_module_symbols[] = { DEFINE_MODULE_SYMBOL(location_subscribe), DEFINE_MODULE_SYMBOL(location_unsubscribe), DEFINE_MODULE_SYMBOL(location_is_in_use), + DEFINE_MODULE_SYMBOL(location_lookup_time_zone), MODULE_SYMBOL_TERMINATOR }; diff --git a/Modules/location-module/tests/source/ip_provider_test.cpp b/Modules/location-module/tests/source/ip_provider_test.cpp index 34c1c5862..11524a257 100644 --- a/Modules/location-module/tests/source/ip_provider_test.cpp +++ b/Modules/location-module/tests/source/ip_provider_test.cpp @@ -3,6 +3,7 @@ #include #include +#include TEST_CASE("ip_api_parse_response parses a successful lookup") { const char* response = @@ -13,17 +14,34 @@ TEST_CASE("ip_api_parse_response parses a successful lookup") { "\r\n" "success\n" "39.03\n" - "-77.5\n"; + "-77.5\n" + "America/New_York\n"; IpApiResponse parsed; REQUIRE(ip_api_parse_response(response, &parsed)); CHECK(parsed.status_code == 200); CHECK(parsed.success); CHECK(parsed.latitude == doctest::Approx(39.03)); CHECK(parsed.longitude == doctest::Approx(-77.5)); + CHECK(strcmp(parsed.time_zone, "America/New_York") == 0); CHECK(parsed.rate_remaining == 44); CHECK(parsed.rate_reset_seconds == 60); } +TEST_CASE("ip_api_parse_response accepts a lookup without a time zone") { + const char* response = "HTTP/1.1 200 OK\r\n\r\nsuccess\n52.37\n4.89\n"; + IpApiResponse parsed; + REQUIRE(ip_api_parse_response(response, &parsed)); + CHECK(parsed.success); + CHECK(strcmp(parsed.time_zone, "") == 0); +} + +TEST_CASE("ip_api_parse_response rejects an invalid time zone") { + IpApiResponse parsed; + CHECK_FALSE(ip_api_parse_response("HTTP/1.1 200 OK\r\n\r\nsuccess\n52.37\n4.89\n../etc/passwd x\n", &parsed)); + const std::string tooLong = "HTTP/1.1 200 OK\r\n\r\nsuccess\n52.37\n4.89\n" + std::string(LOCATION_TIME_ZONE_MAX_LENGTH + 1, 'A') + "\n"; + CHECK_FALSE(ip_api_parse_response(tooLong.c_str(), &parsed)); +} + TEST_CASE("ip_api_parse_response parses a failed lookup") { const char* response = "HTTP/1.1 200 OK\r\nx-rl: 0\r\nx-ttl: 12\r\n\r\nfail\nprivate range\n"; IpApiResponse parsed; diff --git a/Modules/lvgl-module/include/lvgl/widgets/step_indicator.h b/Modules/lvgl-module/include/lvgl/widgets/step_indicator.h new file mode 100644 index 000000000..d612032ef --- /dev/null +++ b/Modules/lvgl-module/include/lvgl/widgets/step_indicator.h @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Creates a step indicator: a horizontal row of line segments, one per step, that shows the progress through a number of steps. + * The segments share the indicator's width. The theme determines their thickness, the gap between them, and the colours of finished and unfinished steps. + * @param[in] parent the parent object + * @return the created step indicator + */ +lv_obj_t* lvgl_step_indicator_create(lv_obj_t* parent); + +/** + * @brief Sets the number of steps, which is the number of segments. + * @param[in] obj the step indicator + * @param[in] count the number of steps + */ +void lvgl_step_indicator_set_step_count(lv_obj_t* obj, uint32_t count); + +/** + * @brief Sets how many steps are finished: the segments of the first @p finished_count steps show as finished. + * @param[in] obj the step indicator + * @param[in] finished_count the number of finished steps + */ +void lvgl_step_indicator_set_finished_count(lv_obj_t* obj, uint32_t finished_count); + +#ifdef __cplusplus +} +#endif diff --git a/Modules/lvgl-module/source/symbols.c b/Modules/lvgl-module/source/symbols.c index 4493045f8..c7729380e 100644 --- a/Modules/lvgl-module/source/symbols.c +++ b/Modules/lvgl-module/source/symbols.c @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -130,6 +131,10 @@ const struct ModuleSymbol lvgl_module_symbols[] = { DEFINE_MODULE_SYMBOL(lvgl_page_indicator_create), DEFINE_MODULE_SYMBOL(lvgl_page_indicator_set_page_count), DEFINE_MODULE_SYMBOL(lvgl_page_indicator_set_page), + // lvgl_step_indicator + DEFINE_MODULE_SYMBOL(lvgl_step_indicator_create), + DEFINE_MODULE_SYMBOL(lvgl_step_indicator_set_step_count), + DEFINE_MODULE_SYMBOL(lvgl_step_indicator_set_finished_count), // lvgl_card DEFINE_MODULE_SYMBOL(lvgl_card_create), // lvgl_chip diff --git a/Modules/lvgl-module/source/themes/lv_theme_material.c b/Modules/lvgl-module/source/themes/lv_theme_material.c index 08dbcf93a..07893d0db 100644 --- a/Modules/lvgl-module/source/themes/lv_theme_material.c +++ b/Modules/lvgl-module/source/themes/lv_theme_material.c @@ -19,6 +19,8 @@ extern const lv_obj_class_t lvgl_chip_class; extern const lv_obj_class_t lvgl_badge_class; extern const lv_obj_class_t lvgl_page_indicator_class; extern const lv_obj_class_t lvgl_page_indicator_dot_class; +extern const lv_obj_class_t lvgl_step_indicator_class; +extern const lv_obj_class_t lvgl_step_indicator_segment_class; extern const lv_obj_class_t lvgl_list_class; extern const lv_obj_class_t lvgl_list_text_class; extern const lv_obj_class_t lvgl_list_button_class; @@ -150,6 +152,11 @@ extern const lv_obj_class_t lvgl_text_button_class; #define PAGE_INDICATOR_COLOR COLOR_ON_SURFACE_VARIANT #define PAGE_INDICATOR_DOT_SIZE (theme->config.is_compact ? 6 : LV_DPX_CALC(theme->disp_dpi, 8)) #define PAGE_INDICATOR_GAP (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6)) +#define STEP_INDICATOR_FINISHED_COLOR COLOR_SECONDARY +#define STEP_INDICATOR_UNFINISHED_COLOR (lv_color_mix(lv_color_black(), COLOR_SECONDARY, LV_OPA_50)) +#define STEP_INDICATOR_THICKNESS (LV_DPX_CALC(theme->disp_dpi, 6)) +#define STEP_INDICATOR_MARGIN (LV_DPX_CALC(theme->disp_dpi, 6)) +#define STEP_INDICATOR_GAP STEP_INDICATOR_MARGIN #define BADGE_COLOR COLOR_SECONDARY #define BADGE_SIZE (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6)) #define SCROLLBAR_COLOR COLOR_OUTLINE @@ -266,7 +273,7 @@ extern const lv_obj_class_t lvgl_text_button_class; #define KEY_CHECKED_TEXT_COLOR COLOR_ON_SECONDARY_CONTAINER #define KEY_SELECTED_COLOR COLOR_PRIMARY #define KEY_SELECTED_TEXT_COLOR COLOR_ON_PRIMARY -#define LIST_COLOR COLOR_SURFACE +#define LIST_COLOR CARD_COLOR #define LIST_PAD DENSITY_LIST_PAD #define LIST_ITEM_COLOR COLOR_SURFACE #define LIST_ITEM_OPACITY LV_OPA_TRANSP @@ -276,7 +283,9 @@ extern const lv_obj_class_t lvgl_text_button_class; #define LIST_ITEM_GAP (theme->config.is_compact ? 3 : SPACE_4) #define LIST_ITEM_FOCUSED_COLOR COLOR_SECONDARY_CONTAINER #define LIST_ITEM_FOCUSED_TEXT_COLOR COLOR_ON_SECONDARY_CONTAINER -#define LIST_DIVIDER_COLOR COLOR_OUTLINE_VARIANT +#define LIST_ITEM_SELECTED_COLOR COLOR_SECONDARY_CONTAINER +#define LIST_ITEM_SELECTED_TEXT_COLOR COLOR_ON_SECONDARY_CONTAINER +#define LIST_DIVIDER_COLOR (lv_color_mix(COLOR_SECONDARY, LIST_COLOR, 89)) #define LIST_DIVIDER_WIDTH DENSITY_BORDER #define LIST_SUBHEADER_TEXT_COLOR COLOR_ON_SURFACE_VARIANT #define TABS_COLOR CARD_COLOR @@ -357,6 +366,9 @@ typedef struct { #endif lv_style_t page_indicator_dot; lv_style_t page_indicator_dot_current; + lv_style_t step_indicator; + lv_style_t step_indicator_segment; + lv_style_t step_indicator_segment_finished; lv_style_t badge; lv_style_t chip; lv_style_t chip_selected; @@ -500,6 +512,7 @@ typedef struct { lv_style_t list; lv_style_t list_item; lv_style_t list_item_focused; + lv_style_t list_item_selected; lv_style_t list_subheader; #if LV_USE_TABVIEW lv_style_t tabs; @@ -798,6 +811,22 @@ static void style_init(my_theme_t * theme) style_init_reset(&theme->styles.page_indicator_dot_current); lv_style_set_bg_opa(&theme->styles.page_indicator_dot_current, LV_OPA_COVER); + style_init_reset(&theme->styles.step_indicator); + lv_style_set_bg_opa(&theme->styles.step_indicator, LV_OPA_TRANSP); + lv_style_set_border_width(&theme->styles.step_indicator, 0); + lv_style_set_pad_all(&theme->styles.step_indicator, STEP_INDICATOR_MARGIN); + lv_style_set_pad_gap(&theme->styles.step_indicator, STEP_INDICATOR_GAP); + + style_init_reset(&theme->styles.step_indicator_segment); + lv_style_set_height(&theme->styles.step_indicator_segment, STEP_INDICATOR_THICKNESS); + lv_style_set_radius(&theme->styles.step_indicator_segment, LV_RADIUS_CIRCLE); + lv_style_set_border_width(&theme->styles.step_indicator_segment, 0); + lv_style_set_bg_color(&theme->styles.step_indicator_segment, STEP_INDICATOR_UNFINISHED_COLOR); + lv_style_set_bg_opa(&theme->styles.step_indicator_segment, LV_OPA_COVER); + + style_init_reset(&theme->styles.step_indicator_segment_finished); + lv_style_set_bg_color(&theme->styles.step_indicator_segment_finished, STEP_INDICATOR_FINISHED_COLOR); + style_init_reset(&theme->styles.badge); lv_style_set_width(&theme->styles.badge, BADGE_SIZE); lv_style_set_height(&theme->styles.badge, BADGE_SIZE); @@ -1210,7 +1239,7 @@ static void style_init(my_theme_t * theme) lv_style_set_bg_color(&theme->styles.list, LIST_COLOR); lv_style_set_bg_opa(&theme->styles.list, LV_OPA_COVER); lv_style_set_pad_hor(&theme->styles.list, 0); - lv_style_set_pad_ver(&theme->styles.list, LIST_PAD); + lv_style_set_pad_ver(&theme->styles.list, 0); lv_style_set_pad_gap(&theme->styles.list, 0); lv_style_set_clip_corner(&theme->styles.list, true); @@ -1230,6 +1259,11 @@ static void style_init(my_theme_t * theme) lv_style_set_bg_opa(&theme->styles.list_item_focused, LV_OPA_COVER); lv_style_set_text_color(&theme->styles.list_item_focused, LIST_ITEM_FOCUSED_TEXT_COLOR); + style_init_reset(&theme->styles.list_item_selected); + lv_style_set_bg_color(&theme->styles.list_item_selected, LIST_ITEM_SELECTED_COLOR); + lv_style_set_bg_opa(&theme->styles.list_item_selected, LV_OPA_COVER); + lv_style_set_text_color(&theme->styles.list_item_selected, LIST_ITEM_SELECTED_TEXT_COLOR); + style_init_reset(&theme->styles.list_subheader); lv_style_set_text_color(&theme->styles.list_subheader, LIST_SUBHEADER_TEXT_COLOR); lv_style_set_text_font(&theme->styles.list_subheader, theme->config.font_small); @@ -1935,6 +1969,17 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj) lv_obj_add_style(obj, &theme->styles.page_indicator_dot_current, LV_STATE_CHECKED); return; } + if(lv_obj_check_type(obj, &lvgl_step_indicator_class)) { + /* step_indicator */ + lv_obj_add_style(obj, &theme->styles.step_indicator, 0); + return; + } + if(lv_obj_check_type(obj, &lvgl_step_indicator_segment_class)) { + /* step_indicator_segment */ + lv_obj_add_style(obj, &theme->styles.step_indicator_segment, 0); + lv_obj_add_style(obj, &theme->styles.step_indicator_segment_finished, LV_STATE_CHECKED); + return; + } if(lv_obj_check_type(obj, &lvgl_badge_class)) { /* badge */ lv_obj_add_style(obj, &theme->styles.badge, 0); @@ -2150,6 +2195,7 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj) if(lv_obj_check_type(obj, &lvgl_list_button_class)) { /* flex_list_button */ lv_obj_add_style(obj, &theme->styles.list_item, 0); + lv_obj_add_style(obj, &theme->styles.list_item_selected, LV_STATE_CHECKED); lv_obj_add_style(obj, &theme->styles.transition_exit, 0); lv_obj_add_style(obj, &theme->styles.state_pressed, LV_STATE_PRESSED); lv_obj_add_style(obj, &theme->styles.transition_enter, LV_STATE_PRESSED); diff --git a/Modules/lvgl-module/source/themes/lv_theme_material_mono.c b/Modules/lvgl-module/source/themes/lv_theme_material_mono.c index 607040fba..b15c07806 100644 --- a/Modules/lvgl-module/source/themes/lv_theme_material_mono.c +++ b/Modules/lvgl-module/source/themes/lv_theme_material_mono.c @@ -19,6 +19,8 @@ extern const lv_obj_class_t lvgl_chip_class; extern const lv_obj_class_t lvgl_badge_class; extern const lv_obj_class_t lvgl_page_indicator_class; extern const lv_obj_class_t lvgl_page_indicator_dot_class; +extern const lv_obj_class_t lvgl_step_indicator_class; +extern const lv_obj_class_t lvgl_step_indicator_segment_class; extern const lv_obj_class_t lvgl_list_class; extern const lv_obj_class_t lvgl_list_text_class; extern const lv_obj_class_t lvgl_list_button_class; @@ -97,6 +99,10 @@ extern const lv_obj_class_t lvgl_text_button_class; #define PAGE_INDICATOR_COLOR COLOR_ON_SURFACE #define PAGE_INDICATOR_DOT_SIZE (theme->config.is_compact ? 6 : LV_DPX_CALC(theme->disp_dpi, 8)) #define PAGE_INDICATOR_GAP (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6)) +#define STEP_INDICATOR_COLOR COLOR_ON_SURFACE +#define STEP_INDICATOR_THICKNESS (LV_DPX_CALC(theme->disp_dpi, 6)) +#define STEP_INDICATOR_MARGIN (LV_DPX_CALC(theme->disp_dpi, 6)) +#define STEP_INDICATOR_GAP STEP_INDICATOR_MARGIN #define BADGE_COLOR COLOR_ON_SURFACE #define BADGE_SIZE (theme->config.is_compact ? 4 : LV_DPX_CALC(theme->disp_dpi, 6)) #define SCROLLBAR_COLOR COLOR_ON_SURFACE @@ -233,6 +239,9 @@ typedef struct { #endif lv_style_t page_indicator_dot; lv_style_t page_indicator_dot_current; + lv_style_t step_indicator; + lv_style_t step_indicator_segment; + lv_style_t step_indicator_segment_finished; lv_style_t badge; lv_style_t chip; #if LV_USE_BUTTON || LV_USE_MSGBOX @@ -582,6 +591,23 @@ static void style_init(my_theme_t * theme) style_init_reset(&theme->styles.page_indicator_dot_current); lv_style_set_bg_opa(&theme->styles.page_indicator_dot_current, LV_OPA_COVER); + style_init_reset(&theme->styles.step_indicator); + lv_style_set_bg_opa(&theme->styles.step_indicator, LV_OPA_TRANSP); + lv_style_set_border_width(&theme->styles.step_indicator, 0); + lv_style_set_pad_all(&theme->styles.step_indicator, STEP_INDICATOR_MARGIN); + lv_style_set_pad_gap(&theme->styles.step_indicator, STEP_INDICATOR_GAP); + + style_init_reset(&theme->styles.step_indicator_segment); + lv_style_set_height(&theme->styles.step_indicator_segment, STEP_INDICATOR_THICKNESS); + lv_style_set_radius(&theme->styles.step_indicator_segment, LV_RADIUS_CIRCLE); + lv_style_set_bg_color(&theme->styles.step_indicator_segment, STEP_INDICATOR_COLOR); + lv_style_set_bg_opa(&theme->styles.step_indicator_segment, LV_OPA_TRANSP); + lv_style_set_border_color(&theme->styles.step_indicator_segment, STEP_INDICATOR_COLOR); + lv_style_set_border_width(&theme->styles.step_indicator_segment, 1); + + style_init_reset(&theme->styles.step_indicator_segment_finished); + lv_style_set_bg_opa(&theme->styles.step_indicator_segment_finished, LV_OPA_COVER); + style_init_reset(&theme->styles.badge); lv_style_set_width(&theme->styles.badge, BADGE_SIZE); lv_style_set_height(&theme->styles.badge, BADGE_SIZE); @@ -889,7 +915,7 @@ static void style_init(my_theme_t * theme) lv_style_set_border_width(&theme->styles.list, BORDER); lv_style_set_radius(&theme->styles.list, SHAPE_CONTAINER); lv_style_set_pad_hor(&theme->styles.list, 0); - lv_style_set_pad_ver(&theme->styles.list, LIST_PAD); + lv_style_set_pad_ver(&theme->styles.list, 0); lv_style_set_pad_gap(&theme->styles.list, 0); lv_style_set_clip_corner(&theme->styles.list, true); @@ -1515,6 +1541,17 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj) lv_obj_add_style(obj, &theme->styles.page_indicator_dot_current, LV_STATE_CHECKED); return; } + if(lv_obj_check_type(obj, &lvgl_step_indicator_class)) { + /* step_indicator */ + lv_obj_add_style(obj, &theme->styles.step_indicator, 0); + return; + } + if(lv_obj_check_type(obj, &lvgl_step_indicator_segment_class)) { + /* step_indicator_segment */ + lv_obj_add_style(obj, &theme->styles.step_indicator_segment, 0); + lv_obj_add_style(obj, &theme->styles.step_indicator_segment_finished, LV_STATE_CHECKED); + return; + } if(lv_obj_check_type(obj, &lvgl_badge_class)) { /* badge */ lv_obj_add_style(obj, &theme->styles.badge, 0); @@ -1723,6 +1760,7 @@ static void theme_apply(lv_theme_t * th, lv_obj_t * obj) if(lv_obj_check_type(obj, &lvgl_list_button_class)) { /* flex_list_button */ lv_obj_add_style(obj, &theme->styles.list_item, 0); + lv_obj_add_style(obj, &theme->styles.inverted, LV_STATE_CHECKED); lv_obj_add_style(obj, &theme->styles.inverted, LV_STATE_PRESSED); lv_obj_add_style(obj, &theme->styles.inverted, LV_STATE_FOCUS_KEY); lv_obj_add_style(obj, &theme->styles.disabled, LV_STATE_DISABLED); diff --git a/Modules/lvgl-module/source/widgets/step_indicator.cpp b/Modules/lvgl-module/source/widgets/step_indicator.cpp new file mode 100644 index 000000000..d15024eaa --- /dev/null +++ b/Modules/lvgl-module/source/widgets/step_indicator.cpp @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 +#define LV_USE_PRIVATE_API 1 // For the lv_obj_class_t declaration + +#include + +static void step_indicator_constructor(const lv_obj_class_t* class_p, lv_obj_t* obj) { + LV_UNUSED(class_p); + lv_obj_set_clickable(obj, false); + lv_obj_set_scrollable(obj, false); + lv_obj_set_flex_flow(obj, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(obj, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); +} + +static void step_indicator_segment_constructor(const lv_obj_class_t* class_p, lv_obj_t* obj) { + LV_UNUSED(class_p); + lv_obj_set_clickable(obj, false); + lv_obj_set_scrollable(obj, false); + // The segments share the indicator's width + lv_obj_set_flex_grow(obj, 1); +} + +// Not static: the theme styles step indicators and their segments by these classes +extern "C" const lv_obj_class_t lvgl_step_indicator_class = { + .base_class = &lv_obj_class, + .constructor_cb = step_indicator_constructor, + .destructor_cb = nullptr, + .event_cb = nullptr, + .user_data = nullptr, + .name = "step_indicator", + .width_def = LV_PCT(100), + .height_def = LV_SIZE_CONTENT, + .editable = LV_OBJ_CLASS_EDITABLE_INHERIT, + .group_def = LV_OBJ_CLASS_GROUP_DEF_FALSE, + .instance_size = 0, + .theme_inheritable = LV_OBJ_CLASS_THEME_INHERITABLE_FALSE +}; + +extern "C" const lv_obj_class_t lvgl_step_indicator_segment_class = { + .base_class = &lv_obj_class, + .constructor_cb = step_indicator_segment_constructor, + .destructor_cb = nullptr, + .event_cb = nullptr, + .user_data = nullptr, + .name = "step_indicator_segment", + .width_def = 0, + .height_def = 0, + .editable = LV_OBJ_CLASS_EDITABLE_INHERIT, + .group_def = LV_OBJ_CLASS_GROUP_DEF_FALSE, + .instance_size = 0, + .theme_inheritable = LV_OBJ_CLASS_THEME_INHERITABLE_FALSE +}; + +extern "C" { + +lv_obj_t* lvgl_step_indicator_create(lv_obj_t* parent) { + lv_obj_t* obj = lv_obj_class_create_obj(&lvgl_step_indicator_class, parent); + lv_obj_class_init_obj(obj); + return obj; +} + +void lvgl_step_indicator_set_step_count(lv_obj_t* obj, uint32_t count) { + uint32_t current = lv_obj_get_child_count(obj); + while (current > count) { + lv_obj_delete(lv_obj_get_child(obj, -1)); + current--; + } + while (current < count) { + lv_obj_t* segment = lv_obj_class_create_obj(&lvgl_step_indicator_segment_class, obj); + lv_obj_class_init_obj(segment); + current++; + } +} + +void lvgl_step_indicator_set_finished_count(lv_obj_t* obj, uint32_t finished_count) { + const uint32_t count = lv_obj_get_child_count(obj); + for (uint32_t i = 0; i < count; i++) { + // The theme shows finished steps by the checked state + lv_obj_set_state(lv_obj_get_child(obj, static_cast(i)), LV_STATE_CHECKED, i < finished_count); + } +} + +} diff --git a/Modules/lvgl-module/themes/material.lss b/Modules/lvgl-module/themes/material.lss index a7357f7c9..a55faff42 100644 --- a/Modules/lvgl-module/themes/material.lss +++ b/Modules/lvgl-module/themes/material.lss @@ -35,6 +35,8 @@ @widget badge: lvgl_badge_class; @widget page_indicator: lvgl_page_indicator_class; @widget page_indicator_dot: lvgl_page_indicator_dot_class; +@widget step_indicator: lvgl_step_indicator_class; +@widget step_indicator_segment: lvgl_step_indicator_segment_class; @widget flex_list: lvgl_list_class; @widget flex_list_text: lvgl_list_text_class; @widget flex_list_button: lvgl_list_button_class; @@ -219,6 +221,13 @@ @page-indicator-dot-size: if($isCompact, 6, 8dp); @page-indicator-gap: if($isCompact, 4, 6dp); +// Step indicator (lvgl_step_indicator_create): a line segment per step, finished steps in the secondary colour +@step-indicator-finished-color: @color-secondary; +@step-indicator-unfinished-color: mix(black, @color-secondary, 50%); +@step-indicator-thickness: 6dp; +@step-indicator-margin: 6dp; +@step-indicator-gap: @step-indicator-margin; + // Badge (lvgl_badge_create): a small dot that marks an item, e.g. a favourite @badge-color: @color-secondary; @badge-size: if($isCompact, 4, 6dp); @@ -373,7 +382,7 @@ @key-selected-text-color: @color-on-primary; // List -@list-color: @color-surface; +@list-color: @card-color; @list-pad: @density-list-pad; @list-item-color: @color-surface; @list-item-opacity: transp; @@ -383,7 +392,10 @@ @list-item-gap: if($isCompact, 3, @space-4); @list-item-focused-color: @color-secondary-container; @list-item-focused-text-color: @color-on-secondary-container; -@list-divider-color: @color-outline-variant; +@list-item-selected-color: @color-secondary-container; +@list-item-selected-text-color: @color-on-secondary-container; +// The secondary colour, muted towards the list background +@list-divider-color: mix(@color-secondary, @list-color, 35%); @list-divider-width: @density-border; @list-subheader-text-color: @color-on-surface-variant; @@ -614,6 +626,25 @@ page_indicator_dot_current { bgOpa: cover; } +step_indicator { + bgOpa: transp; + borderWidth: 0; + padAll: @step-indicator-margin; + padGap: @step-indicator-gap; +} + +step_indicator_segment { + height: @step-indicator-thickness; + radius: circle; + borderWidth: 0; + bgColor: @step-indicator-unfinished-color; + bgOpa: cover; +} + +step_indicator_segment_finished { + bgColor: @step-indicator-finished-color; +} + badge { width: @badge-size; height: @badge-size; @@ -1012,7 +1043,7 @@ list { bgColor: @list-color; bgOpa: cover; padHor: 0; - padVer: @list-pad; + padVer: 0; padGap: 0; clipCorner: true; } @@ -1035,6 +1066,13 @@ list_item_focused { textColor: @list-item-focused-text-color; } +// A list item that is checked to show it's the selected one +list_item_selected { + bgColor: @list-item-selected-color; + bgOpa: cover; + textColor: @list-item-selected-text-color; +} + list_subheader { textColor: @list-subheader-text-color; textFont: $fontSmall; @@ -1501,6 +1539,15 @@ apply(page_indicator_dot) { page_indicator_dot_current: state.checked; } +apply(step_indicator) { + step_indicator: default; +} + +apply(step_indicator_segment) { + step_indicator_segment: default; + step_indicator_segment_finished: state.checked; +} + apply(badge) { badge: default; } @@ -1680,6 +1727,7 @@ apply(flex_list_text) { apply(flex_list_button) { list_item: default; + list_item_selected: state.checked; transition_exit: default; state_pressed: state.pressed; transition_enter: state.pressed; diff --git a/Modules/lvgl-module/themes/material_mono.lss b/Modules/lvgl-module/themes/material_mono.lss index 5fbbb719b..acf10b63d 100644 --- a/Modules/lvgl-module/themes/material_mono.lss +++ b/Modules/lvgl-module/themes/material_mono.lss @@ -33,6 +33,8 @@ @widget badge: lvgl_badge_class; @widget page_indicator: lvgl_page_indicator_class; @widget page_indicator_dot: lvgl_page_indicator_dot_class; +@widget step_indicator: lvgl_step_indicator_class; +@widget step_indicator_segment: lvgl_step_indicator_segment_class; @widget flex_list: lvgl_list_class; @widget flex_list_text: lvgl_list_text_class; @widget flex_list_button: lvgl_list_button_class; @@ -154,6 +156,12 @@ @page-indicator-dot-size: if($isCompact, 6, 8dp); @page-indicator-gap: if($isCompact, 4, 6dp); +// Step indicator (lvgl_step_indicator_create): a line segment per step, finished steps filled, unfinished steps outlined +@step-indicator-color: @color-on-surface; +@step-indicator-thickness: 6dp; +@step-indicator-margin: 6dp; +@step-indicator-gap: @step-indicator-margin; + // Badge (lvgl_badge_create): a small dot that marks an item, e.g. a favourite @badge-color: @color-on-surface; @badge-size: if($isCompact, 4, 6dp); @@ -431,6 +439,26 @@ page_indicator_dot_current { bgOpa: cover; } +step_indicator { + bgOpa: transp; + borderWidth: 0; + padAll: @step-indicator-margin; + padGap: @step-indicator-gap; +} + +step_indicator_segment { + height: @step-indicator-thickness; + radius: circle; + bgColor: @step-indicator-color; + bgOpa: transp; + borderColor: @step-indicator-color; + borderWidth: 1; +} + +step_indicator_segment_finished { + bgOpa: cover; +} + badge { width: @badge-size; height: @badge-size; @@ -733,7 +761,7 @@ list { borderWidth: @border; radius: @shape-container; padHor: 0; - padVer: @list-pad; + padVer: 0; padGap: 0; clipCorner: true; } @@ -1141,6 +1169,15 @@ apply(page_indicator_dot) { page_indicator_dot_current: state.checked; } +apply(step_indicator) { + step_indicator: default; +} + +apply(step_indicator_segment) { + step_indicator_segment: default; + step_indicator_segment_finished: state.checked; +} + apply(badge) { badge: default; } @@ -1291,6 +1328,7 @@ apply(flex_list_text) { apply(flex_list_button) { list_item: default; + inverted: state.checked; inverted: state.pressed; inverted: state.focusKey; disabled: state.disabled; diff --git a/Tactility/Include/Tactility/settings/RadioSettings.h b/Tactility/Include/Tactility/settings/RadioSettings.h index a39621af0..d9f73432c 100644 --- a/Tactility/Include/Tactility/settings/RadioSettings.h +++ b/Tactility/Include/Tactility/settings/RadioSettings.h @@ -2,6 +2,8 @@ #include +#include + namespace tt::settings::radio { /** @return the persisted regulatory region, RADIO_REGION_UNSET when none was chosen */ @@ -19,4 +21,10 @@ const char* getRegionLabel(RadioRegion region); /** @return true when the devicetree has a radio, whether it's started or not */ bool hasRadio(); +/** + * @param[in] timeZoneName an IANA time zone name, e.g. "Europe/Amsterdam" + * @return the region of the most common radio band where that time zone is used, RADIO_REGION_UNSET when unknown + */ +RadioRegion findRegionForTimeZone(const std::string& timeZoneName); + } diff --git a/Tactility/Private/Tactility/app/timezone/TimeZoneView.h b/Tactility/Private/Tactility/app/timezone/TimeZoneView.h new file mode 100644 index 000000000..6ab14d764 --- /dev/null +++ b/Tactility/Private/Tactility/app/timezone/TimeZoneView.h @@ -0,0 +1,81 @@ +#pragma once + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace tt::app::timezone { + +/** + * A searchable list of the time zones, with a button that detects the time zone from the location. + * Its state survives rebuilding its widgets, e.g. when a window is covered and shown again. + */ +class TimeZoneView final { + +public: + + /** Called with the LVGL lock held when the user picks a time zone, or when one was detected */ + using OnSelected = std::function; + + explicit TimeZoneView(OnSelected onSelected); + + /** Waits for a running detection, which can take a few seconds. Destroy it without holding the LVGL lock. */ + ~TimeZoneView(); + + TimeZoneView(const TimeZoneView&) = delete; + TimeZoneView& operator=(const TimeZoneView&) = delete; + + /** Creates the widgets in @a parent, a flex column, with the LVGL lock held. The list grows to fill it. */ + void createWidgets(lv_obj_t* parent); + + /** Call with the LVGL lock held when the widgets were deleted */ + void destroyWidgets(); + + /** Shows this time zone as the selected one */ + void setSelectedName(const std::string& name); + +private: + + struct Entry { + std::string name; + std::string code; + }; + + OnSelected onSelected; + Mutex mutex; + // Guarded by mutex + std::vector entries; + std::string filter; + std::string selectedName; + std::string message; + std::unique_ptr filterTimer; + std::unique_ptr detectThread; + std::atomic detecting = false; + + // Valid while the widgets exist, guarded by the LVGL lock + lv_obj_t* textarea = nullptr; + lv_obj_t* detectButton = nullptr; + lv_obj_t* detectSpinner = nullptr; + lv_obj_t* messageLabel = nullptr; + lv_obj_t* list = nullptr; + + static void onFilterChanged(lv_event_t* event); + static void onDetectPressed(lv_event_t* event); + static void onEntryPressed(lv_event_t* event); + + void loadEntries(); + void rebuildList(); + void showState(); + void detect(); + void select(const std::string& name, const std::string& code); +}; + +} diff --git a/Tactility/Source/app/setup/Setup.cpp b/Tactility/Source/app/setup/Setup.cpp index a8dcf316d..1afbdb747 100644 --- a/Tactility/Source/app/setup/Setup.cpp +++ b/Tactility/Source/app/setup/Setup.cpp @@ -3,11 +3,10 @@ #include #include -#include -#include +#include #include #include -#include +#include #include #include @@ -31,11 +30,13 @@ #include #include #include -#include +#include +#include #include #include #include +#include #include #ifdef ESP_PLATFORM @@ -96,8 +97,19 @@ struct StepConfiguration { std::function run; /** Optional: handles the result of the dialog that run() started */ std::function onResult = nullptr; + /** What the page shows below its title */ + enum class Content { + Description, + /** The description, with the location choices below it */ + LocationChoice, + /** The time zone list instead of the description */ + TimeZones + } content = Content::Description; }; +/** The location choices of the location page, in the order of their chips */ +constexpr const char* LOCATION_CHOICES[] = { "Precise", "Approximate", "Disabled" }; + struct Context { uint32_t appInstanceId; @@ -112,7 +124,17 @@ struct Context { lv_obj_t* backButton = nullptr; lv_obj_t* skipButton = nullptr; lv_obj_t* continueButton = nullptr; - lv_obj_t* pageIndicator = nullptr; + lv_obj_t* stepIndicator = nullptr; + lv_obj_t* bottom = nullptr; + lv_obj_t* timeZoneContainer = nullptr; + lv_obj_t* locationChips = nullptr; + // The index in LOCATION_CHOICES picked on the location page, -1 when none. Guarded by the LVGL lock. + int32_t locationChoice = -1; + + std::unique_ptr timeZoneView; + // The time zone picked on the time zone page, guarded by the LVGL lock + std::string timeZoneName; + std::string timeZoneCode; }; /** The welcome page, a page per step, and the completion page */ @@ -133,8 +155,96 @@ uint32_t getPage(const Context* ctx) { } +/** The welcome page counts as the first step, followed by the configuration steps */ +uint32_t getStepCount(const Context* ctx) { + return static_cast(ctx->steps.size()) + 1; +} + +/** The steps before the current one, or all of them once setup is done */ +uint32_t getFinishedStepCount(const Context* ctx) { + switch (ctx->phase) { + case Phase::Welcome: + return 0; + case Phase::StepIntro: + return static_cast(ctx->stepIndex) + 1; + case Phase::Done: + return getStepCount(ctx); + } + return 0; +} + +StepConfiguration::Content getContent(const Context* ctx) { + return ctx->phase == Phase::StepIntro ? ctx->steps[ctx->stepIndex].content : StepConfiguration::Content::Description; +} + +/** Shows which location choice is picked */ +void renderLocationChips(Context* ctx) { + const uint32_t count = lv_obj_get_child_count(ctx->locationChips); + for (uint32_t i = 0; i < count; i++) { + lv_obj_set_state(lv_obj_get_child(ctx->locationChips, static_cast(i)), LV_STATE_CHECKED, static_cast(i) == ctx->locationChoice); + } +} + +/** The time zone page shows the title at the top and the list below it, the other pages center their text */ +void renderLayout(Context* ctx) { + const auto content = getContent(ctx); + const bool showsTimeZones = content == StepConfiguration::Content::TimeZones; + const bool showsLocationChoice = content == StepConfiguration::Content::LocationChoice; + const bool hasTimeZoneWidgets = lv_obj_get_child_count(ctx->timeZoneContainer) > 0; + lv_obj_set_hidden(ctx->descriptionLabel, showsTimeZones); + lv_obj_set_hidden(ctx->timeZoneContainer, !showsTimeZones); + lv_obj_set_hidden(ctx->locationChips, !showsLocationChoice); + if (!showsTimeZones) { + if (hasTimeZoneWidgets) { + lv_obj_clean(ctx->timeZoneContainer); + ctx->timeZoneView->destroyWidgets(); + } + const int titleMargin = lvgl_get_text_font_height(FONT_SIZE_LARGE); + if (showsLocationChoice) { + // Centered as a group in the space above the buttons + const int32_t gap = titleMargin / 2; + lv_obj_update_layout(lv_obj_get_parent(ctx->titleLabel)); + const int32_t height = lv_obj_get_height(ctx->titleLabel) + gap + lv_obj_get_height(ctx->descriptionLabel) + gap + lv_obj_get_height(ctx->locationChips); + lv_area_t parent; + lv_area_t bottom; + lv_obj_get_content_coords(lv_obj_get_parent(ctx->titleLabel), &parent); + lv_obj_get_coords(ctx->bottom, &bottom); + const int32_t top = LV_MAX((bottom.y1 - parent.y1 - height) / 2, 0); + lv_obj_align(ctx->titleLabel, LV_ALIGN_TOP_MID, 0, top); + lv_obj_align_to(ctx->descriptionLabel, ctx->titleLabel, LV_ALIGN_OUT_BOTTOM_MID, 0, gap); + lv_obj_align_to(ctx->locationChips, ctx->descriptionLabel, LV_ALIGN_OUT_BOTTOM_MID, 0, gap); + // Continue confirms the picked choice + renderLocationChips(ctx); + lv_obj_set_state(ctx->continueButton, LV_STATE_DISABLED, ctx->locationChoice < 0); + } else { + lv_obj_align(ctx->descriptionLabel, LV_ALIGN_CENTER, 0, 0); + lv_obj_align_to(ctx->titleLabel, ctx->descriptionLabel, LV_ALIGN_OUT_TOP_MID, 0, -titleMargin); + lv_obj_remove_state(ctx->continueButton, LV_STATE_DISABLED); + } + return; + } + + lv_obj_align(ctx->titleLabel, LV_ALIGN_TOP_MID, 0, LV_DPX(8)); + if (!hasTimeZoneWidgets) { + ctx->timeZoneView->createWidgets(ctx->timeZoneContainer); + } + // Continue confirms the picked time zone + lv_obj_set_state(ctx->continueButton, LV_STATE_DISABLED, ctx->timeZoneName.empty()); + + // Fills the space between the title and the buttons + lv_obj_update_layout(lv_obj_get_parent(ctx->timeZoneContainer)); + lv_area_t title; + lv_area_t bottom; + lv_obj_get_coords(ctx->titleLabel, &title); + lv_obj_get_coords(ctx->bottom, &bottom); + lv_area_t parent; + lv_obj_get_content_coords(lv_obj_get_parent(ctx->timeZoneContainer), &parent); + lv_obj_set_pos(ctx->timeZoneContainer, 0, title.y2 + LV_DPX(4) - parent.y1); + lv_obj_set_size(ctx->timeZoneContainer, LV_PCT(100), bottom.y1 - title.y2 - LV_DPX(8)); +} + void renderCurrent(Context* ctx) { - lvgl_page_indicator_set_page(ctx->pageIndicator, getPage(ctx)); + lvgl_step_indicator_set_finished_count(ctx->stepIndicator, getFinishedStepCount(ctx)); lv_obj_set_hidden(ctx->backButton, ctx->phase == Phase::Welcome); switch (ctx->phase) { case Phase::Welcome: { @@ -161,6 +271,7 @@ void renderCurrent(Context* ctx) { lv_label_set_text(lv_obj_get_child(ctx->continueButton, 0), "Finish"); break; } + renderLayout(ctx); } void advanceTo(Context* ctx, size_t index) { @@ -219,6 +330,13 @@ void onContinueClicked(lv_event_t* event) { } } +void onLocationChipPressed(lv_event_t* event) { + auto* ctx = static_cast(lv_event_get_user_data(event)); + ctx->locationChoice = static_cast(reinterpret_cast(lv_obj_get_user_data(lv_event_get_target_obj(event)))); + renderLocationChips(ctx); + lv_obj_remove_state(ctx->continueButton, LV_STATE_DISABLED); +} + void createWidgets(lv_obj_t* parent, void* userData) { auto* ctx = static_cast(userData); @@ -235,17 +353,45 @@ void createWidgets(lv_obj_t* parent, void* userData) { lv_label_set_long_mode(ctx->descriptionLabel, LV_LABEL_LONG_WRAP); lv_obj_align(ctx->descriptionLabel, LV_ALIGN_CENTER, 0, 0); - int title_margin = lvgl_get_text_font_height(FONT_SIZE_LARGE); - lv_obj_align_to(ctx->titleLabel, ctx->descriptionLabel, LV_ALIGN_OUT_TOP_MID, 0, -title_margin); + // The location choices: one can be picked, and they wrap to more lines on narrow screens + ctx->locationChips = lv_obj_create(parent); + lv_obj_remove_style_all(ctx->locationChips); + lv_obj_set_size(ctx->locationChips, LV_PCT(100), LV_SIZE_CONTENT); + // The chip margins space the choices, so they fit on one line on small screens + lv_obj_set_flex_flow(ctx->locationChips, LV_FLEX_FLOW_ROW_WRAP); + lv_obj_set_flex_align(ctx->locationChips, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + for (size_t i = 0; i < std::size(LOCATION_CHOICES); i++) { + auto* chip = lvgl_chip_create(ctx->locationChips); + lv_label_set_text(lv_label_create(chip), LOCATION_CHOICES[i]); + lv_obj_set_user_data(chip, reinterpret_cast(static_cast(i))); + lv_obj_add_event_cb(chip, onLocationChipPressed, LV_EVENT_SHORT_CLICKED, ctx); + } + lvgl_grid_navigation_add(ctx->locationChips); + lv_obj_set_hidden(ctx->locationChips, true); - // Back on the left, the page indicator in the middle, and Skip next to Continue on the right - auto* buttonRow = lv_obj_create(parent); + // Filled with the time zone list on the time zone page + ctx->timeZoneContainer = lv_obj_create(parent); + lv_obj_remove_style_all(ctx->timeZoneContainer); + lv_obj_set_flex_flow(ctx->timeZoneContainer, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_hor(ctx->timeZoneContainer, LV_DPX(8), LV_STATE_DEFAULT); + lv_obj_set_hidden(ctx->timeZoneContainer, true); + + // The buttons, with the progress through the pages below them at the bottom + auto* bottom = lv_obj_create(parent); + ctx->bottom = bottom; + lv_obj_remove_style_all(bottom); + lv_obj_set_size(bottom, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(bottom, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(bottom, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_align(bottom, LV_ALIGN_BOTTOM_MID, 0, 0); + + // Back on the left, and Skip next to Continue on the right + auto* buttonRow = lv_obj_create(bottom); lv_obj_remove_style_all(buttonRow); lv_obj_set_size(buttonRow, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_style_pad_hor(buttonRow, 8, LV_STATE_DEFAULT); + lv_obj_set_style_pad_hor(buttonRow, LV_DPX(8), LV_STATE_DEFAULT); lv_obj_set_flex_flow(buttonRow, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(buttonRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_align(buttonRow, LV_ALIGN_BOTTOM_MID, 0, -8); ctx->backButton = lvgl_text_button_create(buttonRow); lv_label_set_text(lv_label_create(ctx->backButton), "Back"); @@ -264,12 +410,8 @@ void createWidgets(lv_obj_t* parent, void* userData) { lv_label_set_text(lv_label_create(ctx->continueButton), "Continue"); lv_obj_add_event_cb(ctx->continueButton, onContinueClicked, LV_EVENT_SHORT_CLICKED, ctx); - // Centered on the row, outside the flex layout. It shows the page number instead of the dots when they don't fit. - ctx->pageIndicator = lvgl_page_indicator_create(buttonRow); - lv_obj_add_flag(ctx->pageIndicator, LV_OBJ_FLAG_IGNORE_LAYOUT); - lv_obj_set_style_max_width(ctx->pageIndicator, LV_PCT(25), LV_STATE_DEFAULT); - lvgl_page_indicator_set_page_count(ctx->pageIndicator, getPageCount(ctx)); - lv_obj_align(ctx->pageIndicator, LV_ALIGN_CENTER, 0, 0); + ctx->stepIndicator = lvgl_step_indicator_create(bottom); + lvgl_step_indicator_set_step_count(ctx->stepIndicator, getStepCount(ctx)); // The arrow keys move between the buttons, and Continue has the focus first lvgl_grid_navigation_add(buttonRow); @@ -288,7 +430,11 @@ void destroyWidgets(void* userData) { ctx->backButton = nullptr; ctx->skipButton = nullptr; ctx->continueButton = nullptr; - ctx->pageIndicator = nullptr; + ctx->stepIndicator = nullptr; + ctx->bottom = nullptr; + ctx->timeZoneContainer = nullptr; + ctx->locationChips = nullptr; + ctx->timeZoneView->destroyWidgets(); } int32_t appMain(int argc, char* argv[]) { @@ -303,11 +449,6 @@ int32_t appMain(int argc, char* argv[]) { .run = [&ctx] { ctx.pendingStepDialogId = touchcalibration::start(ctx.appInstanceId); } }, #endif - { - .title = "Time Zone Setup", - .description = "Let's set the time zone.", - .run = [&ctx] { ctx.pendingStepDialogId = timezone::start(ctx.appInstanceId, true); } - }, { .title = "Wi-Fi Setup", .description = "Let's connect to a Wi-Fi access point.", @@ -326,40 +467,37 @@ int32_t appMain(int argc, char* argv[]) { } }; - // The radio may only transmit on the frequencies and power of the user's region - if (settings::radio::hasRadio()) { - ctx.steps.push_back({ - .title = "Radio Region", - .description = "Let's select your region, so the radio uses the frequencies that are allowed there.", - .run = [&ctx] { - std::vector items; - for (int region = RADIO_REGION_UNSET + 1; region < RADIO_REGION_COUNT; region++) { - items.emplace_back(settings::radio::getRegionLabel(static_cast(region))); - } - ctx.pendingStepDialogId = selectiondialog::start(ctx.appInstanceId, "Region", items); - }, - .onResult = [](int32_t selected) { - if (selected >= 0 && selected < RADIO_REGION_COUNT - 1) { - settings::radio::saveRegion(static_cast(selected + 1)); - } - } - }); - } - ctx.steps.push_back({ .title = "Location Services", - .description = "Location services let apps use your location. It comes from GPS, or from your IP address via ip-api.com when there is no GPS fix.", + .description = "Do you want to allow apps to retrieve your location? It uses GPS and/or contacts ip-api.com", .run = [&ctx] { - const std::vector items = { "Allow precise location", "Allow approximate location", "Don't allow" }; - ctx.pendingStepDialogId = selectiondialog::start(ctx.appInstanceId, "Location", items); - }, - .onResult = [](int32_t selected) { - if (selected == 0 || selected == 1) { - location_set_precision(selected == 0 ? LOCATION_PRECISION_PRECISE : LOCATION_PRECISION_APPROXIMATE); + // The order of LOCATION_CHOICES: precise, approximate, disabled + if (ctx.locationChoice == 0 || ctx.locationChoice == 1) { + location_set_precision(ctx.locationChoice == 0 ? LOCATION_PRECISION_PRECISE : LOCATION_PRECISION_APPROXIMATE); location_set_enabled(true); - } else if (selected == 2) { + } else { location_set_enabled(false); } + advanceTo(&ctx, ctx.stepIndex + 1); + }, + .content = StepConfiguration::Content::LocationChoice + }); + + // Setting the time zone also sets the radio region, as both depend on where the device is + ctx.steps.push_back({ + .title = "Time Zone", + .description = "", + .run = [&ctx] { + settings::setTimeZone(ctx.timeZoneName, ctx.timeZoneCode); + advanceTo(&ctx, ctx.stepIndex + 1); + }, + .content = StepConfiguration::Content::TimeZones + }); + ctx.timeZoneView = std::make_unique([&ctx](const std::string& name, const std::string& code) { + ctx.timeZoneName = name; + ctx.timeZoneCode = code; + if (ctx.continueButton != nullptr) { + lv_obj_remove_state(ctx.continueButton, LV_STATE_DISABLED); } }); diff --git a/Tactility/Source/app/timezone/TimeZone.cpp b/Tactility/Source/app/timezone/TimeZone.cpp index f6f9a5b39..5df442d67 100644 --- a/Tactility/Source/app/timezone/TimeZone.cpp +++ b/Tactility/Source/app/timezone/TimeZone.cpp @@ -1,9 +1,5 @@ #include -#include -#include -#include -#include -#include +#include #include #include @@ -15,176 +11,34 @@ #include #include -#include -#include -#include -#include -#include #include #include namespace tt::app::timezone { -constexpr auto* TAG = "TimeZone"; - extern const ::AppManifest manifest; namespace { -struct TimeZoneEntry { - std::string name; - std::string code; -}; - struct Context { uint32_t appInstanceId; - Mutex mutex; - std::vector entries; - std::unique_ptr updateTimer; - lv_obj_t* listWidget = nullptr; - lv_obj_t* filterTextareaWidget = nullptr; bool saveTimeZone = false; + std::unique_ptr view; // The eventual appMain() return value - see AlertDialog.cpp's Context::result for why this // is a plain (non-atomic) field safely shared between the LVGL thread (writer, before // emitting APP_EVENT_CLOSE) and this app's own thread (reader, after waking from it). int32_t result = 1; // Cancelled - safety-net default if closed without picking a time zone }; - // The last picked name/code. Static rather than per-instance: simple, and in practice only one -// TimeZone dialog is ever open at a time. Written on the LVGL thread (the item-selected -// callback, before emitting APP_EVENT_CLOSE); read by the parent via getLastName()/getLastCode() -// after receiving that event - safe without a lock for the same reason Context::result is (see -// AlertDialog.cpp). +// TimeZone dialog is ever open at a time. Written before emitting APP_EVENT_CLOSE; read by the +// parent via getLastName()/getLastCode() after receiving that event - safe without a lock for +// the same reason Context::result is (see AlertDialog.cpp). std::string lastName; std::string lastCode; -bool parseEntry(const std::string& input, std::string& outName, std::string& outCode) { - std::string partial_strip = input.substr(1, input.size() - 3); - auto first_end_quote = partial_strip.find('"'); - if (first_end_quote == std::string::npos) { - return false; - } else { - outName = partial_strip.substr(0, first_end_quote); - outCode = partial_strip.substr(first_end_quote + 3); - return true; - } -} - -void onTextareaValueChanged(lv_event_t* e) { - auto* ctx = static_cast(lv_event_get_user_data(e)); - if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) { - if (ctx->updateTimer->isRunning()) { - ctx->updateTimer->stop(); - } - ctx->updateTimer->start(); - ctx->mutex.unlock(); - } -} - -void createListItem(Context* ctx, lv_obj_t* list, const std::string& title, size_t index) { - auto* btn = lvgl_list_add_button(list, nullptr, title.c_str()); - struct ButtonContext { - Context* ctx; - size_t index; - }; - auto* buttonCtx = new ButtonContext { ctx, index }; - lv_obj_add_event_cb(btn, [](lv_event_t* e) { - auto* buttonCtx = static_cast(lv_event_get_user_data(e)); - delete buttonCtx; - }, LV_EVENT_DELETE, buttonCtx); - lv_obj_add_event_cb(btn, [](lv_event_t* e) { - auto* buttonCtx = static_cast(lv_event_get_user_data(e)); - auto* ctx = buttonCtx->ctx; - auto index = buttonCtx->index; - LOG_I(TAG, "Selected item at index %d", (int)index); - - auto& entry = ctx->entries[index]; - - if (ctx->saveTimeZone) { - settings::setTimeZone(entry.name, entry.code); - } - - lastName = entry.name; - lastCode = entry.code; - - ctx->result = 0; // Ok - app_event_emit_close(ctx->appInstanceId); - }, LV_EVENT_SHORT_CLICKED, buttonCtx); -} - -void readTimeZones(Context* ctx, std::string filter) { - auto path = std::string(file::MOUNT_POINT_SYSTEM) + "/timezones.csv"; - auto* file = fopen(path.c_str(), "rb"); - if (file == nullptr) { - LOG_E(TAG, "Failed to open %s", path.c_str()); - return; - } - char line[96]; - std::string name; - std::string code; - uint32_t count = 0; - std::vector new_entries; - while (fgets(line, 96, file)) { - if (parseEntry(line, name, code)) { - if (string::lowercase(name).find(filter) != std::string::npos) { - count++; - new_entries.push_back({.name = name, .code = code}); - - // Safety guard - if (count > 50) { - // TODO: Show warning that we're not displaying a complete list - break; - } - } - } else { - LOG_E(TAG, "Parse error at line %llu", count); - } - } - - fclose(file); - - if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) { - ctx->entries = std::move(new_entries); - ctx->mutex.unlock(); - } else { - LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED); - } - - LOG_I(TAG, "Processed %llu entries", count); -} - -void updateList(Context* ctx) { - if (lvgl_try_lock(200 / portTICK_PERIOD_MS)) { - std::string filter = string::lowercase(std::string(lv_textarea_get_text(ctx->filterTextareaWidget))); - lvgl_unlock(); - readTimeZones(ctx, filter); - } else { - LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED_FMT, "TimeZone LVGL"); - return; - } - - if (lvgl_try_lock(200 / portTICK_PERIOD_MS)) { - if (ctx->mutex.lock(100 / portTICK_PERIOD_MS)) { - lv_obj_clean(ctx->listWidget); - - uint32_t index = 0; - for (auto& entry : ctx->entries) { - createListItem(ctx, ctx->listWidget, entry.name, index); - index++; - } - - ctx->mutex.unlock(); - } - - lvgl_unlock(); - } else { - LOG_E(TAG, LOG_MESSAGE_MUTEX_LOCK_FAILED_FMT, "TimeZone LVGL"); - } -} - void onBackPressed(lv_event_t* event) { auto* ctx = static_cast(lv_event_get_user_data(event)); app_event_emit_close(ctx->appInstanceId); @@ -199,32 +53,20 @@ void createWidgets(lv_obj_t* parent, void* userData) { auto* toolbar = lvgl_toolbar_create(parent, "Select Time zone"); lvgl_toolbar_set_nav_action(toolbar, LV_SYMBOL_CLOSE, onBackPressed, ctx); - auto* search_wrapper = lv_obj_create(parent); - lv_obj_set_size(search_wrapper, LV_PCT(100), LV_SIZE_CONTENT); - lv_obj_set_flex_flow(search_wrapper, LV_FLEX_FLOW_ROW); - lv_obj_set_flex_align(search_wrapper, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_START); - lv_obj_set_style_pad_all(search_wrapper, 0, 0); - lv_obj_set_style_border_width(search_wrapper, 0, 0); + // Margins around the view's card + auto* wrapper = lv_obj_create(parent); + lv_obj_remove_style_all(wrapper); + lv_obj_set_width(wrapper, LV_PCT(100)); + lv_obj_set_flex_grow(wrapper, 1); + lv_obj_set_flex_flow(wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(wrapper, LV_DPX(8), LV_STATE_DEFAULT); - auto* icon = lv_image_create(search_wrapper); - lv_obj_set_style_margin_left(icon, 8, 0); - lv_obj_set_style_image_recolor_opa(icon, 255, 0); - lv_obj_set_style_image_recolor(icon, lv_theme_get_color_primary(parent), 0); - lv_obj_set_style_text_font(icon, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT); - lv_image_set_src(icon, LVGL_ICON_SHARED_SEARCH); + ctx->view->createWidgets(wrapper); +} - auto* textarea = lv_textarea_create(search_wrapper); - lv_textarea_set_placeholder_text(textarea, "e.g. Europe/Amsterdam"); - lv_textarea_set_one_line(textarea, true); - lv_obj_add_event_cb(textarea, onTextareaValueChanged, LV_EVENT_VALUE_CHANGED, ctx); - ctx->filterTextareaWidget = textarea; - lv_obj_set_flex_grow(textarea, 1); - - auto* list = lvgl_list_create(parent); - lv_obj_set_width(list, LV_PCT(100)); - lv_obj_set_flex_grow(list, 1); - lv_obj_set_style_border_width(list, 0, 0); - ctx->listWidget = list; +void destroyWidgets(void* userData) { + auto* ctx = static_cast(userData); + ctx->view->destroyWidgets(); } int32_t appMain(int argc, char* argv[]) { @@ -234,9 +76,15 @@ int32_t appMain(int argc, char* argv[]) { Context ctx; ctx.appInstanceId = appInstanceId; ctx.saveTimeZone = argc > 0 && argv[0][0] == '1'; - - ctx.updateTimer = std::make_unique(Timer::Type::Once, 500 / portTICK_PERIOD_MS, [&ctx] { - updateList(&ctx); + // Picking a time zone, or detecting one, closes the app with it + ctx.view = std::make_unique([&ctx](const std::string& name, const std::string& code) { + if (ctx.saveTimeZone) { + settings::setTimeZone(name, code); + } + lastName = name; + lastCode = code; + ctx.result = 0; // Ok + app_event_emit_close(ctx.appInstanceId); }); TaskEventGroup event_group {}; @@ -245,8 +93,7 @@ int32_t appMain(int argc, char* argv[]) { AppEventSubscription sub {}; check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); - WindowId window = window_manager_create(appInstanceId, createWidgets, &ctx); - ctx.updateTimer->start(); + WindowId window = window_manager_create_ext(appInstanceId, createWidgets, destroyWidgets, &ctx); while (true) { task_event_group_wait_any(&event_group, nullptr, portMAX_DELAY); @@ -262,8 +109,8 @@ int32_t appMain(int argc, char* argv[]) { if (shouldClose) break; } - ctx.updateTimer->stop(); window_manager_remove(window); + ctx.view.reset(); check(app_event_unsubscribe(&sub) == ERROR_NONE); task_event_group_destruct(&event_group); diff --git a/Tactility/Source/app/timezone/TimeZoneView.cpp b/Tactility/Source/app/timezone/TimeZoneView.cpp new file mode 100644 index 000000000..dcd758e67 --- /dev/null +++ b/Tactility/Source/app/timezone/TimeZoneView.cpp @@ -0,0 +1,294 @@ +#include + +#include +#include + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace tt::app::timezone { + +constexpr auto* TAG = "TimeZoneView"; + +namespace { + +// Bounds the list, so a short filter doesn't create hundreds of widgets +constexpr size_t MAX_ENTRIES = 50; +constexpr TickType_t FILTER_DELAY = pdMS_TO_TICKS(500); + +std::string getTimeZonesPath() { + return std::string(file::MOUNT_POINT_SYSTEM) + "/timezones.csv"; +} + +/** Parses a line like "Europe/Amsterdam","CET-1CEST,M3.5.0,M10.5.0/3" */ +bool parseEntry(const std::string& input, std::string& outName, std::string& outCode) { + if (input.size() < 3) { + return false; + } + std::string partial_strip = input.substr(1, input.size() - 3); + auto first_end_quote = partial_strip.find('"'); + if (first_end_quote == std::string::npos) { + return false; + } + outName = partial_strip.substr(0, first_end_quote); + outCode = partial_strip.substr(first_end_quote + 3); + return true; +} + +/** Calls @a visitor for each time zone until it returns false */ +void forEachTimeZone(const std::function& visitor) { + auto* file = fopen(getTimeZonesPath().c_str(), "rb"); + if (file == nullptr) { + LOG_E(TAG, "Failed to open %s", getTimeZonesPath().c_str()); + return; + } + char line[96]; + std::string name; + std::string code; + while (fgets(line, sizeof(line), file)) { + if (parseEntry(line, name, code) && !visitor(name, code)) { + break; + } + } + fclose(file); +} + +} // namespace + +TimeZoneView::TimeZoneView(OnSelected onSelected) : onSelected(std::move(onSelected)) { + filterTimer = std::make_unique(Timer::Type::Once, FILTER_DELAY, [this] { + loadEntries(); + lvgl_lock(); + rebuildList(); + lvgl_unlock(); + }); + loadEntries(); +} + +TimeZoneView::~TimeZoneView() { + filterTimer->stop(); + if (detectThread != nullptr) { + detectThread->join(); + } +} + +void TimeZoneView::createWidgets(lv_obj_t* parent) { + // The search row, the message and the list share a card. Only the list scrolls, so the card keeps its size. + auto* card = lvgl_card_create(parent); + lv_obj_set_width(card, LV_PCT(100)); + lv_obj_set_flex_grow(card, 1); + lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN); + lv_obj_remove_flag(card, LV_OBJ_FLAG_SCROLLABLE); + + auto* searchRow = lv_obj_create(card); + lv_obj_remove_style_all(searchRow); + lv_obj_set_size(searchRow, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(searchRow, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(searchRow, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_column(searchRow, LV_DPX(4), LV_STATE_DEFAULT); + + auto* searchIcon = lv_image_create(searchRow); + lv_obj_set_style_image_recolor_opa(searchIcon, LV_OPA_COVER, LV_STATE_DEFAULT); + lv_obj_set_style_image_recolor(searchIcon, lv_theme_get_color_primary(parent), LV_STATE_DEFAULT); + lv_obj_set_style_text_font(searchIcon, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT); + lv_image_set_src(searchIcon, LVGL_ICON_SHARED_SEARCH); + + textarea = lv_textarea_create(searchRow); + lv_textarea_set_placeholder_text(textarea, "e.g. Europe/Amsterdam"); + lv_textarea_set_one_line(textarea, true); + lv_obj_set_flex_grow(textarea, 1); + + // Detects the time zone from the location, with a spinner in its place while it does + detectButton = lvgl_icon_button_create(searchRow); + auto* detectLabel = lv_label_create(detectButton); + lv_obj_set_style_text_font(detectLabel, lvgl_get_shared_icon_default_font(), LV_STATE_DEFAULT); + lv_label_set_text(detectLabel, LVGL_ICON_SHARED_LOCATION_ON); + lv_obj_add_event_cb(detectButton, onDetectPressed, LV_EVENT_SHORT_CLICKED, this); + detectSpinner = lvgl_spinner_create(searchRow); + + messageLabel = lv_label_create(card); + lv_obj_set_width(messageLabel, LV_PCT(100)); + lv_label_set_long_mode(messageLabel, LV_LABEL_LONG_WRAP); + + list = lvgl_list_create(card); + lv_obj_set_width(list, LV_PCT(100)); + lv_obj_set_flex_grow(list, 1); + lv_obj_set_style_border_width(list, 0, LV_STATE_DEFAULT); + + mutex.lock(); + lv_textarea_set_text(textarea, filter.c_str()); + mutex.unlock(); + // Added after restoring the text, so restoring it doesn't filter the list again + lv_obj_add_event_cb(textarea, onFilterChanged, LV_EVENT_VALUE_CHANGED, this); + + showState(); + rebuildList(); +} + +void TimeZoneView::destroyWidgets() { + textarea = nullptr; + detectButton = nullptr; + detectSpinner = nullptr; + messageLabel = nullptr; + list = nullptr; +} + +void TimeZoneView::setSelectedName(const std::string& name) { + mutex.lock(); + selectedName = name; + mutex.unlock(); + if (list != nullptr) { + rebuildList(); + } +} + +void TimeZoneView::onFilterChanged(lv_event_t* event) { + auto* view = static_cast(lv_event_get_user_data(event)); + view->mutex.lock(); + view->filter = string::lowercase(std::string(lv_textarea_get_text(view->textarea))); + view->mutex.unlock(); + view->filterTimer->stop(); + view->filterTimer->start(); +} + +void TimeZoneView::onDetectPressed(lv_event_t* event) { + auto* view = static_cast(lv_event_get_user_data(event)); + if (view->detecting.exchange(true)) { + return; + } + view->mutex.lock(); + view->message.clear(); + view->mutex.unlock(); + view->showState(); + // The previous detection already ended, as detecting was false + if (view->detectThread != nullptr) { + view->detectThread->join(); + } + view->detectThread = std::make_unique("timezone_detect", 4096, [view] { + view->detect(); + return 0; + }); + view->detectThread->start(); +} + +void TimeZoneView::onEntryPressed(lv_event_t* event) { + auto* view = static_cast(lv_event_get_user_data(event)); + const auto index = reinterpret_cast(lv_obj_get_user_data(lv_event_get_target_obj(event))); + view->mutex.lock(); + if (index >= view->entries.size()) { + view->mutex.unlock(); + return; + } + const Entry entry = view->entries[index]; + view->mutex.unlock(); + view->select(entry.name, entry.code); +} + +void TimeZoneView::loadEntries() { + mutex.lock(); + const std::string currentFilter = filter; + mutex.unlock(); + + std::vector matches; + forEachTimeZone([&matches, ¤tFilter](const std::string& name, const std::string& code) { + if (string::lowercase(name).find(currentFilter) != std::string::npos) { + matches.push_back({ name, code }); + } + return matches.size() < MAX_ENTRIES; + }); + + mutex.lock(); + entries = std::move(matches); + mutex.unlock(); +} + +// Called with the LVGL lock held +void TimeZoneView::rebuildList() { + if (list == nullptr) { + return; + } + lv_obj_clean(list); + mutex.lock(); + for (size_t index = 0; index < entries.size(); index++) { + auto* button = lvgl_list_add_button(list, nullptr, entries[index].name.c_str()); + lv_obj_set_user_data(button, reinterpret_cast(static_cast(index))); + lv_obj_add_event_cb(button, onEntryPressed, LV_EVENT_SHORT_CLICKED, this); + if (entries[index].name == selectedName) { + lv_obj_add_state(button, LV_STATE_CHECKED); + } + } + mutex.unlock(); +} + +// Called with the LVGL lock held +void TimeZoneView::showState() { + if (detectButton == nullptr) { + return; + } + const bool isDetecting = detecting; + lv_obj_set_hidden(detectButton, isDetecting); + lv_obj_set_hidden(detectSpinner, !isDetecting); + mutex.lock(); + lv_label_set_text(messageLabel, message.c_str()); + lv_obj_set_hidden(messageLabel, message.empty()); + mutex.unlock(); +} + +// Runs on the detection thread, as the lookup blocks +void TimeZoneView::detect() { + char name[LOCATION_TIME_ZONE_MAX_LENGTH + 1]; + const error_t error = location_lookup_time_zone(name, sizeof(name)); + std::string code; + std::string failure; + if (error == ERROR_NONE) { + forEachTimeZone([&name, &code](const std::string& entryName, const std::string& entryCode) { + if (entryName == name) { + code = entryCode; + return false; + } + return true; + }); + if (code.empty()) { + failure = std::string("The detected time zone ") + name + " isn't in the list."; + } + } else if (error == ERROR_INVALID_STATE) { + failure = "Detecting the time zone needs location services. You can turn them on in Settings."; + } else { + failure = "The time zone couldn't be detected. Check the internet connection and try again."; + } + + lvgl_lock(); + detecting = false; + mutex.lock(); + message = failure; + mutex.unlock(); + showState(); + if (failure.empty()) { + LOG_I(TAG, "Detected %s", name); + // Shows the detected time zone in the list + if (textarea != nullptr) { + lv_textarea_set_text(textarea, name); + } + select(name, code); + } + lvgl_unlock(); +} + +// Called with the LVGL lock held +void TimeZoneView::select(const std::string& name, const std::string& code) { + setSelectedName(name); + onSelected(name, code); +} + +} diff --git a/Tactility/Source/settings/RadioSettings.cpp b/Tactility/Source/settings/RadioSettings.cpp index 3bc99174b..d04827e0e 100644 --- a/Tactility/Source/settings/RadioSettings.cpp +++ b/Tactility/Source/settings/RadioSettings.cpp @@ -46,4 +46,63 @@ bool hasRadio() { return device_exists_of_type(&RADIO_TYPE); } +RadioRegion findRegionForTimeZone(const std::string& timeZoneName) { + struct ZoneRegion { + const char* zone; + RadioRegion region; + }; + // Exact names before the area prefixes below, e.g. the Russian zones in Europe and Asia + static constexpr ZoneRegion ZONES[] = { + { "Europe/Moscow", RADIO_REGION_RU_868 }, + { "Europe/Kaliningrad", RADIO_REGION_RU_868 }, + { "Europe/Samara", RADIO_REGION_RU_868 }, + { "Europe/Volgograd", RADIO_REGION_RU_868 }, + { "Europe/Kirov", RADIO_REGION_RU_868 }, + { "Europe/Astrakhan", RADIO_REGION_RU_868 }, + { "Europe/Saratov", RADIO_REGION_RU_868 }, + { "Europe/Ulyanovsk", RADIO_REGION_RU_868 }, + { "Asia/Yekaterinburg", RADIO_REGION_RU_868 }, + { "Asia/Omsk", RADIO_REGION_RU_868 }, + { "Asia/Novosibirsk", RADIO_REGION_RU_868 }, + { "Asia/Barnaul", RADIO_REGION_RU_868 }, + { "Asia/Tomsk", RADIO_REGION_RU_868 }, + { "Asia/Novokuznetsk", RADIO_REGION_RU_868 }, + { "Asia/Krasnoyarsk", RADIO_REGION_RU_868 }, + { "Asia/Irkutsk", RADIO_REGION_RU_868 }, + { "Asia/Chita", RADIO_REGION_RU_868 }, + { "Asia/Yakutsk", RADIO_REGION_RU_868 }, + { "Asia/Khandyga", RADIO_REGION_RU_868 }, + { "Asia/Vladivostok", RADIO_REGION_RU_868 }, + { "Asia/Ust-Nera", RADIO_REGION_RU_868 }, + { "Asia/Magadan", RADIO_REGION_RU_868 }, + { "Asia/Sakhalin", RADIO_REGION_RU_868 }, + { "Asia/Srednekolymsk", RADIO_REGION_RU_868 }, + { "Asia/Kamchatka", RADIO_REGION_RU_868 }, + { "Asia/Anadyr", RADIO_REGION_RU_868 }, + { "Asia/Shanghai", RADIO_REGION_CN_470 }, + { "Asia/Urumqi", RADIO_REGION_CN_470 }, + { "Asia/Kolkata", RADIO_REGION_IN_865 }, + { "Asia/Calcutta", RADIO_REGION_IN_865 }, + { "Asia/Tokyo", RADIO_REGION_JP_920 }, + { "Pacific/Auckland", RADIO_REGION_NZ_865 }, + { "Pacific/Chatham", RADIO_REGION_NZ_865 }, + }; + static constexpr ZoneRegion AREAS[] = { + { "Europe/", RADIO_REGION_EU_868 }, + { "America/", RADIO_REGION_US_915 }, + { "Australia/", RADIO_REGION_AU_915 }, + }; + for (const auto& zone : ZONES) { + if (timeZoneName == zone.zone) { + return zone.region; + } + } + for (const auto& area : AREAS) { + if (timeZoneName.starts_with(area.zone)) { + return area.region; + } + } + return RADIO_REGION_UNSET; +} + } diff --git a/Tactility/Source/settings/time.cpp b/Tactility/Source/settings/time.cpp index f99372a5e..1060444b8 100644 --- a/Tactility/Source/settings/time.cpp +++ b/Tactility/Source/settings/time.cpp @@ -1,10 +1,12 @@ #include +#include #include #include #include #include +#include #include #include @@ -15,6 +17,8 @@ namespace tt::settings { +constexpr auto* TAG = "Time"; + constexpr auto* TIMEZONE_PREFERENCES_KEY_NAME = "tz_name"; constexpr auto* TIMEZONE_PREFERENCES_KEY_CODE = "tz_code"; constexpr auto* TIMEZONE_PREFERENCES_KEY_TIME24 = "tz_time24"; @@ -61,6 +65,14 @@ void setTimeZone(const std::string& name, const std::string& code) { tzset(); #endif + // The time zone tells where the device is, and so which radio band it may use + if (radio::hasRadio()) { + const RadioRegion region = radio::findRegionForTimeZone(name); + if (region != RADIO_REGION_UNSET && !radio::saveRegion(region)) { + LOG_E(TAG, "Failed to save the radio region"); + } + } + system_event_emit(KERNEL_EVENT_TIME_CHANGED, nullptr, 0); }