components_settings_settings.bs

import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/apiPool.bs"
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/config.bs"
import "pkg:/source/utils/deviceCapabilities.bs"
import "pkg:/source/utils/globals.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/translate.bs"
import "pkg:/source/utils/translateLocale.bs"

sub init()
  m.log = new log.Logger("Settings")
  m.top.overhangTitle = translate(translationKeys.LabelSettings)
  m.top.isOptionsAvailable = false

  m.userLocation = []
  m.themeColorsChanged = false
  m.languageChanged = false
  m.customColorBackup = invalid

  ' Load available languages for the language picker
  languagesJson = ReadAsciiFile("pkg:/locale/languages.json")
  m.availableLanguages = ParseJson(languagesJson)
  if not isValid(m.availableLanguages) then m.availableLanguages = []
  ' Normalize language entries so downstream code can safely access all fields
  for each lang in m.availableLanguages
    if not isValid(lang.name) then lang.name = ""
    if not isValid(lang.nativeName) then lang.nativeName = ""
    if not isValid(lang.code) then lang.code = ""
  end for

  versionLabel = m.top.findNode("versionLabel")
  versionLabel.text = "v" + m.global.app.version
  m.settingsMenu = m.top.findNode("settingsMenu")
  m.settingDetail = m.top.findNode("settingDetail")
  m.settingDesc = m.top.findNode("settingDesc")
  m.path = m.top.findNode("path")
  m.top.findNode("rightPanelBackground").blendColor = m.global.constants.colorBackgroundSecondary

  m.boolSetting = m.top.findNode("boolSetting")
  m.integerSetting = m.top.findNode("integerSetting")
  m.radioSetting = m.top.findNode("radioSetting")
  m.hexSetting = m.top.findNode("hexSetting")
  m.alphaSetting = m.top.findNode("alphaSetting")
  m.languagePicker = m.top.findNode("languagePicker")

  m.integerSetting.observeField("submit", "onKeyGridSubmit")
  m.integerSetting.observeField("escape", "onKeyGridEscape")

  m.hexSetting.observeField("submit", "onHexKeyGridSubmit")
  m.hexSetting.observeField("escape", "onHexKeyGridEscape")
  m.hexSetting.observeField("reset", "onHexResetRequested")

  m.alphaSetting.observeField("submit", "onAlphaKeyGridSubmit")
  m.alphaSetting.observeField("escape", "onAlphaKeyGridEscape")
  m.alphaSetting.observeField("reset", "onAlphaResetRequested")

  m.languagePicker.observeField("selectedCode", "onLanguagePickerSelected")
  m.languagePicker.languages = m.availableLanguages
  m.suppressLanguagePicker = false

  m.settingsMenu.setFocus(true)
  m.settingsMenu.observeField("itemFocused", "settingFocused")
  m.settingsMenu.observeField("itemSelected", "settingSelected")

  m.boolSetting.observeField("checkedItem", "boolSettingChanged")
  m.radioSetting.observeField("checkedItem", "radioSettingChanged")

  ' Load Configuration Tree
  m.configTree = GetConfigTree()
  LoadMenu({ children: m.configTree })
end sub

sub onKeyGridSubmit()
  selectedSetting = m.userLocation.peek().filteredChildren[m.settingsMenu.itemFocused]
  newValue = m.integerSetting.text

  ' Update node field - observer handles registry persistence
  user.settings.Save(selectedSetting.settingName, newValue)

  m.settingsMenu.setFocus(true)
end sub

sub onKeyGridEscape()
  if m.integerSetting.escape = "left" or m.integerSetting.escape = "back"
    m.settingsMenu.setFocus(true)
  end if
end sub

sub onHexKeyGridSubmit()
  selectedSetting = m.userLocation.peek().filteredChildren[m.settingsMenu.itemFocused]
  newValue = UCase(m.hexSetting.text)

  ' Validate hex color input
  if not isValidHexColor(newValue)
    m.global.sceneManager.callFunc("userMessage", translate(translationKeys.ErrorInvalidColor), translate(translationKeys.LabelPleaseEnterExactly6HexCharacters09A))
    return
  end if

  ' Track if theme color changed (requires UI refresh on exit)
  if selectedSetting.settingName.left(12) = "uiThemeColor"
    m.themeColorsChanged = true
  end if

  ' Update node field - observer handles registry persistence
  user.settings.Save(selectedSetting.settingName, newValue)

  m.settingsMenu.setFocus(true)
end sub

sub onHexKeyGridEscape()
  if m.hexSetting.escape = "left" or m.hexSetting.escape = "back"
    m.settingsMenu.setFocus(true)
  end if
end sub

' Called when reset button is selected in HexKeyboard
sub onHexResetRequested()
  m.global.sceneManager.callFunc("showConfirmationDialog", translate(translationKeys.LabelResetSetting), translate(translationKeys.MessageAreYouSureThisWillReset), [translate(translationKeys.ButtonCancel), translate(translationKeys.ButtonReset)])
  m.global.sceneManager.observeFieldScoped("isDataReturned", "onResetDialogResponse")
end sub

' Handle user response from reset confirmation dialog
sub onResetDialogResponse()
  m.global.sceneManager.unobserveFieldScoped("isDataReturned")

  returnData = m.global.sceneManager.returnData
  if not isValid(returnData) then return

  ' User selected "Cancel" or closed dialog
  if returnData.indexSelected <> 1
    m.hexSetting.findNode("resetButton").setFocus(true)
    return
  end if

  ' User confirmed reset - get default value from config tree
  selectedSetting = m.userLocation.peek().filteredChildren[m.settingsMenu.itemFocused]
  configEntry = findConfigTreeKey(selectedSetting.settingName, m.configTree)

  if not isValid(configEntry) or not isValid(configEntry.default)
    m.log.warn("Could not find default value for setting", selectedSetting.settingName)
    m.hexSetting.findNode("resetButton").setFocus(true)
    return
  end if

  defaultValue = configEntry.default

  ' Track if theme color changed (requires UI refresh on exit)
  if selectedSetting.settingName.left(12) = "uiThemeColor"
    m.themeColorsChanged = true
  end if

  ' Update node field - observer handles registry persistence
  user.settings.Save(selectedSetting.settingName, defaultValue)

  ' Update the UI
  m.hexSetting.text = defaultValue

  m.hexSetting.findNode("resetButton").setFocus(true)
end sub

sub onAlphaKeyGridSubmit()
  selectedSetting = m.userLocation.peek().filteredChildren[m.settingsMenu.itemFocused]
  newValue = LCase(m.alphaSetting.text)

  ' Validate alpha input is exactly 3 letters (a-z only)
  isValidCode = Len(newValue) = 3
  if isValidCode
    for i = 0 to 2
      charCode = Asc(Mid(newValue, i + 1, 1))
      if charCode < 97 or charCode > 122
        isValidCode = false
        exit for
      end if
    end for
  end if

  if not isValidCode
    m.global.sceneManager.callFunc("userMessage", translate(translationKeys.ErrorInvalidLanguageCode), translate(translationKeys.MessagePleaseEnterExactly3LettersEG))
    return
  end if

  ' Update node field - observer handles registry persistence
  user.settings.Save(selectedSetting.settingName, newValue)

  m.settingsMenu.setFocus(true)
end sub

sub onAlphaKeyGridEscape()
  if m.alphaSetting.escape = "left" or m.alphaSetting.escape = "back"
    m.settingsMenu.setFocus(true)
  end if
end sub

' Called when reset button is selected in AlphaKeyboard
sub onAlphaResetRequested()
  m.global.sceneManager.callFunc("showConfirmationDialog", translate(translationKeys.LabelResetSetting), translate(translationKeys.MessageAreYouSureThisWillReset), [translate(translationKeys.ButtonCancel), translate(translationKeys.ButtonReset)])
  m.global.sceneManager.observeFieldScoped("isDataReturned", "onAlphaResetDialogResponse")
end sub

' Handle user response from alpha reset confirmation dialog
sub onAlphaResetDialogResponse()
  m.global.sceneManager.unobserveFieldScoped("isDataReturned")

  returnData = m.global.sceneManager.returnData
  if not isValid(returnData) then return

  ' User selected "Cancel" or closed dialog
  if returnData.indexSelected <> 1
    m.alphaSetting.findNode("resetButton").setFocus(true)
    return
  end if

  ' User confirmed reset - get default value from config tree
  selectedSetting = m.userLocation.peek().filteredChildren[m.settingsMenu.itemFocused]
  configEntry = findConfigTreeKey(selectedSetting.settingName, m.configTree)

  if not isValid(configEntry) or not isValid(configEntry.default)
    m.log.warn("Could not find default value for setting", selectedSetting.settingName)
    m.alphaSetting.findNode("resetButton").setFocus(true)
    return
  end if

  defaultValue = configEntry.default

  ' Update node field - observer handles registry persistence
  user.settings.Save(selectedSetting.settingName, defaultValue)

  ' Update the UI
  m.alphaSetting.text = defaultValue

  m.alphaSetting.findNode("resetButton").setFocus(true)
end sub

sub LoadMenu(configSection)
  if not isValid(configSection.children)
    ' Load parent menu
    m.userLocation.pop()
    configSection = m.userLocation.peek()
  else
    if m.userLocation.Count() > 0 then m.userLocation.peek().selectedIndex = m.settingsMenu.itemFocused
    m.userLocation.push(configSection)
  end if

  result = CreateObject("roSGNode", "ContentNode")

  ' Build filtered children list (respecting visibleWhen conditions)
  filteredChildren = []
  for each item in configSection.children
    if isValid(item.visibleWhen) and not isSettingVisible(item.visibleWhen)
      continue for
    end if
    filteredChildren.push(item)
  end for
  configSection.filteredChildren = filteredChildren

  for each item in filteredChildren
    listItem = result.CreateChild("ContentNode")
    listItem.title = translate(item.titleKey)
    listItem.Description = translate(item.descriptionKey)
    listItem.id = item.id
  end for

  ' Inject "Reset User Settings" action at root level only
  if m.userLocation.Count() = 1
    resetItem = {
      title: "Reset User Settings",
      description: "Reset all settings to their default values. Your login session will not be affected.",
      titleKey: translationKeys.LabelResetUserSettings,
      descriptionKey: translationKeys.SettingResetAllSettingsToTheirDefault,
      type: "action",
      actionId: "resetUserSettings"
    }
    filteredChildren.push(resetItem)
    listItem = result.CreateChild("ContentNode")
    listItem.title = translate(resetItem.titleKey)
    listItem.Description = translate(resetItem.descriptionKey)
    listItem.id = "resetUserSettings"
  end if

  m.settingsMenu.content = result

  if isValid(configSection.selectedIndex) and configSection.selectedIndex > -1
    m.settingsMenu.jumpToItem = configSection.selectedIndex
  end if

  ' Set Path display
  m.path.text = ""
  for each level in m.userLocation
    if isValid(level.title)
      if m.path.text = ""
        m.path.text = translate(level.titleKey)
      else
        m.path.text += " / " + translate(level.titleKey)
      end if
    end if
  end for
end sub

sub settingFocused()

  selectedSetting = m.userLocation.peek().filteredChildren[m.settingsMenu.itemFocused]
  m.settingDesc.text = translate(selectedSetting.descriptionKey)

  ' Hide Settings
  m.boolSetting.visible = false
  m.integerSetting.visible = false
  m.radioSetting.visible = false
  m.hexSetting.visible = false
  m.alphaSetting.visible = false
  m.languagePicker.visible = false

  userSettings = m.global.user.settings

  if not isValid(selectedSetting.type)
    return
  else if selectedSetting.type = "bool"

    m.boolSetting.visible = true

    if userSettings[selectedSetting.settingName] = true
      m.boolSetting.checkedItem = 1
    else
      m.boolSetting.checkedItem = 0
    end if
  else if selectedSetting.type = "integer"
    integerValue = userSettings[selectedSetting.settingName].ToStr()
    if isValid(integerValue)
      m.integerSetting.text = integerValue
    end if
    m.integerSetting.visible = true
  else if LCase(selectedSetting.type) = "radio"

    selectedValue = userSettings[selectedSetting.settingName]

    radioContent = CreateObject("roSGNode", "ContentNode")

    m.radioSetting.checkedItem = 0

    itemIndex = 0
    for each item in m.userLocation.peek().filteredChildren[m.settingsMenu.itemFocused].options
      listItem = radioContent.CreateChild("ContentNode")
      listItem.title = translate(item.titleKey)
      listItem.id = item.id
      if selectedValue = item.id
        m.radioSetting.checkedItem = itemIndex
      end if
      itemIndex++
    end for

    m.radioSetting.content = radioContent
    m.radioSetting.jumpToItem = m.radioSetting.checkedItem

    m.radioSetting.visible = true
  else if selectedSetting.type = "text"
    ' Text input type (used for hex color codes)
    textValue = userSettings[selectedSetting.settingName]
    if isValid(textValue)
      m.hexSetting.text = textValue
    else
      m.hexSetting.text = ""
    end if
    m.hexSetting.visible = true
  else if selectedSetting.type = "alpha"
    ' Alpha text input type (used for language codes)
    textValue = userSettings[selectedSetting.settingName]
    if isValid(textValue)
      m.alphaSetting.text = textValue
    else
      m.alphaSetting.text = ""
    end if
    m.alphaSetting.visible = true
  else if selectedSetting.type = "languagePicker"
    selectedValue = userSettings[selectedSetting.settingName]
    if not isValid(selectedValue) then selectedValue = ""
    m.suppressLanguagePicker = true
    m.languagePicker.selectedCode = selectedValue
    m.suppressLanguagePicker = false
    m.languagePicker.visible = true
  else if selectedSetting.type = "action"
    ' Action items show description only, no setting controls
  else
    m.log.warn("Unknown setting type", selectedSetting.type)
  end if

end sub


sub settingSelected()

  selectedItem = m.userLocation.peek().filteredChildren[m.settingsMenu.itemFocused]

  ' Handle action items (e.g., Reset User Settings)
  if isValid(selectedItem.actionId) and selectedItem.actionId = "resetUserSettings"
    confirmResetUserSettings()
    return
  end if

  if isValid(selectedItem.type) ' Show setting
    if selectedItem.type = "bool"
      m.boolSetting.setFocus(true)
    else if selectedItem.type = "integer"
      m.integerSetting.setFocus(true)
    else if selectedItem.type = "radio"
      m.radioSetting.setFocus(true)
    else if selectedItem.type = "text"
      m.hexSetting.setFocus(true)
    else if selectedItem.type = "alpha"
      m.alphaSetting.setFocus(true)
    else if selectedItem.type = "languagePicker"
      m.languagePicker.findNode("keyboard").setFocus(true)
    end if
  else if isValid(selectedItem.children) and selectedItem.children.Count() > 0 ' Show sub menu
    LoadMenu(selectedItem)
    m.settingsMenu.setFocus(true)
  else
    return
  end if

  m.settingDesc.text = m.settingsMenu.content.GetChild(m.settingsMenu.itemFocused).Description

end sub


sub boolSettingChanged()
  if not isValid(m.boolSetting.focusedChild) then return
  selectedSetting = m.userLocation.peek().filteredChildren[m.settingsMenu.itemFocused]

  if m.boolSetting.checkedItem
    ' Update node field - observer handles registry persistence
    user.settings.Save(selectedSetting.settingName, "true")

    ' Special handling for globalRememberMe - set active_user in global registry
    if selectedSetting.settingName = "globalRememberMe"
      setSetting("active_user", m.global.user.id)
    end if
  else
    ' Update node field - observer handles registry persistence
    user.settings.Save(selectedSetting.settingName, "false")

    ' Special handling for globalRememberMe - remove active_user from global registry
    if selectedSetting.settingName = "globalRememberMe"
      unsetSetting("active_user")
    end if
  end if
end sub

sub radioSettingChanged()
  if not isValid(m.radioSetting.focusedChild) then return
  selectedSetting = m.userLocation.peek().filteredChildren[m.settingsMenu.itemFocused]

  ' Language picker is handled by onLanguagePickerSelected(), not the radio control
  if selectedSetting.type = "languagePicker" then return

  selectedOption = selectedSetting.options[m.radioSetting.checkedItem]
  newValue = selectedOption.id

  ' Backup custom colors before applying a preset (so user can switch back)
  if isValid(selectedOption.presetValues)
    currentTheme = m.global.user.settings.uiTheme
    if currentTheme = "custom" or currentTheme = ""
      backupCustomColors()
    end if
  else if newValue = "custom" and isValid(m.customColorBackup)
    ' Switching to Custom - restore backed up colors
    restoreCustomColors()
  end if

  ' Update node field - observer handles registry persistence
  user.settings.Save(selectedSetting.settingName, newValue)

  ' Apply preset values if the selected option has them
  if isValid(selectedOption.presetValues)
    for each presetKey in selectedOption.presetValues
      user.settings.Save(presetKey, selectedOption.presetValues[presetKey])
    end for
    m.themeColorsChanged = true
  end if

  ' Refresh menu if sibling items have visibleWhen referencing this setting
  if hasDependentVisibility(selectedSetting.settingName)
    refreshCurrentMenu()
  end if
end sub

' Evaluate a visibleWhen condition against current user settings
' @param {object} condition - { settingName: string, value: string }
' @return {boolean} - true if the setting matches the required value
function isSettingVisible(condition as object) as boolean
  if not isValid(condition) or not isValid(condition.settingName) or not isValid(condition.value)
    return true
  end if
  currentValue = m.global.user.settings[condition.settingName]
  return currentValue = condition.value
end function

' Check if any sibling items in the current menu depend on a setting for visibility
function hasDependentVisibility(settingName as string) as boolean
  currentSection = m.userLocation.peek()
  for each item in currentSection.children
    if isValid(item.visibleWhen) and item.visibleWhen.settingName = settingName
      return true
    end if
  end for
  return false
end function

' Refresh the current menu level in-place (re-filter visibleWhen and re-render)
' Rebuilds filtered children and content without touching the navigation stack
sub refreshCurrentMenu()
  currentSection = m.userLocation.peek()
  savedIndex = m.settingsMenu.itemFocused

  ' Rebuild filtered children list
  filteredChildren = []
  for each item in currentSection.children
    if isValid(item.visibleWhen) and not isSettingVisible(item.visibleWhen)
      continue for
    end if
    filteredChildren.push(item)
  end for
  currentSection.filteredChildren = filteredChildren

  ' Rebuild content nodes
  result = CreateObject("roSGNode", "ContentNode")
  for each item in filteredChildren
    listItem = result.CreateChild("ContentNode")
    listItem.title = translate(item.titleKey)
    listItem.Description = translate(item.descriptionKey)
    listItem.id = item.id
  end for

  ' Re-inject reset button at root level
  if m.userLocation.Count() = 1
    resetItem = {
      title: "Reset User Settings",
      description: "Reset all settings to their default values. Your login session will not be affected.",
      titleKey: translationKeys.LabelResetUserSettings,
      descriptionKey: translationKeys.SettingResetAllSettingsToTheirDefault,
      type: "action",
      actionId: "resetUserSettings"
    }
    filteredChildren.push(resetItem)
    listItem = result.CreateChild("ContentNode")
    listItem.title = translate(resetItem.titleKey)
    listItem.Description = translate(resetItem.descriptionKey)
    listItem.id = "resetUserSettings"
  end if

  m.settingsMenu.content = result

  ' Restore focus, clamping to new list bounds
  if savedIndex >= filteredChildren.Count()
    savedIndex = filteredChildren.Count() - 1
  end if
  if savedIndex >= 0
    m.settingsMenu.jumpToItem = savedIndex
  end if
end sub

' Backup current custom color values before applying a preset
sub backupCustomColors()
  userSettings = m.global.user.settings
  m.customColorBackup = {
    uiThemeColorPrimary: userSettings.uiThemeColorPrimary,
    uiThemeColorSecondary: userSettings.uiThemeColorSecondary,
    uiThemeColorBackgroundPrimary: userSettings.uiThemeColorBackgroundPrimary,
    uiThemeColorBackgroundSecondary: userSettings.uiThemeColorBackgroundSecondary,
    uiThemeColorTextPrimary: userSettings.uiThemeColorTextPrimary,
    uiThemeColorTextSecondary: userSettings.uiThemeColorTextSecondary,
    uiThemeColorTextDisabled: userSettings.uiThemeColorTextDisabled
  }
end sub

' Restore custom color values from backup
sub restoreCustomColors()
  if not isValid(m.customColorBackup) then return
  for each key in m.customColorBackup
    user.settings.Save(key, m.customColorBackup[key])
  end for
  m.themeColorsChanged = true
end sub

' Check if the current color values differ from the backed-up custom colors
' @return {boolean} - true if any color has changed from the backup
function hasCustomColorsChanged() as boolean
  if not isValid(m.customColorBackup) then return false
  userSettings = m.global.user.settings
  for each key in m.customColorBackup
    if userSettings[key] <> m.customColorBackup[key]
      return true
    end if
  end for
  return false
end function

' Show confirmation dialog before resetting all user settings
sub confirmResetUserSettings()
  m.global.sceneManager.callFunc("showConfirmationDialog", translate(translationKeys.LabelResetUserSettings), translate(translationKeys.MessageAreYouSureYouWantTo2), [translate(translationKeys.ButtonCancel), translate(translationKeys.ButtonReset)])
  m.global.sceneManager.observeFieldScoped("isDataReturned", "onResetUserSettingsResponse")
end sub

' Handle user response from reset user settings confirmation dialog
sub onResetUserSettingsResponse()
  m.global.sceneManager.unobserveFieldScoped("isDataReturned")

  returnData = m.global.sceneManager.returnData
  if not isValid(returnData) or returnData.indexSelected <> 1
    ' User selected "Cancel" or closed dialog
    m.settingsMenu.setFocus(true)
    return
  end if

  executeResetUserSettings()
end sub

' Reset all user settings to defaults from settings.json
' Preserves session keys (authToken, username, serverId, primaryImageTag, LastRunVersion)
sub executeResetUserSettings()
  localUser = m.global.user
  userId = localUser.id
  if not isValid(userId) or userId = ""
    m.log.warn("Cannot reset settings - user ID is invalid")
    m.settingsMenu.setFocus(true)
    return
  end if

  m.log.info("Resetting all user settings to defaults")

  ' Disable auto-sync to prevent observers from firing during cleanup
  localUser.settings.callFunc("disableAutoSync")

  ' Delete all non-session keys from user registry
  sessionKeys = ["authToken", "username", "serverId", "primaryImageTag", "LastRunVersion"]
  allRegistryData = RegistryReadAll(userId)
  keysToDelete = getSettingKeysToDelete(allRegistryData, sessionKeys)

  for each key in keysToDelete
    registryDelete(key, userId)
  end for

  ' Reload all defaults from settings.json onto the settings node
  user.settings.SaveDefaults()

  ' globalRememberMe was reset to false — clear auto-login state
  unsetSetting("active_user")

  ' Clear per-library display settings
  localUser.settings.displaySettings = {}

  ' Re-enable auto-sync for future changes
  localUser.settings.callFunc("enableAutoSync")

  ' Theme colors were reset to defaults - flag for refresh on exit
  m.themeColorsChanged = true

  m.log.info("User settings reset complete", { deletedKeys: keysToDelete.Count() })

  ' Show success feedback (toast lives on JRScene, persists across scene transitions)
  m.top.getScene().callFunc("showToast", translate(translationKeys.LabelSettingsResetToDefaults), "success")

  ' Exit settings and reload home with fresh theme colors
  performSettingsExit()
end sub

' JRScreen hook that gets ran when the screen is shown.
sub onScreenShown()
  ' Clear backdrop on settings screens
  m.global.sceneManager.callFunc("setBackgroundImage", "")

  overhang = m.top.getScene().findNode("overhang")
  if not isValid(overhang) then return

  overhang.isLogoVisible = true
  overhang.currentUser = m.global.user.name

  ' Ensure settings menu has focus every time this screen is shown.
  ' init() calls setFocus before the node is in the scene graph (silently fails),
  ' and SceneManager's fallback focuses the Group, not settingsMenu — breaking
  ' all BACK key handling since onKeyEvent checks settingsMenu.focusedChild.
  if isValid(m.settingsMenu)
    m.settingsMenu.setFocus(true)
  end if
end sub

' JRScreen hook that gets ran as needed.
' Assumes settings were changed and they affect the device profile.
' Posts a new device profile to the server via fire-and-forget side effect.
sub onScreenHidden()
  SubmitSideEffect(GetApi().BuildPostSessionCapabilitiesRequest(getDeviceCapabilities()))
end sub

' Returns true if any of the data entry forms are in focus
function isFormInFocus() as boolean
  if isValid(m.settingDetail.focusedChild) or m.radioSetting.hasFocus() or m.boolSetting.hasFocus() or m.integerSetting.hasFocus() or m.hexSetting.hasFocus()
    return true
  end if
  return false
end function

' Exit settings screen, refreshing theme colors only if they changed
' Shows confirmation dialog if custom colors would be permanently lost
sub exitSettingsAndReloadHome()
  ' Check if custom colors will be lost (user switched from Custom to a preset)
  if isValid(m.customColorBackup) and m.global.user.settings.uiTheme <> "custom" and hasCustomColorsChanged()
    m.global.sceneManager.callFunc("showConfirmationDialog", translate(translationKeys.LabelReplaceCustomColors), translate(translationKeys.MessageYourCustomColorsWillBeReplaced), [translate(translationKeys.LabelYes), translate(translationKeys.LabelNo)])
    m.global.sceneManager.observeFieldScoped("isDataReturned", "onExitConfirmResponse")
    return
  end if

  performSettingsExit()
end sub

' Handle user response from exit confirmation dialog
sub onExitConfirmResponse()
  m.global.sceneManager.unobserveFieldScoped("isDataReturned")

  returnData = m.global.sceneManager.returnData
  if not isValid(returnData) or returnData.indexSelected <> 0
    ' User selected "No" or closed dialog - restore custom colors and stay
    restoreCustomColors()
    user.settings.Save("uiTheme", "custom")
    refreshCurrentMenu()
    m.settingsMenu.setFocus(true)
    return
  end if

  ' User confirmed - clear backup and exit
  m.customColorBackup = invalid
  performSettingsExit()
end sub

' Handle language selection from LanguagePicker component
sub onLanguagePickerSelected()
  ' Guard against being triggered during init or programmatic selectedCode changes
  if m.suppressLanguagePicker then return
  if not isValid(m.userLocation) or m.userLocation.count() = 0 then return

  newLocale = m.languagePicker.selectedCode
  selectedSetting = m.userLocation.peek().filteredChildren[m.settingsMenu.itemFocused]

  ' Save to user registry (empty string = automatic)
  user.settings.Save(selectedSetting.settingName, newLocale)

  ' Resolve and load the new locale
  if newLocale = ""
    resolvedLocale = resolveTranslationLocale(true)
  else
    resolvedLocale = newLocale
  end if

  if resolvedLocale <> m.global.translationLocale
    loadTranslations(resolvedLocale)
    m.languageChanged = true
  end if
end sub

' Perform the actual settings exit (apply theme changes and navigate)
sub performSettingsExit()
  if m.themeColorsChanged or m.languageChanged
    if m.themeColorsChanged
      applyThemeColorOverrides(m.global.user.settings)
      m.global.sceneManager.callFunc("refreshThemeColors")
    end if
    m.global.sceneManager.callFunc("reloadHome")
  else
    m.global.sceneManager.callFunc("popScene")
  end if
end sub

function onKeyEvent(key as string, press as boolean) as boolean
  if not press then return false

  if (key = "back" or key = "left") and isValid(m.settingsMenu.focusedChild) and m.userLocation.Count() > 1
    LoadMenu({})
    return true
  else if (key = "back" or key = "left") and isFormInFocus()
    m.settingsMenu.setFocus(true)
    return true
  else if key = "back" and isValid(m.settingsMenu.focusedChild) and m.userLocation.Count() = 1
    ' Exiting Settings - apply theme colors and reload home screen
    exitSettingsAndReloadHome()
    return true
  end if

  if key = "options"
    exitSettingsAndReloadHome()
    return true
  end if

  if key = "right"
    settingSelected()
  end if

  if key = "up" and isValid(m.settingsMenu.focusedChild) and m.settingsMenu.itemFocused = 0
    m.settingsMenu.jumpToItem = m.settingsMenu.content.getChildCount() - 1

    return true
  end if

  if key = "down" and isValid(m.settingsMenu.focusedChild)
    if m.settingsMenu.itemFocused = m.settingsMenu.content.getChildCount() - 1
      m.settingsMenu.jumpToItem = 0

      return true
    end if
  end if

  return false
end function

' onDestroy: Full teardown releasing all resources before component removal
' Called automatically by SceneManager.popScene() / clearScenes()
sub onDestroy()
  m.log.verbose("onDestroy")

  ' Unobserve all child node observers
  m.integerSetting.unobserveField("submit")
  m.integerSetting.unobserveField("escape")
  m.hexSetting.unobserveField("submit")
  m.hexSetting.unobserveField("escape")
  m.hexSetting.unobserveField("reset")
  m.alphaSetting.unobserveField("submit")
  m.alphaSetting.unobserveField("escape")
  m.alphaSetting.unobserveField("reset")
  m.languagePicker.unobserveField("selectedCode")
  m.settingsMenu.unobserveField("itemFocused")
  m.settingsMenu.unobserveField("itemSelected")
  m.boolSetting.unobserveField("checkedItem")
  m.radioSetting.unobserveField("checkedItem")

  ' Unobserve scoped observer on sceneManager (may be active if reset dialog was open)
  m.global.sceneManager.unobserveFieldScoped("isDataReturned")

  ' Clear node references
  m.settingsMenu = invalid
  m.settingDetail = invalid
  m.settingDesc = invalid
  m.path = invalid
  m.boolSetting = invalid
  m.integerSetting = invalid
  m.radioSetting = invalid
  m.hexSetting = invalid
  m.alphaSetting = invalid
  m.languagePicker = invalid

  ' Clear data structures
  m.userLocation = invalid
  m.configTree = invalid
  m.customColorBackup = invalid
end sub