diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py index 9af397f4..84837dbe 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py @@ -1,6 +1,6 @@ import lvgl as lv -from mpos import Activity, Intent, AppearanceManager, AppManager, SettingActivity, SettingsActivity, TimeZone +from mpos import Activity, Intent, AppearanceManager, AppManager, NumberFormat, SettingActivity, SettingsActivity, TimeZone from bootloader import ResetIntoBootloader from calibrate_imu import CalibrateIMUActivity @@ -82,6 +82,7 @@ def getIntent(self): {"title": "Light/Dark Theme", "key": "theme_light_dark", "ui": "radiobuttons", "ui_options": [("Light", "light"), ("Dark", "dark")], "changed_callback": self.theme_changed}, {"title": "Theme Color", "key": "theme_primary_color", "placeholder": "HTML hex color, like: EC048C", "ui": "dropdown", "ui_options": theme_colors, "changed_callback": self.theme_changed, "default_value": AppearanceManager.DEFAULT_PRIMARY_COLOR}, {"title": "Timezone", "key": "timezone", "ui": "dropdown", "ui_options": [(tz, tz) for tz in TimeZone.get_timezones()], "changed_callback": lambda *args: TimeZone.refresh_timezone_preference()}, + {"title": "Number Format", "key": "number_format", "ui": "dropdown", "ui_options": NumberFormat.get_format_options(), "changed_callback": lambda *args: NumberFormat.refresh_preference(), "default_value": "comma_dot"}, # Advanced settings, alphabetically: {"title": "Auto Start App", "key": "auto_start_app", "ui": "radiobuttons", "ui_options": [(app.name, app.fullname) for app in AppManager.get_app_list()]}, {"title": "Check IMU Calibration", "key": "check_imu_calibration", "ui": "activity", "activity_class": CheckIMUCalibrationActivity}, diff --git a/internal_filesystem/lib/mpos/__init__.py b/internal_filesystem/lib/mpos/__init__.py index 82ce41c0..71a2193f 100644 --- a/internal_filesystem/lib/mpos/__init__.py +++ b/internal_filesystem/lib/mpos/__init__.py @@ -14,12 +14,16 @@ from .camera_manager import CameraManager from .sensor_manager import SensorManager from .time_zone import TimeZone +from .number_format import NumberFormat from .device_info import DeviceInfo from .build_info import BuildInfo # Battery manager (imported early for UI dependencies) from .battery_manager import BatteryManager -from .webserver.webserver import WebServer +try: + from .webserver.webserver import WebServer +except ImportError: + WebServer = None # _webrepl not available on desktop/Unix builds # Common activities from .app.activities.chooser import ChooserActivity diff --git a/internal_filesystem/lib/mpos/number_format.py b/internal_filesystem/lib/mpos/number_format.py new file mode 100644 index 00000000..a4829a5b --- /dev/null +++ b/internal_filesystem/lib/mpos/number_format.py @@ -0,0 +1,111 @@ +from . import config + + +NUMBER_FORMAT_MAP = { + "comma_dot": (".", ","), # 1,234.56 US/UK + "dot_comma": (",", "."), # 1.234,56 Europe + "space_comma": (",", " "), # 1 234,56 French + "apos_dot": (".", "'"), # 1'234.56 Swiss + "under_dot": (".", "_"), # 1_234.56 Tech + "none_dot": (".", ""), # 1234.56 No thousands + "none_comma": (",", ""), # 1234,56 No thousands +} + +DEFAULT_FORMAT = "comma_dot" + + +class NumberFormat: + """Number formatting utility using the system number format preference.""" + + number_format_preference = None + + @staticmethod + def refresh_preference(): + """Refresh the number format preference from SharedPreferences.""" + NumberFormat.number_format_preference = config.SharedPreferences( + "com.micropythonos.settings" + ).get_string("number_format") + if not NumberFormat.number_format_preference: + NumberFormat.number_format_preference = DEFAULT_FORMAT + + @staticmethod + def get_separators(): + """Return (decimal_sep, thousands_sep) for the current preference.""" + if NumberFormat.number_format_preference is None: + NumberFormat.refresh_preference() + return NUMBER_FORMAT_MAP.get( + NumberFormat.number_format_preference, + NUMBER_FORMAT_MAP[DEFAULT_FORMAT], + ) + + @staticmethod + def format_number(value, decimals=None): + """Format a number using the current number format preference. + + Args: + value: int or float to format. + decimals: number of decimal places (None = auto for ints, strip trailing zeros for floats). + + Returns: + Formatted string. + """ + dec_sep, thou_sep = NumberFormat.get_separators() + + if isinstance(value, int) and decimals is None: + negative = value < 0 + s = str(abs(value)) + s = _insert_thousands(s, thou_sep) + return ("-" + s) if negative else s + + # Float formatting + if decimals is None: + decimals = 2 + s = "{:.{}f}".format(float(value), decimals) + + negative = s.startswith("-") + if negative: + s = s[1:] + + # Split on the Python decimal point + if "." in s: + int_part, frac_part = s.split(".") + # Strip trailing zeros from fractional part + frac_part = frac_part.rstrip("0") + else: + int_part = s + frac_part = "" + + int_part = _insert_thousands(int_part, thou_sep) + + if frac_part: + result = int_part + dec_sep + frac_part + else: + result = int_part + + return ("-" + result) if negative else result + + @staticmethod + def get_format_options(): + """Return a list of (label, key) tuples for the settings dropdown.""" + return [ + ("1,234.56 (US/UK)", "comma_dot"), + ("1.234,56 (Europe)", "dot_comma"), + ("1 234,56 (French)", "space_comma"), + ("1'234.56 (Swiss)", "apos_dot"), + ("1_234.56 (Tech)", "under_dot"), + ("1234.56 (No separator)", "none_dot"), + ("1234,56 (No separator)", "none_comma"), + ] + + +def _insert_thousands(int_str, separator): + """Insert thousands separator into an integer string.""" + if not separator or len(int_str) <= 3: + return int_str + parts = [] + while len(int_str) > 3: + parts.append(int_str[-3:]) + int_str = int_str[:-3] + parts.append(int_str) + parts.reverse() + return separator.join(parts) diff --git a/tests/test_number_format.py b/tests/test_number_format.py new file mode 100644 index 00000000..f557e371 --- /dev/null +++ b/tests/test_number_format.py @@ -0,0 +1,82 @@ +import unittest +from mpos.number_format import NumberFormat, NUMBER_FORMAT_MAP, DEFAULT_FORMAT + + +class TestNumberFormat(unittest.TestCase): + + def setUp(self): + NumberFormat.number_format_preference = DEFAULT_FORMAT + + def test_default_is_us_format(self): + self.assertEqual(NumberFormat.get_separators(), (".", ",")) + + def test_format_int_default(self): + self.assertEqual(NumberFormat.format_number(1234), "1,234") + self.assertEqual(NumberFormat.format_number(0), "0") + self.assertEqual(NumberFormat.format_number(999), "999") + self.assertEqual(NumberFormat.format_number(1000), "1,000") + self.assertEqual(NumberFormat.format_number(1234567), "1,234,567") + + def test_format_negative_int(self): + self.assertEqual(NumberFormat.format_number(-1234), "-1,234") + self.assertEqual(NumberFormat.format_number(-5), "-5") + + def test_format_float_default(self): + self.assertEqual(NumberFormat.format_number(1234.56, 2), "1,234.56") + self.assertEqual(NumberFormat.format_number(1234.50, 2), "1,234.5") + self.assertEqual(NumberFormat.format_number(1234.00, 2), "1,234") + self.assertEqual(NumberFormat.format_number(0.5, 2), "0.5") + + def test_format_europe(self): + NumberFormat.number_format_preference = "dot_comma" + self.assertEqual(NumberFormat.format_number(1234), "1.234") + self.assertEqual(NumberFormat.format_number(1234.56, 2), "1.234,56") + + def test_format_french(self): + NumberFormat.number_format_preference = "space_comma" + self.assertEqual(NumberFormat.format_number(1234), "1 234") + self.assertEqual(NumberFormat.format_number(1234.56, 2), "1 234,56") + + def test_format_swiss(self): + NumberFormat.number_format_preference = "apos_dot" + self.assertEqual(NumberFormat.format_number(1234), "1'234") + self.assertEqual(NumberFormat.format_number(1234.56, 2), "1'234.56") + + def test_format_tech(self): + NumberFormat.number_format_preference = "under_dot" + self.assertEqual(NumberFormat.format_number(1234), "1_234") + self.assertEqual(NumberFormat.format_number(1234.56, 2), "1_234.56") + + def test_format_no_thousands_dot(self): + NumberFormat.number_format_preference = "none_dot" + self.assertEqual(NumberFormat.format_number(1234567), "1234567") + self.assertEqual(NumberFormat.format_number(1234.56, 2), "1234.56") + + def test_format_no_thousands_comma(self): + NumberFormat.number_format_preference = "none_comma" + self.assertEqual(NumberFormat.format_number(1234567), "1234567") + self.assertEqual(NumberFormat.format_number(1234.56, 2), "1234,56") + + def test_strip_trailing_zeros(self): + NumberFormat.number_format_preference = DEFAULT_FORMAT + self.assertEqual(NumberFormat.format_number(1.10, 2), "1.1") + self.assertEqual(NumberFormat.format_number(1.00, 2), "1") + self.assertEqual(NumberFormat.format_number(1.00, 8), "1") + + def test_large_number(self): + self.assertEqual(NumberFormat.format_number(100000000), "100,000,000") + + def test_get_format_options_returns_list(self): + options = NumberFormat.get_format_options() + self.assertIsInstance(options, list) + self.assertTrue(len(options) == len(NUMBER_FORMAT_MAP)) + for label, key in options: + self.assertIn(key, NUMBER_FORMAT_MAP) + + def test_unknown_preference_falls_back_to_default(self): + NumberFormat.number_format_preference = "nonexistent" + self.assertEqual(NumberFormat.get_separators(), (".", ",")) + + +if __name__ == "__main__": + unittest.main()