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/dialogs.bs"
import "pkg:/source/utils/globals.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/screenReadiness.bs"
import "pkg:/source/utils/translate.bs"
import "pkg:/source/utils/translateLocale.bs"

sub init()
  ' Open the readiness ledger for one load of this screen. `init()` is the whole load:
  ' everything this screen shows is built here, synchronously, from two `pkg:` JSON files
  ' — so unlike `ItemDetails` (which opens its ledger in the route handler because its
  ' load is a fetch that outlives init) there is no later handler to open it in.
  '
  ' Deliberately NOT `onScreenShown()`: that runs after the router has already mounted
  ' this node, so a ledger opened there would begin AFTER the work it is supposed to
  ' measure. `ItemDetails` measures the same span this does — from "the screen starts
  ' loading" to "the screen has rendered" — and excludes the router's mount either way.
  screenLoad.begin("settings")
  m.log = new log.Logger("Settings")
  m.top.overhangTitle = translate(translationKeys.LabelSettings)
  m.top.isOptionsAvailable = false

  ' Overhang config — declarative fields projected onto the overhang by JRScene (registerOverhangData).
  m.top.isLogoVisible = true
  m.top.shouldShowUserDropdown = true

  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")

  ' The right-hand panel — description text plus whichever setting control the focused row
  ' uses — is filled by `settingFocused`, which Scene Graph dispatches from the observer
  ' above on a LATER turn of the event loop, not inline from `LoadMenu`'s content
  ' assignment. So it is outstanding at paint, and the ledger's first rule is that it must
  ' be declared before paint rather than discovered afterwards.
  screenLoad.pending("detail")

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

  ' PAINT — the LEFT half of the screen. The menu list is built and focusable by here, and
  ' the two `pkg:` JSON reads that feed it (`languages.json` above, `settings.json` in
  ' `GetConfigTree`) are synchronous, so this is genuinely "content ready" for that half.
  '
  ' The RIGHT half is not, and that is why `detail` is declared above rather than this
  ' screen reporting zero fills. Setting `m.settingsMenu.content` does not call
  ' `settingFocused` inline — Scene Graph delivers the `itemFocused` change through the
  ' message port, so the handler that fills the description and reveals the setting control
  ' runs on a later turn of the event loop, ~250 ms after this line on a Stick 4K.
  '
  ' Verified on device rather than reasoned about, because same-component observer dispatch
  ' is exactly the kind of platform behaviour that is easy to assume and wrong: a probe log
  ' in `settingFocused` landed after the `screen-load paint` line on every launch. An
  ' earlier cut of this file marked paint here and declared nothing, and so published a
  ' number for a screen whose right half was still empty — the flattering-first-paint
  ' failure the whole ledger exists to prevent.
  screenLoad.paint()
end sub

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

  ' A settings.json entry MAY declare `min` / `max`. One that does is range-checked here,
  ' at SAVE, rather than clamped where the value is read.
  '
  ' Clamping at use time is invisible: the number in Settings keeps saying what was typed
  ' while the app quietly behaves as though it said something else, and the next reader of
  ' either half has no way to tell. `playbackBitrateLimit` is the standing example — it
  ' takes any number and the consumer guards, so a user who types 400 sees 400 forever.
  ' Refusing outright is the other extreme and is worse for a keypad: the user has already
  ' committed the value and would have to retype it to learn what is allowed. So the range
  ' is enforced once, at the boundary, and the user is TOLD what will be stored instead and
  ' gets to decline.
  '
  ' A setting with no declared range takes the pre-existing path untouched, raw text and
  ' all — this is a capability entries opt into, not a change to every integer setting.
  bounds = settingRangeBounds(selectedSetting)
  if isValid(bounds)
    typed = Int(Val(newValue))
    inRange = clampToSettingRange(typed, bounds)

    if inRange <> typed
      ' Carried on `m` rather than re-read in the response handler: the handler runs after
      ' a round trip through a dialog, and re-deriving the setting from the focused menu
      ' index would save against whatever is focused THEN.
      m.rangeConfirmSettingName = selectedSetting.settingName
      m.rangeConfirmValue = inRange.ToStr()
      m.rangeConfirmDialog = showConfirmDialog(translate(translationKeys.ErrorValueOutOfRange), translate(translationKeys.MessageValueOutsideSupportedRange, [typed.ToStr(), bounds.min.ToStr(), bounds.max.ToStr(), m.rangeConfirmValue]), "onIntegerRangeDialogResponse", translate(translationKeys.ButtonSave), translate(translationKeys.ButtonCancel))
      return
    end if

    ' In range, so store the NORMALIZED number rather than the raw text: the keypad happily
    ' produces "016", which round-trips through Val() to the right value but shows the user
    ' their leading zeros forever. Scoped to entries that declare a range so no existing
    ' setting changes shape.
    user.settings.Save(selectedSetting.settingName, typed.ToStr())
    m.integerSetting.text = typed.ToStr()
    m.settingsMenu.setFocus(true)
    return
  end if

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

  m.settingsMenu.setFocus(true)
end sub

' Handle the user's answer to "that value is out of range — save the clamped one instead?"
sub onIntegerRangeDialogResponse()
  ' The dialog and the two values it was raised over are ONE carried decision, so they are
  ' read into locals and cleared together, before any early return. Clearing only the dialog
  ' would leave `m` describing a confirmation that is no longer pending — a stale setting
  ' name outliving its dialog is exactly the mismatch this handler carries them to avoid.
  dialog = m.rangeConfirmDialog
  settingName = m.rangeConfirmSettingName
  clampedValue = m.rangeConfirmValue
  m.rangeConfirmDialog = invalid
  m.rangeConfirmSettingName = invalid
  m.rangeConfirmValue = invalid
  if not isValid(dialog) or not isValid(dialog.result) then return

  ' Declined, or dismissed. Focus goes back to the KEYPAD, not the menu: the user was told
  ' the value they typed is not storable, so the useful next action is typing another one.
  if not dialog.result.confirmed
    m.integerSetting.setFocus(true)
    return
  end if

  user.settings.Save(settingName, clampedValue)
  ' Show what was actually stored. Leaving the typed value on screen after saving a
  ' different one is the same invisible divergence the save-time check exists to remove.
  m.integerSetting.text = clampedValue
  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)
    showAlertDialog(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.resetConfirmDialog = showConfirmDialog(translate(translationKeys.LabelResetSetting), translate(translationKeys.MessageAreYouSureThisWillReset), "onResetDialogResponse", translate(translationKeys.ButtonReset), translate(translationKeys.ButtonCancel))
end sub

' Handle user response from reset confirmation dialog
sub onResetDialogResponse()
  dialog = m.resetConfirmDialog
  m.resetConfirmDialog = invalid
  if not isValid(dialog) or not isValid(dialog.result) then return

  ' User selected "Cancel" or closed dialog
  if not dialog.result.confirmed
    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
    showAlertDialog(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.resetConfirmDialog = showConfirmDialog(translate(translationKeys.LabelResetSetting), translate(translationKeys.MessageAreYouSureThisWillReset), "onAlphaResetDialogResponse", translate(translationKeys.ButtonReset), translate(translationKeys.ButtonCancel))
end sub

' Handle user response from alpha reset confirmation dialog
sub onAlphaResetDialogResponse()
  dialog = m.resetConfirmDialog
  m.resetConfirmDialog = invalid
  if not isValid(dialog) or not isValid(dialog.result) then return

  ' User selected "Cancel" or closed dialog
  if not dialog.result.confirmed
    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()
  ' The right panel has landed. Resolved at the TOP because this handler has an early
  ' return (an entry with no `type`), and a `pending` that some path can skip leaves the
  ' screen permanently unsettled — reported as unmeasurable, never as a wrong number. The
  ' wait being measured is the DISPATCH, which is the ~250 ms; everything below is
  ' synchronous node writes on the render thread.
  '
  ' Silent on every later call. Moving focus re-runs this on a screen that settled long
  ' ago, and `resolve` ignores a run that is over — see screenReadiness.bs.
  screenLoad.resolve("detail")

  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.resetConfirmDialog = showConfirmDialog(translate(translationKeys.LabelResetUserSettings), translate(translationKeys.MessageAreYouSureYouWantTo2), "onResetUserSettingsResponse", translate(translationKeys.ButtonReset), translate(translationKeys.ButtonCancel))
end sub

' Handle user response from reset user settings confirmation dialog
sub onResetUserSettingsResponse()
  dialog = m.resetConfirmDialog
  m.resetConfirmDialog = invalid
  if not isValid(dialog) or not isValid(dialog.result) or not dialog.result.confirmed
    ' 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", "")

  ' 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()
    ' Yes = replace the custom colors. The helper puts the affirmative on the
    ' RIGHT (cancel-left convention), so this reads [No, Yes] rather than the
    ' [Yes, No] the legacy call used — consistent with every other confirm now.
    m.exitConfirmDialog = showConfirmDialog(translate(translationKeys.LabelReplaceCustomColors), translate(translationKeys.MessageYourCustomColorsWillBeReplaced), "onExitConfirmResponse", translate(translationKeys.LabelYes), translate(translationKeys.LabelNo))
    return
  end if

  performSettingsExit()
end sub

' Handle user response from exit confirmation dialog
sub onExitConfirmResponse()
  dialog = m.exitConfirmDialog
  m.exitConfirmDialog = invalid
  if not isValid(dialog) or not isValid(dialog.result) or not dialog.result.confirmed
    ' 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 registry (empty string = automatic). The auto-sync observer routes
  ' global* settings to the device-wide section and per-user settings to the
  ' user's section.
  user.settings.Save(selectedSetting.settingName, newLocale)

  ' globalTranslationLocale is the device-wide sign-in-screen language, consumed
  ' ONLY pre-login (see resolveTranslationLocale). It must NOT live-reload the
  ' current post-login session — that would re-theme the home screen the user is
  ' looking at to the device default, overriding their own language. It takes
  ' effect the next time the sign-in screens render (Sign Out / Change User),
  ' which re-resolve the pre-login locale at the appStart login-flow entry.
  if selectedSetting.settingName = "globalTranslationLocale" then return

  ' 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
    ' reloadHome signals main.bs to re-navigate the router to "/" for a fresh,
    ' re-themed/re-translated Home (see JRScene.reloadRoutedHome).
    m.global.sceneManager.callFunc("reloadHome")
  else
    ' Settings is a routed view — pop it via the router (back to Home).
    sgrouter.goBack()
  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 via JRScreen.beforeViewClose when sgRouter permanently closes this view.
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")

  ' Drop any confirmation still on screen — it is a scene child, so it would
  ' otherwise outlive this view (see abandonDialog in source/utils/dialogs.bs)
  abandonDialog(m.resetConfirmDialog)
  abandonDialog(m.exitConfirmDialog)
  abandonDialog(m.rangeConfirmDialog)
  m.resetConfirmDialog = invalid
  m.exitConfirmDialog = invalid
  m.rangeConfirmDialog = invalid
  ' Cleared with their dialog, not just alongside it — see onIntegerRangeDialogResponse.
  m.rangeConfirmSettingName = invalid
  m.rangeConfirmValue = invalid

  ' 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