From 18a681a924dc5f9d9f0e07b80016068601ef976a Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 24 Aug 2025 22:27:55 +0200 Subject: [PATCH] WIP --- Data/data/settings.properties | 2 + .../system/app/LocaleSettings/i18n/en-GB.i18n | 7 + .../system/app/LocaleSettings/i18n/en-US.i18n | 7 + .../system/app/LocaleSettings/i18n/fr-FR.i18n | 7 + .../system/app/LocaleSettings/i18n/nl-BE.i18n | 7 + .../system/app/LocaleSettings/i18n/nl-NL.i18n | 7 + Tactility/Include/Tactility/i18n/Core.h | 17 -- .../Tactility/i18n/CoreTextResources.h | 2 + Tactility/Include/Tactility/i18n/I18n.h | 24 --- Tactility/Include/Tactility/i18n/Launcher.h | 13 -- .../Include/Tactility/i18n/TextResources.h | 49 +++++ .../Include/Tactility/settings/Language.h | 24 +++ .../Tactility/settings/SettingsProperties.h | 16 ++ .../Tactility/{time => settings}/Time.h | 2 +- .../Tactility/app/launcher/TextResources.h | 2 + .../app/localesettings/LocaleSettings.h | 7 + .../app/localesettings/TextResources.h | 19 ++ .../Private/Tactility/settings/TimePrivate.h | 7 + .../Private/Tactility/time/TimePrivate.h | 7 - Tactility/Source/PartitionsEsp.cpp | 16 +- Tactility/Source/Tactility.cpp | 2 + Tactility/Source/TactilityHeadless.cpp | 4 +- Tactility/Source/app/launcher/Launcher.cpp | 16 +- .../app/localesettings/LocaleSettings.cpp | 168 ++++++++++++++++++ .../app/timedatesettings/TimeDateSettings.cpp | 68 +------ Tactility/Source/i18n/I18n.cpp | 81 --------- Tactility/Source/i18n/TextResources.cpp | 81 +++++++++ Tactility/Source/lvgl/Statusbar.cpp | 4 +- Tactility/Source/settings/Language.cpp | 65 +++++++ .../Source/settings/SettingsProperties.cpp | 77 ++++++++ Tactility/Source/{time => settings}/Time.cpp | 49 +++-- TactilityC/Source/tt_time.cpp | 12 +- Translations/Translations.ods | Bin 13439 -> 13804 bytes Translations/generate-all.py | 11 +- Translations/generate.py | 1 + sdkconfig.board.cyd-2432s024c | 4 + sdkconfig.board.cyd-2432s032c | 4 + sdkconfig.board.cyd-4848s040c | 4 + sdkconfig.board.cyd-8048s043c | 4 + sdkconfig.board.cyd-jc2432w328c | 4 + sdkconfig.board.cyd-jc8048w550c | 4 + sdkconfig.board.elecrow-crowpanel-advance-28 | 4 + sdkconfig.board.elecrow-crowpanel-advance-35 | 4 + sdkconfig.board.elecrow-crowpanel-advance-50 | 4 + sdkconfig.board.elecrow-crowpanel-basic-28 | 4 + sdkconfig.board.elecrow-crowpanel-basic-35 | 4 + sdkconfig.board.elecrow-crowpanel-basic-50 | 4 + sdkconfig.board.lilygo-tdeck | 4 + sdkconfig.board.lilygo-tlora-pager | 4 + sdkconfig.board.m5stack-core2 | 4 + sdkconfig.board.m5stack-cores3 | 4 + sdkconfig.board.unphone | 4 + sdkconfig.board.waveshare-s3-touch-43 | 4 + sdkconfig.defaults | 4 + 54 files changed, 708 insertions(+), 249 deletions(-) create mode 100644 Data/data/settings.properties create mode 100644 Data/system/app/LocaleSettings/i18n/en-GB.i18n create mode 100644 Data/system/app/LocaleSettings/i18n/en-US.i18n create mode 100644 Data/system/app/LocaleSettings/i18n/fr-FR.i18n create mode 100644 Data/system/app/LocaleSettings/i18n/nl-BE.i18n create mode 100644 Data/system/app/LocaleSettings/i18n/nl-NL.i18n delete mode 100644 Tactility/Include/Tactility/i18n/Core.h delete mode 100644 Tactility/Include/Tactility/i18n/I18n.h delete mode 100644 Tactility/Include/Tactility/i18n/Launcher.h create mode 100644 Tactility/Include/Tactility/i18n/TextResources.h create mode 100644 Tactility/Include/Tactility/settings/Language.h create mode 100644 Tactility/Include/Tactility/settings/SettingsProperties.h rename Tactility/Include/Tactility/{time => settings}/Time.h (96%) create mode 100644 Tactility/Private/Tactility/app/localesettings/LocaleSettings.h create mode 100644 Tactility/Private/Tactility/app/localesettings/TextResources.h create mode 100644 Tactility/Private/Tactility/settings/TimePrivate.h delete mode 100644 Tactility/Private/Tactility/time/TimePrivate.h create mode 100644 Tactility/Source/app/localesettings/LocaleSettings.cpp delete mode 100644 Tactility/Source/i18n/I18n.cpp create mode 100644 Tactility/Source/i18n/TextResources.cpp create mode 100644 Tactility/Source/settings/Language.cpp create mode 100644 Tactility/Source/settings/SettingsProperties.cpp rename Tactility/Source/{time => settings}/Time.cpp (75%) diff --git a/Data/data/settings.properties b/Data/data/settings.properties new file mode 100644 index 00000000..0b905db8 --- /dev/null +++ b/Data/data/settings.properties @@ -0,0 +1,2 @@ +language=en-US +timeFormat24h=true \ No newline at end of file diff --git a/Data/system/app/LocaleSettings/i18n/en-GB.i18n b/Data/system/app/LocaleSettings/i18n/en-GB.i18n new file mode 100644 index 00000000..ab750d45 --- /dev/null +++ b/Data/system/app/LocaleSettings/i18n/en-GB.i18n @@ -0,0 +1,7 @@ +Dutch (Netherlands) +Dutch (Belgium) +English (United States) +English (United Kingdom) +French (France) +Region +Language diff --git a/Data/system/app/LocaleSettings/i18n/en-US.i18n b/Data/system/app/LocaleSettings/i18n/en-US.i18n new file mode 100644 index 00000000..ab750d45 --- /dev/null +++ b/Data/system/app/LocaleSettings/i18n/en-US.i18n @@ -0,0 +1,7 @@ +Dutch (Netherlands) +Dutch (Belgium) +English (United States) +English (United Kingdom) +French (France) +Region +Language diff --git a/Data/system/app/LocaleSettings/i18n/fr-FR.i18n b/Data/system/app/LocaleSettings/i18n/fr-FR.i18n new file mode 100644 index 00000000..209b263a --- /dev/null +++ b/Data/system/app/LocaleSettings/i18n/fr-FR.i18n @@ -0,0 +1,7 @@ +Néerlandais (Pays-Bas) +Néerlandais (Belgique) +Anglais (États-Unis) +Anglais (Royaume-Uni) +Français (France) +Région +Langue diff --git a/Data/system/app/LocaleSettings/i18n/nl-BE.i18n b/Data/system/app/LocaleSettings/i18n/nl-BE.i18n new file mode 100644 index 00000000..39900b4b --- /dev/null +++ b/Data/system/app/LocaleSettings/i18n/nl-BE.i18n @@ -0,0 +1,7 @@ +Nederlands (Nederland) +Nederlands (België) +Engels (Verenigde Staten) +Engels (Verenigd Koninkrijk) +Frans (Frankrijk) +Regio +Taal diff --git a/Data/system/app/LocaleSettings/i18n/nl-NL.i18n b/Data/system/app/LocaleSettings/i18n/nl-NL.i18n new file mode 100644 index 00000000..39900b4b --- /dev/null +++ b/Data/system/app/LocaleSettings/i18n/nl-NL.i18n @@ -0,0 +1,7 @@ +Nederlands (Nederland) +Nederlands (België) +Engels (Verenigde Staten) +Engels (Verenigd Koninkrijk) +Frans (Frankrijk) +Regio +Taal diff --git a/Tactility/Include/Tactility/i18n/Core.h b/Tactility/Include/Tactility/i18n/Core.h deleted file mode 100644 index 591664cb..00000000 --- a/Tactility/Include/Tactility/i18n/Core.h +++ /dev/null @@ -1,17 +0,0 @@ -#pragma once - -// WARNING: This file is auto-generated. Do not edit manually. - -namespace tt::i18n::core { - -enum class Text { - OK = 0, - YES = 1, - NO = 2, - CANCEL = 3, - RETRY = 4, - CLOSE = 5, - OPEN = 6, -}; - -} diff --git a/Tactility/Include/Tactility/i18n/CoreTextResources.h b/Tactility/Include/Tactility/i18n/CoreTextResources.h index 591664cb..ad389b83 100644 --- a/Tactility/Include/Tactility/i18n/CoreTextResources.h +++ b/Tactility/Include/Tactility/i18n/CoreTextResources.h @@ -1,5 +1,7 @@ #pragma once +#include "Tactility/i18n/TextResources.h" + // WARNING: This file is auto-generated. Do not edit manually. namespace tt::i18n::core { diff --git a/Tactility/Include/Tactility/i18n/I18n.h b/Tactility/Include/Tactility/i18n/I18n.h deleted file mode 100644 index 9e091045..00000000 --- a/Tactility/Include/Tactility/i18n/I18n.h +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#include -#include - -namespace tt::i18n { - -class IndexedText { - -public: - - virtual ~IndexedText() = default; - - virtual const std::string& get(int index) const = 0; - - template - const std::string& get(EnumType value) const { return get(static_cast(value)); } - - const std::string& operator[](const int index) const { return get(index); } -}; - -std::shared_ptr loadIndexedText(const std::string& path); - -} \ No newline at end of file diff --git a/Tactility/Include/Tactility/i18n/Launcher.h b/Tactility/Include/Tactility/i18n/Launcher.h deleted file mode 100644 index 2a316726..00000000 --- a/Tactility/Include/Tactility/i18n/Launcher.h +++ /dev/null @@ -1,13 +0,0 @@ -#pragma once - -// WARNING: This file is auto-generated. Do not edit manually. - -namespace tt::i18n::launcher { - -enum class Text { - APPS = 0, - FILES = 1, - SETTINGS = 2, -}; - -} diff --git a/Tactility/Include/Tactility/i18n/TextResources.h b/Tactility/Include/Tactility/i18n/TextResources.h new file mode 100644 index 00000000..4e016443 --- /dev/null +++ b/Tactility/Include/Tactility/i18n/TextResources.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include + +namespace tt::i18n { + +/** + * Holds localized text data. + * + * It is used with data generated from Translations/ with the python generation scripts. + * It's used with a header file that specifies the indexes, and generated text files (.i18n) + */ +class TextResources { + + std::vector data; + std::string path; + static std::string ERROR_RESULT; + +public: + /** + * @param[in] path + */ + TextResources(const std::string& path) : path(path) {} + + const std::string& get(const int index) const { + if (index < data.size()) { + return data[index]; + } else { + return ERROR_RESULT; + } + } + + template + const std::string& get(EnumType value) const { return get(static_cast(value)); } + + const std::string& operator[](const int index) const { return get(index); } + + template + const std::string& operator[](const EnumType index) const { return get(index); } + + /** + * Load or reload an i18n file with the system's current locale settings. + * @return true on success + */ + bool load(); +}; + +} \ No newline at end of file diff --git a/Tactility/Include/Tactility/settings/Language.h b/Tactility/Include/Tactility/settings/Language.h new file mode 100644 index 00000000..953cb281 --- /dev/null +++ b/Tactility/Include/Tactility/settings/Language.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +namespace tt::settings { + +enum class Language { + en_GB, + en_US, + fr_FR, + nl_BE, + nl_NL, + count +}; + +void setLanguage(Language language); + +Language getLanguage(); + +std::string toString(Language language); + +bool fromString(const std::string& text, Language& language); + +} diff --git a/Tactility/Include/Tactility/settings/SettingsProperties.h b/Tactility/Include/Tactility/settings/SettingsProperties.h new file mode 100644 index 00000000..3161c81d --- /dev/null +++ b/Tactility/Include/Tactility/settings/SettingsProperties.h @@ -0,0 +1,16 @@ +#pragma once + +#include "Language.h" + +namespace tt::settings { + +struct SettingsProperties { + Language language; + bool timeFormat24h; +}; + +bool loadSettingsProperties(SettingsProperties& properties); + +bool saveSettingsProperties(const SettingsProperties& properties); + +} diff --git a/Tactility/Include/Tactility/time/Time.h b/Tactility/Include/Tactility/settings/Time.h similarity index 96% rename from Tactility/Include/Tactility/time/Time.h rename to Tactility/Include/Tactility/settings/Time.h index 88448e4f..40ff450f 100644 --- a/Tactility/Include/Tactility/time/Time.h +++ b/Tactility/Include/Tactility/settings/Time.h @@ -2,7 +2,7 @@ #include -namespace tt::time { +namespace tt::settings { /** * Set the timezone diff --git a/Tactility/Private/Tactility/app/launcher/TextResources.h b/Tactility/Private/Tactility/app/launcher/TextResources.h index 6bfdf684..efb5acd9 100644 --- a/Tactility/Private/Tactility/app/launcher/TextResources.h +++ b/Tactility/Private/Tactility/app/launcher/TextResources.h @@ -1,5 +1,7 @@ #pragma once +#include "Tactility/i18n/TextResources.h" + // WARNING: This file is auto-generated. Do not edit manually. namespace tt::app::launcher::i18n { diff --git a/Tactility/Private/Tactility/app/localesettings/LocaleSettings.h b/Tactility/Private/Tactility/app/localesettings/LocaleSettings.h new file mode 100644 index 00000000..5fd746c0 --- /dev/null +++ b/Tactility/Private/Tactility/app/localesettings/LocaleSettings.h @@ -0,0 +1,7 @@ +#pragma once + +namespace tt::app::localesettings { + +void start(); + +} \ No newline at end of file diff --git a/Tactility/Private/Tactility/app/localesettings/TextResources.h b/Tactility/Private/Tactility/app/localesettings/TextResources.h new file mode 100644 index 00000000..08728900 --- /dev/null +++ b/Tactility/Private/Tactility/app/localesettings/TextResources.h @@ -0,0 +1,19 @@ +#pragma once + +#include "Tactility/i18n/TextResources.h" + +// WARNING: This file is auto-generated. Do not edit manually. + +namespace tt::app::localesettings::i18n { + +enum class Text { + NL_NL = 0, + NL_BE = 1, + EN_US = 2, + EN_GB = 3, + FR_FR = 4, + REGION = 5, + LANGUAGE = 6, +}; + +} diff --git a/Tactility/Private/Tactility/settings/TimePrivate.h b/Tactility/Private/Tactility/settings/TimePrivate.h new file mode 100644 index 00000000..1968e5e2 --- /dev/null +++ b/Tactility/Private/Tactility/settings/TimePrivate.h @@ -0,0 +1,7 @@ +#pragma once + +namespace tt::settings { + +void initTimeZone(); + +} diff --git a/Tactility/Private/Tactility/time/TimePrivate.h b/Tactility/Private/Tactility/time/TimePrivate.h deleted file mode 100644 index 92b13222..00000000 --- a/Tactility/Private/Tactility/time/TimePrivate.h +++ /dev/null @@ -1,7 +0,0 @@ -#pragma once - -namespace tt::time { - -void init(); - -} diff --git a/Tactility/Source/PartitionsEsp.cpp b/Tactility/Source/PartitionsEsp.cpp index cf9f7bbf..3381182f 100644 --- a/Tactility/Source/PartitionsEsp.cpp +++ b/Tactility/Source/PartitionsEsp.cpp @@ -22,13 +22,27 @@ static esp_err_t initNvsFlashSafely() { static wl_handle_t data_wl_handle = WL_INVALID_HANDLE; +size_t getSectorSize() { +#if defined(CONFIG_FATFS_SECTOR_512) + return 512; +#elif defined(CONFIG_FATFS_SECTOR_1024) + return 1024; +#elif defined(CONFIG_FATFS_SECTOR_2048) + return 2048; +#elif defined(CONFIG_FATFS_SECTOR_4096) + return 4096; +#else +#error Not implemented +#endif +} + esp_err_t initPartitionsEsp() { ESP_ERROR_CHECK(initNvsFlashSafely()); const esp_vfs_fat_mount_config_t mount_config = { .format_if_mount_failed = false, .max_files = 4, - .allocation_unit_size = CONFIG_WL_SECTOR_SIZE, + .allocation_unit_size = getSectorSize(), .disk_status_check_enable = false, .use_one_fat = true, }; diff --git a/Tactility/Source/Tactility.cpp b/Tactility/Source/Tactility.cpp index 8cb64c69..5250348c 100644 --- a/Tactility/Source/Tactility.cpp +++ b/Tactility/Source/Tactility.cpp @@ -47,6 +47,7 @@ namespace app { namespace imageviewer { extern const AppManifest manifest; } namespace inputdialog { extern const AppManifest manifest; } namespace launcher { extern const AppManifest manifest; } + namespace localesettings { extern const AppManifest manifest; } namespace log { extern const AppManifest manifest; } namespace notes { extern const AppManifest manifest; } namespace power { extern const AppManifest manifest; } @@ -89,6 +90,7 @@ static void registerSystemApps() { addApp(app::imageviewer::manifest); addApp(app::inputdialog::manifest); addApp(app::launcher::manifest); + addApp(app::localesettings::manifest); addApp(app::log::manifest); addApp(app::notes::manifest); addApp(app::serialconsole::manifest); diff --git a/Tactility/Source/TactilityHeadless.cpp b/Tactility/Source/TactilityHeadless.cpp index 97570836..a827e5ab 100644 --- a/Tactility/Source/TactilityHeadless.cpp +++ b/Tactility/Source/TactilityHeadless.cpp @@ -6,7 +6,7 @@ #include "Tactility/service/ServiceRegistration.h" #include -#include +#include #ifdef ESP_PLATFORM #include "Tactility/InitEsp.h" @@ -45,7 +45,7 @@ void initHeadless(const hal::Configuration& config) { initEsp(); #endif hardwareConfig = &config; - time::init(); + settings::initTimeZone(); hal::init(config); network::ntp::init(); registerAndStartSystemServices(); diff --git a/Tactility/Source/app/launcher/Launcher.cpp b/Tactility/Source/app/launcher/Launcher.cpp index 82ec55af..1c994cc7 100644 --- a/Tactility/Source/app/launcher/Launcher.cpp +++ b/Tactility/Source/app/launcher/Launcher.cpp @@ -1,14 +1,12 @@ #include "Tactility/app/AppContext.h" +#include "Tactility/app/launcher/TextResources.h" #include "Tactility/app/AppRegistration.h" #include "Tactility/service/loader/Loader.h" -#include "Tactility/i18n/Launcher.h" - #include #include #include -#include namespace tt::app::launcher { @@ -55,11 +53,9 @@ static lv_obj_t* createAppButton(lv_obj_t* parent, const char* title, const char } class LauncherApp : public App { - std::shared_ptr launcherText = i18n::loadIndexedText("/data/i18n/launcher"); + tt::i18n::TextResources textResources = tt::i18n::TextResources("/system/app/Launcher/i18n"); void onCreate(TT_UNUSED AppContext& app) override { - assert(launcherText != nullptr); - BootProperties boot_properties; if (loadBootProperties(boot_properties) && !boot_properties.autoStartAppId.empty()) { TT_LOG_I(TAG, "Starting %s", boot_properties.autoStartAppId.c_str()); @@ -68,6 +64,8 @@ class LauncherApp : public App { } void onShow(TT_UNUSED AppContext& app, lv_obj_t* parent) override { + textResources.load(); + auto* wrapper = lv_obj_create(parent); lv_obj_align(wrapper, LV_ALIGN_CENTER, 0, 0); @@ -94,9 +92,9 @@ class LauncherApp : public App { auto files_icon_path = paths->getSystemPathLvgl("icon_files.png"); auto settings_icon_path = paths->getSystemPathLvgl("icon_settings.png"); - const auto& apps_title = launcherText->get(i18n::launcher::Text::APPS); - const auto& files_title = launcherText->get(i18n::launcher::Text::FILES); - const auto& settings_title = launcherText->get(i18n::launcher::Text::SETTINGS); + const auto& apps_title = textResources[i18n::Text::APPS]; + const auto& files_title = textResources[i18n::Text::FILES]; + const auto& settings_title = textResources[i18n::Text::SETTINGS]; createAppButton(wrapper, apps_title.c_str(), apps_icon_path.c_str(), "AppList", 0); createAppButton(wrapper, files_title.c_str(), files_icon_path.c_str(), "Files", padding); diff --git a/Tactility/Source/app/localesettings/LocaleSettings.cpp b/Tactility/Source/app/localesettings/LocaleSettings.cpp new file mode 100644 index 00000000..cb4bb560 --- /dev/null +++ b/Tactility/Source/app/localesettings/LocaleSettings.cpp @@ -0,0 +1,168 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace tt::app::localesettings { + +constexpr auto* TAG = "LocaleSettings"; + +extern const AppManifest manifest; + +class LocaleSettingsApp : public App { + tt::i18n::TextResources textResources = tt::i18n::TextResources("/system/app/LocaleSettings/i18n"); + Mutex mutex = Mutex(Mutex::Type::Recursive); + lv_obj_t* timeZoneLabel = nullptr; + lv_obj_t* regionLabel = nullptr; + lv_obj_t* languageDropdown = nullptr; + lv_obj_t* languageLabel = nullptr; + + static void onConfigureTimeZonePressed(TT_UNUSED lv_event_t* event) { + timezone::start(); + } + + std::map languageMap; + + std::string getLanguageOptions() const { + std::vector items; + for (int i = 0; i < static_cast(settings::Language::count); i++) { + switch (static_cast(i)) { + case settings::Language::en_GB: + items.push_back(textResources[i18n::Text::EN_GB]); + break; + case settings::Language::en_US: + items.push_back(textResources[i18n::Text::EN_US]); + break; + case settings::Language::fr_FR: + items.push_back(textResources[i18n::Text::FR_FR]); + break; + case settings::Language::nl_BE: + items.push_back(textResources[i18n::Text::NL_BE]); + break; + case settings::Language::nl_NL: + items.push_back(textResources[i18n::Text::NL_NL]); + break; + case settings::Language::count: + break; + } + } + return string::join(items, "\n"); + } + + void updateViews() { + textResources.load(); + + lv_label_set_text(regionLabel , textResources[i18n::Text::REGION].c_str()); + lv_label_set_text(languageLabel, textResources[i18n::Text::LANGUAGE].c_str()); + + std::string language_options = getLanguageOptions(); + lv_dropdown_set_options(languageDropdown, language_options.c_str()); + lv_dropdown_set_selected(languageDropdown, static_cast(settings::getLanguage())); + } + + static void onLanguageSet(lv_event_t* event) { + auto* dropdown = static_cast(lv_event_get_target(event)); + auto index = lv_dropdown_get_selected(dropdown); + auto language = static_cast(index); + settings::setLanguage(language); + + auto* self = static_cast(lv_event_get_user_data(event)); + self->updateViews(); + } + +public: + + void onShow(AppContext& app, lv_obj_t* parent) override { + textResources.load(); + + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + + lvgl::toolbar_create(parent, app); + + auto* main_wrapper = lv_obj_create(parent); + lv_obj_set_flex_flow(main_wrapper, LV_FLEX_FLOW_COLUMN); + lv_obj_set_width(main_wrapper, LV_PCT(100)); + lv_obj_set_flex_grow(main_wrapper, 1); + + auto* region_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_width(region_wrapper, LV_PCT(100)); + lv_obj_set_height(region_wrapper, LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(region_wrapper, 0, 0); + lv_obj_set_style_border_width(region_wrapper, 0, 0); + + regionLabel = lv_label_create(region_wrapper); + lv_label_set_text(regionLabel , textResources[i18n::Text::REGION].c_str()); + lv_obj_align(regionLabel , LV_ALIGN_LEFT_MID, 0, 0); + + timeZoneLabel = lv_label_create(region_wrapper); + std::string timeZoneName = settings::getTimeZoneName(); + if (timeZoneName.empty()) { + timeZoneName = "not set"; + } + lv_label_set_text(timeZoneLabel, timeZoneName.c_str()); + // TODO: Find out why Y offset is needed + lv_obj_align_to(timeZoneLabel, regionLabel, LV_ALIGN_OUT_RIGHT_MID, 10, 8); + + auto* region_button = lv_button_create(region_wrapper); + lv_obj_align(region_button, LV_ALIGN_TOP_RIGHT, 0, 0); + auto* region_button_image = lv_image_create(region_button); + lv_obj_add_event_cb(region_button, onConfigureTimeZonePressed, LV_EVENT_SHORT_CLICKED, nullptr); + lv_image_set_src(region_button_image, LV_SYMBOL_SETTINGS); + + auto* language_wrapper = lv_obj_create(main_wrapper); + lv_obj_set_width(language_wrapper, LV_PCT(100)); + lv_obj_set_height(language_wrapper, LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(language_wrapper, 0, 0); + lv_obj_set_style_border_width(language_wrapper, 0, 0); + + languageLabel = lv_label_create(language_wrapper); + lv_label_set_text(languageLabel, textResources[i18n::Text::LANGUAGE].c_str()); + lv_obj_align(languageLabel, LV_ALIGN_LEFT_MID, 0, 0); + + languageDropdown = lv_dropdown_create(language_wrapper); + lv_obj_align(languageDropdown, LV_ALIGN_RIGHT_MID, 0, 0); + std::string language_options = getLanguageOptions(); + lv_dropdown_set_options(languageDropdown, language_options.c_str()); + lv_dropdown_set_selected(languageDropdown, static_cast(settings::getLanguage())); + lv_obj_add_event_cb(languageDropdown, onLanguageSet, LV_EVENT_VALUE_CHANGED, this); + } + + void onResult(AppContext& app, TT_UNUSED LaunchId launchId, Result result, std::unique_ptr bundle) override { + if (result == Result::Ok && bundle != nullptr) { + const auto name = timezone::getResultName(*bundle); + const auto code = timezone::getResultCode(*bundle); + TT_LOG_I(TAG, "Result name=%s code=%s", name.c_str(), code.c_str()); + settings::setTimeZone(name, code); + + if (!name.empty()) { + if (lvgl::lock(100 / portTICK_PERIOD_MS)) { + lv_label_set_text(timeZoneLabel, name.c_str()); + lvgl::unlock(); + } + } + } + } +}; + +extern const AppManifest manifest = { + .id = "LocaleSettings", + .name = "Region & Language", + .icon = TT_ASSETS_APP_ICON_TIME_DATE_SETTINGS, + .type = Type::Settings, + .createApp = create +}; + +void start() { + service::loader::startApp(manifest.id); +} + +} // namespace diff --git a/Tactility/Source/app/timedatesettings/TimeDateSettings.cpp b/Tactility/Source/app/timedatesettings/TimeDateSettings.cpp index f9290374..95e4eb57 100644 --- a/Tactility/Source/app/timedatesettings/TimeDateSettings.cpp +++ b/Tactility/Source/app/timedatesettings/TimeDateSettings.cpp @@ -1,33 +1,25 @@ -#include "Tactility/app/timezone/TimeZone.h" -#include "Tactility/lvgl/Toolbar.h" -#include "Tactility/service/loader/Loader.h" -#include "Tactility/lvgl/LvglSync.h" - #include -#include +#include +#include +#include +#include + #include -#define TAG "text_viewer" - namespace tt::app::timedatesettings { +constexpr auto* TAG = "TimeDate"; + extern const AppManifest manifest; class TimeDateSettingsApp : public App { -private: - Mutex mutex = Mutex(Mutex::Type::Recursive); - lv_obj_t* regionLabelWidget = nullptr; - - static void onConfigureTimeZonePressed(TT_UNUSED lv_event_t* event) { - timezone::start(); - } static void onTimeFormatChanged(lv_event_t* event) { auto* widget = lv_event_get_target_obj(event); bool show_24 = lv_obj_has_state(widget, LV_STATE_CHECKED); - time::setTimeFormat24Hour(show_24); + settings::setTimeFormat24Hour(show_24); } public: @@ -42,32 +34,6 @@ public: lv_obj_set_width(main_wrapper, LV_PCT(100)); lv_obj_set_flex_grow(main_wrapper, 1); - auto* region_wrapper = lv_obj_create(main_wrapper); - lv_obj_set_width(region_wrapper, LV_PCT(100)); - lv_obj_set_height(region_wrapper, LV_SIZE_CONTENT); - lv_obj_set_style_pad_all(region_wrapper, 0, 0); - lv_obj_set_style_border_width(region_wrapper, 0, 0); - - auto* region_prefix_label = lv_label_create(region_wrapper); - lv_label_set_text(region_prefix_label, "Region: "); - lv_obj_align(region_prefix_label, LV_ALIGN_LEFT_MID, 0, 0); - - auto* region_label = lv_label_create(region_wrapper); - std::string timeZoneName = time::getTimeZoneName(); - if (timeZoneName.empty()) { - timeZoneName = "not set"; - } - regionLabelWidget = region_label; - lv_label_set_text(region_label, timeZoneName.c_str()); - // TODO: Find out why Y offset is needed - lv_obj_align_to(region_label, region_prefix_label, LV_ALIGN_OUT_RIGHT_MID, 0, 8); - - auto* region_button = lv_button_create(region_wrapper); - lv_obj_align(region_button, LV_ALIGN_TOP_RIGHT, 0, 0); - auto* region_button_image = lv_image_create(region_button); - lv_obj_add_event_cb(region_button, onConfigureTimeZonePressed, LV_EVENT_SHORT_CLICKED, nullptr); - lv_image_set_src(region_button_image, LV_SYMBOL_SETTINGS); - auto* time_format_wrapper = lv_obj_create(main_wrapper); lv_obj_set_width(time_format_wrapper, LV_PCT(100)); lv_obj_set_height(time_format_wrapper, LV_SIZE_CONTENT); @@ -81,28 +47,12 @@ public: auto* time_24h_switch = lv_switch_create(time_format_wrapper); lv_obj_align(time_24h_switch, LV_ALIGN_RIGHT_MID, 0, 0); lv_obj_add_event_cb(time_24h_switch, onTimeFormatChanged, LV_EVENT_VALUE_CHANGED, nullptr); - if (time::isTimeFormat24Hour()) { + if (settings::isTimeFormat24Hour()) { lv_obj_add_state(time_24h_switch, LV_STATE_CHECKED); } else { lv_obj_remove_state(time_24h_switch, LV_STATE_CHECKED); } } - - void onResult(AppContext& app, TT_UNUSED LaunchId launchId, Result result, std::unique_ptr bundle) override { - if (result == Result::Ok && bundle != nullptr) { - auto name = timezone::getResultName(*bundle); - auto code = timezone::getResultCode(*bundle); - TT_LOG_I(TAG, "Result name=%s code=%s", name.c_str(), code.c_str()); - time::setTimeZone(name, code); - - if (!name.empty()) { - if (lvgl::lock(100 / portTICK_PERIOD_MS)) { - lv_label_set_text(regionLabelWidget, name.c_str()); - lvgl::unlock(); - } - } - } - } }; extern const AppManifest manifest = { diff --git a/Tactility/Source/i18n/I18n.cpp b/Tactility/Source/i18n/I18n.cpp deleted file mode 100644 index 6458104a..00000000 --- a/Tactility/Source/i18n/I18n.cpp +++ /dev/null @@ -1,81 +0,0 @@ -#include "Tactility/i18n/I18n.h" -#include "Tactility/file/FileLock.h" - -#include -#include -#include - -namespace tt::i18n { - -constexpr auto* TAG = "I18n"; -static std::string ERROR_RESULT = "TRANSLATION_ERROR"; - -class IndexedTextImplementation : IndexedText { - - std::vector data; - -public: - - explicit IndexedTextImplementation(std::vector data) : data(std::move(data)) {} - - const std::string& get(const int index) const override { - if (index < data.size()) { - return data[index]; - } else { - return ERROR_RESULT; - } - } -}; - -static std::string getDesiredLocale() { - // TODO: Implement locale settings - return "en-GB"; -} - -static std::string getFallbackLocale() { - // TODO: Implement locale settings - return "en-GB"; -} - -static std::string getI18nDataFilePath(const std::string& path) { - auto locale = getDesiredLocale(); - auto desired_file_path = std::format("{}/{}.i18n", path, locale); - if (file::isFile(desired_file_path)) { - return desired_file_path; - } else { - TT_LOG_W(TAG, "Translations not found for %s at %s", locale.c_str(), desired_file_path.c_str()); - } - - auto fallback_locale = getFallbackLocale(); - auto fallback_file_path = std::format("{}/{}.i18n", path, getFallbackLocale()); - if (file::isFile(fallback_file_path)) { - return fallback_file_path; - } else { - TT_LOG_W(TAG, "Fallback translations not found for %s at %s", fallback_locale.c_str(), fallback_file_path.c_str()); - return ""; - } -} - -std::shared_ptr loadIndexedText(const std::string& path) { - std::vector data; - auto file_path = getI18nDataFilePath(path); - if (file_path.empty()) { - return nullptr; - } - - // We lock on folder level, because file is TBD - file::withLock(path, [&file_path, &data] { - file::readLines(file_path, true, [&data](const char* line) { - data.push_back(line); - }); - }); - - if (data.empty()) { - return nullptr; - } - - auto result = std::make_shared(data); - return std::reinterpret_pointer_cast(result); -} - -} diff --git a/Tactility/Source/i18n/TextResources.cpp b/Tactility/Source/i18n/TextResources.cpp new file mode 100644 index 00000000..07b94a55 --- /dev/null +++ b/Tactility/Source/i18n/TextResources.cpp @@ -0,0 +1,81 @@ +#include "Tactility/i18n/TextResources.h" +#include "Tactility/file/FileLock.h" + +#include + +#include +#include +#include + +namespace tt::i18n { + +constexpr auto* TAG = "I18n"; + +static std::string getFallbackLocale() { + return "en-US"; +} + +static std::string getDesiredLocale() { + switch (settings::getLanguage()) { + case settings::Language::en_GB: + return "en-GB"; + case settings::Language::en_US: + return "en-US"; + case settings::Language::fr_FR: + return "fr-FR"; + case settings::Language::nl_BE: + return "nl-BE"; + case settings::Language::nl_NL: + return "nl-NL"; + default: + return getFallbackLocale(); + } +} + +static std::string getI18nDataFilePath(const std::string& path) { + auto locale = getDesiredLocale(); + auto desired_file_path = std::format("{}/{}.i18n", path, locale); + if (file::isFile(desired_file_path)) { + return desired_file_path; + } else { + TT_LOG_W(TAG, "Translations not found for %s at %s", locale.c_str(), desired_file_path.c_str()); + } + + auto fallback_locale = getFallbackLocale(); + auto fallback_file_path = std::format("{}/{}.i18n", path, getFallbackLocale()); + if (file::isFile(fallback_file_path)) { + return fallback_file_path; + } else { + TT_LOG_W(TAG, "Fallback translations not found for %s at %s", fallback_locale.c_str(), fallback_file_path.c_str()); + return ""; + } +} + +std::string TextResources::ERROR_RESULT = "TXT_RES_ERROR"; + +bool TextResources::load() { + std::vector new_data; + + // Resolve the language file that we need (depends on system language selection) + auto file_path = getI18nDataFilePath(path); + if (file_path.empty()) { + TT_LOG_E(TAG, "Couldn't find i18n data for %s", path.c_str()); + return false; + } + + file::withLock(file_path, [&file_path, &new_data] { + file::readLines(file_path, true, [&new_data](const char* line) { + new_data.push_back(line); + }); + }); + + if (new_data.empty()) { + TT_LOG_E(TAG, "Couldn't find i18n data for %s", path.c_str()); + return false; + } + + data = std::move(new_data); + return true; +} + +} diff --git a/Tactility/Source/lvgl/Statusbar.cpp b/Tactility/Source/lvgl/Statusbar.cpp index 7e9e39b1..68ca0deb 100644 --- a/Tactility/Source/lvgl/Statusbar.cpp +++ b/Tactility/Source/lvgl/Statusbar.cpp @@ -10,7 +10,7 @@ #include #include #include -#include +#include #include @@ -199,7 +199,7 @@ lv_obj_t* statusbar_create(lv_obj_t* parent) { static void update_time(Statusbar* statusbar) { if (statusbar_data.time_set) { - bool format24 = time::isTimeFormat24Hour(); + bool format24 = settings::isTimeFormat24Hour(); int hours = format24 ? statusbar_data.time_hours : statusbar_data.time_hours % 12; lv_label_set_text_fmt(statusbar->time, "%d:%02d", hours, statusbar_data.time_minutes); } else { diff --git a/Tactility/Source/settings/Language.cpp b/Tactility/Source/settings/Language.cpp new file mode 100644 index 00000000..2e4094eb --- /dev/null +++ b/Tactility/Source/settings/Language.cpp @@ -0,0 +1,65 @@ +#include +#include +#include +#include + +namespace tt::settings { + +constexpr auto* TAG = "Language"; + +void setLanguage(Language newLanguage) { + SettingsProperties properties; + if (!loadSettingsProperties(properties)) { + return; + } + + properties.language = newLanguage; + saveSettingsProperties(properties); +} + +Language getLanguage() { + SettingsProperties properties; + if (!loadSettingsProperties(properties)) { + return Language::en_US; + } else { + return properties.language; + } +} + +std::string toString(Language language) { + switch (language) { + case Language::en_GB: + return "en-GB"; + case Language::en_US: + return "en-US"; + case Language::fr_FR: + return "fr-FR"; + case Language::nl_BE: + return "nl-BE"; + case Language::nl_NL: + return "nl-NL"; + default: + TT_LOG_E(TAG, "Missing serialization for language %d", static_cast(language)); + std::unreachable(); + } +} + +bool fromString(const std::string& text, Language& language) { + if (text == "en-GB") { + language = Language::en_GB; + } else if (text == "en-US") { + language = Language::en_US; + } else if (text == "fr-FR") { + language = Language::fr_FR; + } else if (text == "nl-BE") { + language = Language::nl_BE; + } else if (text == "nl-NL") { + language = Language::nl_NL; + } else { + return false; + } + + return true; +} + +} diff --git a/Tactility/Source/settings/SettingsProperties.cpp b/Tactility/Source/settings/SettingsProperties.cpp new file mode 100644 index 00000000..05e56624 --- /dev/null +++ b/Tactility/Source/settings/SettingsProperties.cpp @@ -0,0 +1,77 @@ +#include +#include +#include +#include +#include + +namespace tt::settings { + +constexpr auto* TAG = "SettingsProperties"; +constexpr auto* FILE_PATH = "/data/settings.properties"; + +static Mutex mutex = Mutex(); +static bool cached = false; +static SettingsProperties cachedProperties; + +static bool loadSettingsPropertiesFromFile(SettingsProperties& properties) { + std::map map; + if (!file::withLock(FILE_PATH, [&map] { + return file::loadPropertiesFile(FILE_PATH, map); + })) { + TT_LOG_E(TAG, "Failed to load %s", FILE_PATH); + return false; + } + + auto language_entry = map.find("language"); + if (language_entry != map.end()) { + if (!fromString(language_entry->second, properties.language)) { + TT_LOG_W(TAG, "Unknown language \"%s\" in %s", language_entry->second.c_str(), FILE_PATH); + properties.language = Language::en_US; + } + } else { + properties.language = Language::en_US; + } + + auto time_format_entry = map.find("timeFormat24h"); + bool time_format_24h = time_format_entry == map.end() ? true : (time_format_entry->second == "true"); + properties.timeFormat24h = time_format_24h; + + return true; +} + +bool loadSettingsProperties(SettingsProperties& properties) { + auto scoped_lock = mutex.asScopedLock(); + scoped_lock.lock(); + + if (!cached) { + if (!loadSettingsPropertiesFromFile(cachedProperties)) { + return false; + } + cached = true; + } + + properties = cachedProperties; + return true; +} + +bool saveSettingsProperties(const SettingsProperties& properties) { + auto scoped_lock = mutex.asScopedLock(); + scoped_lock.lock(); + + return file::withLock(FILE_PATH, [&properties] { + std::map map; + map["language"] = toString(properties.language); + map["timeFormat24h"] = properties.timeFormat24h ? "true" : "false"; + + if (!file::savePropertiesFile(FILE_PATH, map)) { + TT_LOG_E(TAG, "Failed to save %s", FILE_PATH); + return false; + } + + cachedProperties = properties; + cached = true; + return true; + }); +} + +} \ No newline at end of file diff --git a/Tactility/Source/time/Time.cpp b/Tactility/Source/settings/Time.cpp similarity index 75% rename from Tactility/Source/time/Time.cpp rename to Tactility/Source/settings/Time.cpp index 99d97f12..50cd855a 100644 --- a/Tactility/Source/time/Time.cpp +++ b/Tactility/Source/settings/Time.cpp @@ -1,12 +1,14 @@ -#include "Tactility/time/Time.h" +#include "Tactility/settings/Time.h" #include "Tactility/kernel/SystemEvents.h" +#include + #ifdef ESP_PLATFORM #include #include "Tactility/Preferences.h" #endif -namespace tt::time { +namespace tt::settings { #ifdef ESP_PLATFORM @@ -16,7 +18,7 @@ namespace tt::time { #define TIMEZONE_PREFERENCES_KEY_CODE "tz_code" #define TIMEZONE_PREFERENCES_KEY_TIME24 "tz_time24" -void init() { +void initTimeZone() { auto code= getTimeZoneCode(); if (!code.empty()) { setenv("TZ", code.c_str(), 1); @@ -55,19 +57,6 @@ std::string getTimeZoneCode() { } } -bool isTimeFormat24Hour() { - Preferences preferences(TIME_SETTINGS_NAMESPACE); - bool show24Hour = true; - preferences.optBool(TIMEZONE_PREFERENCES_KEY_TIME24, show24Hour); - return show24Hour; -} - -void setTimeFormat24Hour(bool show24Hour) { - Preferences preferences(TIME_SETTINGS_NAMESPACE); - preferences.putBool(TIMEZONE_PREFERENCES_KEY_TIME24, show24Hour); - kernel::publishSystemEvent(kernel::SystemEvent::Time); -} - #else static std::string timeZoneName; @@ -90,15 +79,25 @@ std::string getTimeZoneCode() { return timeZoneCode; } -bool isTimeFormat24Hour() { - return show24Hour; -} - -void setTimeFormat24Hour(bool enabled) { - show24Hour = enabled; - kernel::publishSystemEvent(kernel::SystemEvent::Time); -} - #endif +bool isTimeFormat24Hour() { + SettingsProperties properties; + if (!loadSettingsProperties(properties)) { + return true; + } else { + return properties.timeFormat24h; + } +} + +void setTimeFormat24Hour(bool show24Hour) { + SettingsProperties properties; + if (!loadSettingsProperties(properties)) { + return; + } + + properties.timeFormat24h = show24Hour; + saveSettingsProperties(properties); +} + } diff --git a/TactilityC/Source/tt_time.cpp b/TactilityC/Source/tt_time.cpp index 9a255e8f..ee97242b 100644 --- a/TactilityC/Source/tt_time.cpp +++ b/TactilityC/Source/tt_time.cpp @@ -1,6 +1,6 @@ #include "tt_time.h" -#include +#include #include using namespace tt; @@ -8,11 +8,11 @@ using namespace tt; extern "C" { void tt_timezone_set(const char* name, const char* code) { - time::setTimeZone(name, code); + settings::setTimeZone(name, code); } bool tt_timezone_get_name(char* buffer, size_t bufferSize) { - auto name = time::getTimeZoneName(); + auto name = settings::getTimeZoneName(); if (bufferSize < (name.length() + 1)) { return false; } else { @@ -22,7 +22,7 @@ bool tt_timezone_get_name(char* buffer, size_t bufferSize) { } bool tt_timezone_get_code(char* buffer, size_t bufferSize) { - auto code = time::getTimeZoneCode(); + auto code = settings::getTimeZoneCode(); if (bufferSize < (code.length() + 1)) { return false; } else { @@ -32,11 +32,11 @@ bool tt_timezone_get_code(char* buffer, size_t bufferSize) { } bool tt_timezone_is_format_24_hour() { - return time::isTimeFormat24Hour(); + return settings::isTimeFormat24Hour(); } void tt_timezone_set_format_24_hour(bool show24Hour) { - return time::setTimeFormat24Hour(show24Hour); + return settings::setTimeFormat24Hour(show24Hour); } } diff --git a/Translations/Translations.ods b/Translations/Translations.ods index 4728ca6d1f0657fa1a101f542f2ae556dcb8d8d1..3ce333cdb1c527796c70bc09d411fc7401decf78 100644 GIT binary patch delta 4285 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 S{~4W$pbRDX2;Iegq5lD4F2s}o delta 3897 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