diff --git a/internal_filesystem/builtin/apps/com.micropythonos.launcher/assets/launcher.py b/internal_filesystem/builtin/apps/com.micropythonos.launcher/assets/launcher.py index a8e9106f..952dfb9c 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.launcher/assets/launcher.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.launcher/assets/launcher.py @@ -10,7 +10,7 @@ # Most of this time is actually spent reading and parsing manifests. import lvgl as lv import mpos.apps -from mpos import NOTIFICATION_BAR_HEIGHT, PackageManager, Activity, DisplayMetrics +from mpos import AppearanceManager, PackageManager, Activity, DisplayMetrics import time import uhashlib import ubinascii @@ -28,9 +28,9 @@ class Launcher(Activity): main_screen = lv.obj() main_screen.set_style_border_width(0, lv.PART.MAIN) main_screen.set_style_radius(0, 0) - main_screen.set_pos(0, NOTIFICATION_BAR_HEIGHT) + main_screen.set_pos(0, AppearanceManager.NOTIFICATION_BAR_HEIGHT) main_screen.set_style_pad_hor(DisplayMetrics.pct_of_width(2), 0) - main_screen.set_style_pad_ver(NOTIFICATION_BAR_HEIGHT, 0) + main_screen.set_style_pad_ver(AppearanceManager.NOTIFICATION_BAR_HEIGHT, 0) main_screen.set_flex_flow(lv.FLEX_FLOW.ROW_WRAP) self.setContentView(main_screen) diff --git a/internal_filesystem/lib/mpos/__init__.py b/internal_filesystem/lib/mpos/__init__.py index 09511e1b..b2c1963b 100644 --- a/internal_filesystem/lib/mpos/__init__.py +++ b/internal_filesystem/lib/mpos/__init__.py @@ -32,10 +32,10 @@ from .ui.testing import ( # UI utility functions from .ui.display_metrics import DisplayMetrics +from .ui.appearance_manager import AppearanceManager from .ui.event import get_event_name, print_event from .ui.view import setContentView, back_screen -from .ui.theme import set_theme -from .ui.topmenu import open_bar, close_bar, open_drawer, drawer_open, NOTIFICATION_BAR_HEIGHT +from .ui.topmenu import open_bar, close_bar, open_drawer, drawer_open from .ui.focus import save_and_clear_current_focusgroup from .ui.gesture_navigation import handle_back_swipe, handle_top_swipe from .ui.util import shutdown, set_foreground_app, get_foreground_app @@ -69,12 +69,12 @@ __all__ = [ "SettingActivity", "SettingsActivity", "CameraActivity", # UI components "MposKeyboard", - # UI utility - DisplayMetrics + # UI utility - DisplayMetrics and AppearanceManager "DisplayMetrics", + "AppearanceManager", "get_event_name", "print_event", "setContentView", "back_screen", - "set_theme", - "open_bar", "close_bar", "open_drawer", "drawer_open", "NOTIFICATION_BAR_HEIGHT", + "open_bar", "close_bar", "open_drawer", "drawer_open", "save_and_clear_current_focusgroup", "handle_back_swipe", "handle_top_swipe", "shutdown", "set_foreground_app", "get_foreground_app", diff --git a/internal_filesystem/lib/mpos/main.py b/internal_filesystem/lib/mpos/main.py index a826e555..b01fb7e5 100644 --- a/internal_filesystem/lib/mpos/main.py +++ b/internal_filesystem/lib/mpos/main.py @@ -8,6 +8,7 @@ import mpos.ui from . import ui from .content.package_manager import PackageManager from mpos.ui.display import init_rootscreen +from mpos.ui.appearance_manager import AppearanceManager import mpos.ui.topmenu # Auto-detect and initialize hardware @@ -41,7 +42,7 @@ mpos.fs_driver.fs_register(fs_drv, 'M') prefs = mpos.config.SharedPreferences("com.micropythonos.settings") -mpos.ui.set_theme(prefs) +AppearanceManager.init(prefs) init_rootscreen() mpos.ui.topmenu.create_notification_bar() mpos.ui.topmenu.create_drawer(mpos.ui.display) diff --git a/internal_filesystem/lib/mpos/ui/__init__.py b/internal_filesystem/lib/mpos/ui/__init__.py index 5f844906..6f38a949 100644 --- a/internal_filesystem/lib/mpos/ui/__init__.py +++ b/internal_filesystem/lib/mpos/ui/__init__.py @@ -3,8 +3,8 @@ from .view import ( screen_stack, remove_and_stop_current_activity, remove_and_stop_all_activities ) from .gesture_navigation import handle_back_swipe, handle_top_swipe -from .theme import set_theme -from .topmenu import open_bar, close_bar, open_drawer, drawer_open, NOTIFICATION_BAR_HEIGHT +from .appearance_manager import AppearanceManager +from .topmenu import open_bar, close_bar, open_drawer, drawer_open from .focus import save_and_clear_current_focusgroup from .display_metrics import DisplayMetrics from .event import get_event_name, print_event @@ -20,8 +20,8 @@ main_display = None __all__ = [ "setContentView", "back_screen", "remove_and_stop_current_activity", "remove_and_stop_all_activities", "handle_back_swipe", "handle_top_swipe", - "set_theme", - "open_bar", "close_bar", "open_drawer", "drawer_open", "NOTIFICATION_BAR_HEIGHT", + "AppearanceManager", + "open_bar", "close_bar", "open_drawer", "drawer_open", "save_and_clear_current_focusgroup", "DisplayMetrics", "get_event_name", "print_event", diff --git a/internal_filesystem/lib/mpos/ui/appearance_manager.py b/internal_filesystem/lib/mpos/ui/appearance_manager.py new file mode 100644 index 00000000..93fbdf09 --- /dev/null +++ b/internal_filesystem/lib/mpos/ui/appearance_manager.py @@ -0,0 +1,285 @@ +# lib/mpos/ui/appearance_manager.py +""" +AppearanceManager - Android-inspired appearance management singleton. + +Manages all aspects of the app's visual appearance: +- Light/dark mode (UI appearance) +- Theme colors (primary, secondary, accent) +- UI dimensions (notification bar height, etc.) +- LVGL theme initialization +- Keyboard styling workarounds + +This is a singleton implemented using class methods and class variables. +No instance creation is needed - all methods are class methods. + +Example: + from mpos import AppearanceManager + + # Check light/dark mode + if AppearanceManager.is_light_mode(): + print("Light mode enabled") + + # Get UI dimensions + bar_height = AppearanceManager.get_notification_bar_height() + + # Initialize appearance from preferences + AppearanceManager.init(prefs) +""" + +import lvgl as lv + + +class AppearanceManager: + """ + Android-inspired appearance management singleton. + + Centralizes all UI appearance settings including theme colors, light/dark mode, + and UI dimensions. Follows the singleton pattern using class methods and class + variables, similar to Android's Configuration and Resources classes. + + All methods are class methods - no instance creation needed. + """ + + # ========== UI Dimensions ========== + # These are constants that define the layout of the UI + NOTIFICATION_BAR_HEIGHT = 24 # Height of the notification bar in pixels + + # ========== Private Class Variables ========== + # State variables shared across all "instances" (there is only one logical instance) + _is_light_mode = True + _primary_color = None + _accent_color = None + _keyboard_button_fix_style = None + + # ========== Initialization ========== + + @classmethod + def init(cls, prefs): + """ + Initialize AppearanceManager from preferences. + + Called during system startup to load theme settings from SharedPreferences + and initialize the LVGL theme. This should be called once during boot. + + Args: + prefs: SharedPreferences object containing theme settings + - "theme_light_dark": "light" or "dark" (default: "light") + - "theme_primary_color": hex color string like "0xFF5722" or "#FF5722" + + Example: + from mpos import AppearanceManager + import mpos.config + + prefs = mpos.config.get_shared_preferences() + AppearanceManager.init(prefs) + """ + # Load light/dark mode preference + theme_light_dark = prefs.get_string("theme_light_dark", "light") + theme_dark_bool = (theme_light_dark == "dark") + cls._is_light_mode = not theme_dark_bool + + # Load primary color preference + primary_color = lv.theme_get_color_primary(None) + color_string = prefs.get_string("theme_primary_color") + if color_string: + try: + color_string = color_string.replace("0x", "").replace("#", "").strip().lower() + color_int = int(color_string, 16) + print(f"[AppearanceManager] Setting primary color: {color_int}") + primary_color = lv.color_hex(color_int) + cls._primary_color = primary_color + except Exception as e: + print(f"[AppearanceManager] Converting color setting '{color_string}' failed: {e}") + + # Initialize LVGL theme with loaded settings + # Get the display driver from the active screen + screen = lv.screen_active() + disp = screen.get_display() + lv.theme_default_init( + disp, + primary_color, + lv.color_hex(0xFBDC05), # Accent color (yellow) + theme_dark_bool, + lv.font_montserrat_12 + ) + + # Reset keyboard button fix style so it's recreated with new theme colors + cls._keyboard_button_fix_style = None + + print(f"[AppearanceManager] Initialized: light_mode={cls._is_light_mode}, primary_color={primary_color}") + + # ========== Light/Dark Mode ========== + + @classmethod + def is_light_mode(cls): + """ + Check if light mode is currently enabled. + + Returns: + bool: True if light mode is enabled, False if dark mode is enabled + + Example: + from mpos import AppearanceManager + + if AppearanceManager.is_light_mode(): + print("Using light theme") + else: + print("Using dark theme") + """ + return cls._is_light_mode + + @classmethod + def set_light_mode(cls, is_light, prefs=None): + """ + Set light/dark mode and update the theme. + + Args: + is_light (bool): True for light mode, False for dark mode + prefs (SharedPreferences, optional): If provided, saves the setting + + Example: + from mpos import AppearanceManager + + AppearanceManager.set_light_mode(False) # Switch to dark mode + """ + cls._is_light_mode = is_light + + # Save to preferences if provided + if prefs: + theme_str = "light" if is_light else "dark" + prefs.set_string("theme_light_dark", theme_str) + + # Reinitialize LVGL theme with new mode + if prefs: + cls.init(prefs) + + print(f"[AppearanceManager] Light mode set to: {is_light}") + + # ========== Theme Colors ========== + + @classmethod + def get_primary_color(cls): + """ + Get the primary theme color. + + Returns: + lv.color_t: The primary color, or None if not set + + Example: + from mpos import AppearanceManager + + color = AppearanceManager.get_primary_color() + if color: + button.set_style_bg_color(color, 0) + """ + return cls._primary_color + + @classmethod + def set_primary_color(cls, color, prefs=None): + """ + Set the primary theme color. + + Args: + color (lv.color_t or int): The new primary color + prefs (SharedPreferences, optional): If provided, saves the setting + + Example: + from mpos import AppearanceManager + import lvgl as lv + + AppearanceManager.set_primary_color(lv.color_hex(0xFF5722)) + """ + cls._primary_color = color + + # Save to preferences if provided + if prefs and isinstance(color, int): + prefs.set_string("theme_primary_color", f"0x{color:06X}") + + print(f"[AppearanceManager] Primary color set to: {color}") + + # ========== UI Dimensions ========== + + @classmethod + def get_notification_bar_height(cls): + """ + Get the height of the notification bar. + + The notification bar is the top bar that displays system information + (time, battery, signal, etc.). This method returns its height in pixels. + + Returns: + int: Height of the notification bar in pixels (default: 24) + + Example: + from mpos import AppearanceManager + + bar_height = AppearanceManager.get_notification_bar_height() + content_y = bar_height # Position content below the bar + """ + return cls.NOTIFICATION_BAR_HEIGHT + + # ========== Keyboard Styling Workarounds ========== + + @classmethod + def get_keyboard_button_fix_style(cls): + """ + Get the keyboard button fix style for light mode. + + The LVGL default theme applies bg_color_white to keyboard buttons, + which makes them white-on-white (invisible) in light mode. + This method returns a custom style to override that. + + Returns: + lv.style_t: Style to apply to keyboard buttons, or None if not needed + + Note: + This is a workaround for an LVGL/MicroPython issue. It only applies + in light mode. In dark mode, the default LVGL styling is fine. + + Example: + from mpos import AppearanceManager + + style = AppearanceManager.get_keyboard_button_fix_style() + if style: + keyboard.add_style(style, lv.PART.ITEMS) + """ + # Only return style in light mode + if not cls._is_light_mode: + return None + + # Create style if it doesn't exist + if cls._keyboard_button_fix_style is None: + cls._keyboard_button_fix_style = lv.style_t() + cls._keyboard_button_fix_style.init() + + # Set button background to light gray (matches LVGL's intended design) + # This provides contrast against white background + # Using palette_lighten gives us the same gray as used in the theme + gray_color = lv.palette_lighten(lv.PALETTE.GREY, 2) + cls._keyboard_button_fix_style.set_bg_color(gray_color) + cls._keyboard_button_fix_style.set_bg_opa(lv.OPA.COVER) + + return cls._keyboard_button_fix_style + + @classmethod + def apply_keyboard_fix(cls, keyboard): + """ + Apply keyboard button visibility fix to a keyboard instance. + + Call this function after creating a keyboard to ensure buttons + are visible in light mode. + + Args: + keyboard: The lv.keyboard instance to fix + + Example: + from mpos import AppearanceManager + import lvgl as lv + + keyboard = lv.keyboard(screen) + AppearanceManager.apply_keyboard_fix(keyboard) + """ + style = cls.get_keyboard_button_fix_style() + if style: + keyboard.add_style(style, lv.PART.ITEMS) + print(f"[AppearanceManager] Applied keyboard button fix for light mode") diff --git a/internal_filesystem/lib/mpos/ui/gesture_navigation.py b/internal_filesystem/lib/mpos/ui/gesture_navigation.py index 61ad9455..968a956f 100644 --- a/internal_filesystem/lib/mpos/ui/gesture_navigation.py +++ b/internal_filesystem/lib/mpos/ui/gesture_navigation.py @@ -4,6 +4,7 @@ from .widget_animator import WidgetAnimator from .view import back_screen from mpos.ui import topmenu as topmenu from .display import DisplayMetrics +from .appearance_manager import AppearanceManager downbutton = None backbutton = None @@ -106,10 +107,10 @@ def _top_swipe_cb(event): def handle_back_swipe(): global backbutton rect = lv.obj(lv.layer_top()) - rect.set_size(topmenu.NOTIFICATION_BAR_HEIGHT, lv.layer_top().get_height()-topmenu.NOTIFICATION_BAR_HEIGHT) # narrow because it overlaps buttons + rect.set_size(AppearanceManager.NOTIFICATION_BAR_HEIGHT, lv.layer_top().get_height()-AppearanceManager.NOTIFICATION_BAR_HEIGHT) # narrow because it overlaps buttons rect.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) rect.set_scroll_dir(lv.DIR.NONE) - rect.set_pos(0, topmenu.NOTIFICATION_BAR_HEIGHT) + rect.set_pos(0, AppearanceManager.NOTIFICATION_BAR_HEIGHT) style = lv.style_t() style.init() style.set_bg_opa(lv.OPA.TRANSP) @@ -137,7 +138,7 @@ def handle_back_swipe(): def handle_top_swipe(): global downbutton rect = lv.obj(lv.layer_top()) - rect.set_size(lv.pct(100), topmenu.NOTIFICATION_BAR_HEIGHT) + rect.set_size(lv.pct(100), AppearanceManager.NOTIFICATION_BAR_HEIGHT) rect.set_pos(0, 0) rect.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) style = lv.style_t() diff --git a/internal_filesystem/lib/mpos/ui/topmenu.py b/internal_filesystem/lib/mpos/ui/topmenu.py index 17ddef99..27f35e40 100644 --- a/internal_filesystem/lib/mpos/ui/topmenu.py +++ b/internal_filesystem/lib/mpos/ui/topmenu.py @@ -3,12 +3,11 @@ import lvgl as lv import mpos.time import mpos.battery_voltage from .display_metrics import DisplayMetrics +from .appearance_manager import AppearanceManager from .util import (get_foreground_app) from . import focus_direction from .widget_animator import WidgetAnimator -NOTIFICATION_BAR_HEIGHT=24 - CLOCK_UPDATE_INTERVAL = 1000 # 10 or even 1 ms doesn't seem to change the framerate but 100ms is enough WIFI_ICON_UPDATE_INTERVAL = 1500 BATTERY_ICON_UPDATE_INTERVAL = 15000 # not too often, but not too short, otherwise it takes a while to appear @@ -20,7 +19,7 @@ DRAWER_ANIM_DURATION=300 hide_bar_animation = None show_bar_animation = None -show_bar_animation_start_value = -NOTIFICATION_BAR_HEIGHT +show_bar_animation_start_value = -AppearanceManager.NOTIFICATION_BAR_HEIGHT show_bar_animation_end_value = 0 hide_bar_animation_start_value = show_bar_animation_end_value hide_bar_animation_end_value = show_bar_animation_start_value @@ -80,7 +79,7 @@ def create_notification_bar(): global notification_bar # Create notification bar notification_bar = lv.obj(lv.layer_top()) - notification_bar.set_size(lv.pct(100), NOTIFICATION_BAR_HEIGHT) + notification_bar.set_size(lv.pct(100), AppearanceManager.NOTIFICATION_BAR_HEIGHT) notification_bar.set_pos(0, show_bar_animation_start_value) notification_bar.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) notification_bar.set_scroll_dir(lv.DIR.NONE) @@ -203,7 +202,7 @@ def create_notification_bar(): hide_bar_animation = lv.anim_t() hide_bar_animation.init() hide_bar_animation.set_var(notification_bar) - hide_bar_animation.set_values(0, -NOTIFICATION_BAR_HEIGHT) + hide_bar_animation.set_values(0, -AppearanceManager.NOTIFICATION_BAR_HEIGHT) hide_bar_animation.set_duration(2000) hide_bar_animation.set_custom_exec_cb(lambda not_used, value : notification_bar.set_y(value)) @@ -222,7 +221,7 @@ def create_drawer(display=None): global drawer drawer=lv.obj(lv.layer_top()) drawer.set_size(lv.pct(100),lv.pct(90)) - drawer.set_pos(0,NOTIFICATION_BAR_HEIGHT) + drawer.set_pos(0,AppearanceManager.NOTIFICATION_BAR_HEIGHT) drawer.set_scroll_dir(lv.DIR.VER) drawer.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) drawer.set_style_pad_all(15, 0) @@ -381,7 +380,7 @@ def drawer_scroll_callback(event): elif event_code == lv.EVENT.SCROLL and scroll_start_y != None: diff = y - scroll_start_y #print(f"scroll distance: {diff}") - if diff < -NOTIFICATION_BAR_HEIGHT: + if diff < -AppearanceManager.NOTIFICATION_BAR_HEIGHT: close_drawer() elif event_code == lv.EVENT.SCROLL_END: scroll_start_y = None