Setup improvements

This commit is contained in:
Ken Van Hoeylandt 2026-10-16 00:36:03 +02:00
parent bb68fba13d
commit 0ab30d4b6e
20 changed files with 1048 additions and 244 deletions

View File

@ -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

View File

@ -2,6 +2,7 @@
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <tactility/concurrent/task_event_group.h>
@ -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

View File

@ -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
*/

View File

@ -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);

View File

@ -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
};

View File

@ -3,6 +3,7 @@
#include <location/ip_provider.h>
#include <cstring>
#include <string>
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;

View File

@ -0,0 +1,36 @@
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include <lvgl.h>
#include <stdint.h>
#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

View File

@ -22,6 +22,7 @@
#include <lvgl/widgets/icon_button.h>
#include <lvgl/widgets/list.h>
#include <lvgl/widgets/page_indicator.h>
#include <lvgl/widgets/step_indicator.h>
#include <lvgl/widgets/spinner.h>
#include <lvgl/widgets/statusbar.h>
#include <lvgl/widgets/text_button.h>
@ -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

View File

@ -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);

View File

@ -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);

View File

@ -0,0 +1,82 @@
// SPDX-License-Identifier: Apache-2.0
#define LV_USE_PRIVATE_API 1 // For the lv_obj_class_t declaration
#include <lvgl/widgets/step_indicator.h>
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<int32_t>(i)), LV_STATE_CHECKED, i < finished_count);
}
}
}

View File

@ -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;

View File

@ -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;

View File

@ -2,6 +2,8 @@
#include <tactility/drivers/radio.h>
#include <string>
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);
}

View File

@ -0,0 +1,81 @@
#pragma once
#include <Tactility/Mutex.h>
#include <Tactility/Thread.h>
#include <Tactility/Timer.h>
#include <lvgl.h>
#include <atomic>
#include <functional>
#include <memory>
#include <string>
#include <vector>
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<void(const std::string& name, const std::string& code)>;
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<Entry> entries;
std::string filter;
std::string selectedName;
std::string message;
std::unique_ptr<Timer> filterTimer;
std::unique_ptr<Thread> detectThread;
std::atomic<bool> 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);
};
}

View File

@ -3,11 +3,10 @@
#include <tactility/paths.h>
#include <Tactility/StringUtils.h>
#include <Tactility/app/timezone/TimeZone.h>
#include <Tactility/app/selectiondialog/SelectionDialog.h>
#include <Tactility/app/timezone/TimeZoneView.h>
#include <Tactility/app/wifimanage/WifiManage.h>
#include <Tactility/file/File.h>
#include <Tactility/settings/RadioSettings.h>
#include <Tactility/settings/Time.h>
#include <Tactility/Tactility.h>
#include <app/event.h>
@ -31,11 +30,13 @@
#include <lvgl/fonts.h>
#include <lvgl/grid_navigation.h>
#include <lvgl/lvgl.h>
#include <lvgl/widgets/page_indicator.h>
#include <lvgl/widgets/chip.h>
#include <lvgl/widgets/step_indicator.h>
#include <lvgl/widgets/text_button.h>
#include <lvgl.h>
#include <functional>
#include <memory>
#include <vector>
#ifdef ESP_PLATFORM
@ -96,8 +97,19 @@ struct StepConfiguration {
std::function<void()> run;
/** Optional: handles the result of the dialog that run() started */
std::function<void(int32_t)> 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<timezone::TimeZoneView> 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<uint32_t>(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<uint32_t>(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<int32_t>(i)), LV_STATE_CHECKED, static_cast<int32_t>(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<Context*>(lv_event_get_user_data(event));
ctx->locationChoice = static_cast<int32_t>(reinterpret_cast<uintptr_t>(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<Context*>(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<void*>(static_cast<uintptr_t>(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<std::string> items;
for (int region = RADIO_REGION_UNSET + 1; region < RADIO_REGION_COUNT; region++) {
items.emplace_back(settings::radio::getRegionLabel(static_cast<RadioRegion>(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<RadioRegion>(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<std::string> 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<timezone::TimeZoneView>([&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);
}
});

View File

@ -1,9 +1,5 @@
#include <Tactility/app/timezone/TimeZone.h>
#include <Tactility/LogMessages.h>
#include <Tactility/MountPoints.h>
#include <Tactility/Mutex.h>
#include <Tactility/StringUtils.h>
#include <Tactility/Timer.h>
#include <Tactility/app/timezone/TimeZoneView.h>
#include <Tactility/settings/Time.h>
#include <app/event.h>
@ -15,176 +11,34 @@
#include <lvgl_window_manager/window_manager.h>
#include <tactility/check.h>
#include <tactility/log.h>
#include <lvgl/lvgl.h>
#include <lvgl/icons/shared.h>
#include <lvgl/fonts.h>
#include <lvgl/widgets/list.h>
#include <lvgl/widgets/toolbar.h>
#include <memory>
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<TimeZoneEntry> entries;
std::unique_ptr<Timer> updateTimer;
lv_obj_t* listWidget = nullptr;
lv_obj_t* filterTextareaWidget = nullptr;
bool saveTimeZone = false;
std::unique_ptr<TimeZoneView> 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<Context*>(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<ButtonContext*>(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<ButtonContext*>(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<TimeZoneEntry> 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<Context*>(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<Context*>(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>(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<TimeZoneView>([&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);

View File

@ -0,0 +1,294 @@
#include <Tactility/app/timezone/TimeZoneView.h>
#include <Tactility/MountPoints.h>
#include <Tactility/StringUtils.h>
#include <location/location.h>
#include <tactility/log.h>
#include <lvgl/lvgl.h>
#include <lvgl/fonts.h>
#include <lvgl/icons/shared.h>
#include <lvgl/widgets/card.h>
#include <lvgl/widgets/icon_button.h>
#include <lvgl/widgets/list.h>
#include <lvgl/widgets/spinner.h>
#include <cstdio>
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<bool(const std::string& name, const std::string& code)>& 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>(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<TimeZoneView*>(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<TimeZoneView*>(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<Thread>("timezone_detect", 4096, [view] {
view->detect();
return 0;
});
view->detectThread->start();
}
void TimeZoneView::onEntryPressed(lv_event_t* event) {
auto* view = static_cast<TimeZoneView*>(lv_event_get_user_data(event));
const auto index = reinterpret_cast<uintptr_t>(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<Entry> matches;
forEachTimeZone([&matches, &currentFilter](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<void*>(static_cast<uintptr_t>(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);
}
}

View File

@ -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;
}
}

View File

@ -1,10 +1,12 @@
#include <service/paths.h>
#include <Tactility/settings/RadioSettings.h>
#include <Tactility/settings/Time.h>
#include <Tactility/settings/SystemSettings.h>
#include <tactility/paths.h>
#include <tactility/preferences.h>
#include <tactility/log.h>
#include <tactility/system_event.h>
#include <lvgl/widgets/statusbar.h>
@ -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);
}