A bare CheckBox's word-wrap left-aligns every wrapped line flush with the checkbox glyph, so "Privacy Policy." on its own line read as unindented and sloppy next to an otherwise fully themed form. Split the toggle from its label (a separate Label wraps within its own column instead, so wrapped lines stay aligned under the first line) and wrapped the row in a bordered card matching the app's field styling. Clicking the label also toggles the checkbox now, for a bigger hit target. Verified with an actual rendered screenshot: wrapped text now indents consistently, and both checked/unchecked states are legible.
943 lines
41 KiB
GDScript
943 lines
41 KiB
GDScript
extends Control
|
|
|
|
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
|
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
|
|
const AUTH_BASE := "https://us3q.ranaze.com/api/Auth"
|
|
const EXTERNAL_AUTH_STORAGE_PREFIX := "ranaze_external_auth_url_"
|
|
const STEAM_DARK_ICON := preload("res://assets/icons/brands/steam-dark.svg")
|
|
const STEAM_LIGHT_ICON := preload("res://assets/icons/brands/steam-light.svg")
|
|
const GOOGLE_ICON := preload("res://assets/icons/brands/google.svg")
|
|
|
|
var main: Control
|
|
var auth_base_url := AUTH_BASE
|
|
var background: ColorRect
|
|
var left_brand: VBoxContainer
|
|
var brand_tagline: RichTextLabel
|
|
var auth_panel: PanelContainer
|
|
var panel_content: VBoxContainer
|
|
var heading: Label
|
|
var subheading: Label
|
|
var message_label: Label
|
|
var theme_button: Button
|
|
var login_view: VBoxContainer
|
|
var register_view: VBoxContainer
|
|
var reset_view: VBoxContainer
|
|
var steam_view: VBoxContainer
|
|
var login_input: LineEdit
|
|
var password_input: LineEdit
|
|
var password_reveal_entries: Array[Dictionary] = []
|
|
var registration_username_input: LineEdit
|
|
var registration_email_input: LineEdit
|
|
var registration_password_input: LineEdit
|
|
var registration_confirm_input: LineEdit
|
|
var registration_legal_check: CheckBox
|
|
var reset_login_input: LineEdit
|
|
var reset_code_input: LineEdit
|
|
var reset_password_input: LineEdit
|
|
var reset_confirm_input: LineEdit
|
|
var reset_confirm_group: VBoxContainer
|
|
var steam_action: OptionButton
|
|
var steam_username_input: LineEdit
|
|
var steam_email_input: LineEdit
|
|
var steam_password_input: LineEdit
|
|
var steam_legal_check: CheckBox
|
|
var external_buttons: Dictionary = {}
|
|
var panel_gutter: MarginContainer
|
|
var submit_buttons: Array[Button] = []
|
|
var fields: Array[LineEdit] = []
|
|
var requests: Dictionary = {}
|
|
var poll_timer: Timer
|
|
var mode := "login"
|
|
var reset_requested := false
|
|
var steam_transaction_id := ""
|
|
var steam_poll_deadline_msec := 0
|
|
var steam_poll_failures := 0
|
|
var steam_popup_opened := false
|
|
var external_provider_id := ""
|
|
var external_provider_name := ""
|
|
var external_verified_email := ""
|
|
var automatic_login_pending := false
|
|
var login_attempt_username := ""
|
|
var compact_layout := false
|
|
var layout_updating := false
|
|
|
|
|
|
func _ready() -> void:
|
|
auth_base_url = EndpointConfig.resolve(AUTH_BASE, "/api/Auth")
|
|
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
_build_ui()
|
|
_create_request("login", _on_login_completed)
|
|
_create_request("register", _on_register_completed)
|
|
_create_request("reset_request", _on_reset_requested)
|
|
_create_request("reset_confirm", _on_reset_confirmed)
|
|
_create_request("providers", _on_providers_loaded)
|
|
_create_request("steam_start", _on_steam_started)
|
|
_create_request("steam_status", _on_steam_status)
|
|
_create_request("steam_complete", _on_steam_completed)
|
|
poll_timer = Timer.new()
|
|
poll_timer.wait_time = 2.0
|
|
poll_timer.timeout.connect(_poll_steam)
|
|
add_child(poll_timer)
|
|
get_viewport().size_changed.connect(_update_responsive_layout)
|
|
resized.connect(_update_responsive_layout)
|
|
UIThemeService.theme_changed.connect(_on_theme_changed)
|
|
_update_responsive_layout.call_deferred()
|
|
_set_mode("login")
|
|
_apply_theme()
|
|
_load_external_providers()
|
|
|
|
|
|
func on_show(payload: Variant = null) -> void:
|
|
_reset_steam_flow()
|
|
_set_busy(false)
|
|
_set_mode("login")
|
|
_clear_message()
|
|
var login_message := str(payload).strip_edges() if payload != null else ""
|
|
if not login_message.is_empty():
|
|
message_label.text = login_message
|
|
message_label.add_theme_color_override("font_color", Color("ef4444"))
|
|
login_input.grab_focus()
|
|
|
|
|
|
func on_hide() -> void:
|
|
_reset_steam_flow()
|
|
|
|
|
|
func _build_ui() -> void:
|
|
background = ColorRect.new()
|
|
background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
background.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
add_child(background)
|
|
|
|
_add_dot_grid(Vector2(22, 24))
|
|
_add_dot_grid(Vector2(22, 840))
|
|
|
|
theme_button = Button.new()
|
|
theme_button.tooltip_text = "Switch color theme"
|
|
theme_button.custom_minimum_size = Vector2(44, 44)
|
|
theme_button.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
|
theme_button.position = Vector2(-58, 14)
|
|
theme_button.pressed.connect(UIThemeService.toggle_theme)
|
|
add_child(theme_button)
|
|
|
|
var page_margin := MarginContainer.new()
|
|
page_margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
page_margin.add_theme_constant_override("margin_left", 52)
|
|
page_margin.add_theme_constant_override("margin_right", 52)
|
|
page_margin.add_theme_constant_override("margin_top", 28)
|
|
page_margin.add_theme_constant_override("margin_bottom", 28)
|
|
add_child(page_margin)
|
|
|
|
var columns := HBoxContainer.new()
|
|
columns.add_theme_constant_override("separation", 72)
|
|
page_margin.add_child(columns)
|
|
|
|
left_brand = VBoxContainer.new()
|
|
left_brand.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
left_brand.alignment = BoxContainer.ALIGNMENT_CENTER
|
|
left_brand.add_theme_constant_override("separation", 24)
|
|
columns.add_child(left_brand)
|
|
var brand_row := HBoxContainer.new()
|
|
brand_row.alignment = BoxContainer.ALIGNMENT_CENTER
|
|
brand_row.add_theme_constant_override("separation", 24)
|
|
left_brand.add_child(brand_row)
|
|
var large_icon := TextureRect.new()
|
|
large_icon.texture = Brand.ranaze_mark_texture()
|
|
large_icon.custom_minimum_size = Vector2(190, 190)
|
|
large_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
|
large_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
|
brand_row.add_child(large_icon)
|
|
var wordmark := VBoxContainer.new()
|
|
wordmark.add_theme_constant_override("separation", 4)
|
|
brand_row.add_child(wordmark)
|
|
var name_row := HBoxContainer.new()
|
|
name_row.add_theme_constant_override("separation", 0)
|
|
wordmark.add_child(name_row)
|
|
var rana_wordmark := Label.new()
|
|
rana_wordmark.text = "Rana"
|
|
rana_wordmark.add_theme_font_size_override("font_size", 76)
|
|
rana_wordmark.add_theme_font_override("font", Brand.font)
|
|
name_row.add_child(rana_wordmark)
|
|
var ze_wordmark := Label.new()
|
|
ze_wordmark.text = "Ze"
|
|
ze_wordmark.add_theme_font_size_override("font_size", 76)
|
|
ze_wordmark.add_theme_font_override("font", Brand.font)
|
|
ze_wordmark.add_theme_color_override("font_color", Brand.SKY_BLUE)
|
|
ze_wordmark.set_meta("brand_accent", true)
|
|
name_row.add_child(ze_wordmark)
|
|
var trainer_wordmark := Label.new()
|
|
trainer_wordmark.text = "STEP 3 TRAINER"
|
|
trainer_wordmark.add_theme_font_size_override("font_size", 22)
|
|
trainer_wordmark.add_theme_font_override("font", Brand.font)
|
|
trainer_wordmark.add_theme_color_override("font_color", Brand.muted_color(true))
|
|
trainer_wordmark.set_meta("brand_subtitle", true)
|
|
trainer_wordmark.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
wordmark.add_child(trainer_wordmark)
|
|
brand_tagline = RichTextLabel.new()
|
|
brand_tagline.bbcode_enabled = true
|
|
brand_tagline.fit_content = true
|
|
brand_tagline.scroll_active = false
|
|
brand_tagline.text = "Focused practice. Better decisions.\n[color=#2563eb]Step 3[/color] success."
|
|
brand_tagline.add_theme_font_override("normal_font", Brand.font)
|
|
brand_tagline.add_theme_font_size_override("normal_font_size", 20)
|
|
brand_tagline.custom_minimum_size = Vector2(520, 64)
|
|
brand_tagline.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
left_brand.add_child(brand_tagline)
|
|
|
|
var panel_center := CenterContainer.new()
|
|
panel_center.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
panel_center.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
columns.add_child(panel_center)
|
|
auth_panel = PanelContainer.new()
|
|
auth_panel.custom_minimum_size = Vector2(710, 1000)
|
|
panel_center.add_child(auth_panel)
|
|
var scroll := ScrollContainer.new()
|
|
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
|
auth_panel.add_child(scroll)
|
|
panel_gutter = MarginContainer.new()
|
|
panel_gutter.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
panel_gutter.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
panel_gutter.add_theme_constant_override("margin_right", 28)
|
|
scroll.add_child(panel_gutter)
|
|
panel_content = VBoxContainer.new()
|
|
panel_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
panel_content.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
panel_content.add_theme_constant_override("separation", 24)
|
|
panel_gutter.add_child(panel_content)
|
|
|
|
# main_column holds everything except the legal footer and is the part
|
|
# that should center vertically when a view (e.g. login) is shorter than
|
|
# the panel - the footer stays pinned to the very bottom either way.
|
|
var main_column := VBoxContainer.new()
|
|
main_column.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
|
main_column.alignment = BoxContainer.ALIGNMENT_CENTER
|
|
main_column.add_theme_constant_override("separation", 16)
|
|
panel_content.add_child(main_column)
|
|
|
|
var panel_header := HBoxContainer.new()
|
|
panel_header.add_theme_constant_override("separation", 18)
|
|
main_column.add_child(panel_header)
|
|
var small_icon := TextureRect.new()
|
|
small_icon.texture = Brand.ranaze_mark_texture()
|
|
small_icon.custom_minimum_size = Vector2(72, 72)
|
|
small_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
|
small_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
|
panel_header.add_child(small_icon)
|
|
var titles := VBoxContainer.new()
|
|
titles.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
panel_header.add_child(titles)
|
|
heading = Label.new()
|
|
heading.add_theme_font_size_override("font_size", 32)
|
|
heading.add_theme_font_override("font", Brand.font)
|
|
titles.add_child(heading)
|
|
subheading = Label.new()
|
|
subheading.add_theme_font_size_override("font_size", 20)
|
|
subheading.add_theme_font_override("font", Brand.font)
|
|
titles.add_child(subheading)
|
|
|
|
login_view = _build_login_view()
|
|
register_view = _build_register_view()
|
|
reset_view = _build_reset_view()
|
|
steam_view = _build_steam_view()
|
|
for view in [login_view, register_view, reset_view, steam_view]:
|
|
main_column.add_child(view)
|
|
|
|
message_label = Label.new()
|
|
message_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
message_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
|
message_label.add_theme_font_size_override("font_size", 14)
|
|
message_label.add_theme_font_override("font", Brand.font)
|
|
main_column.add_child(message_label)
|
|
var legal_footer := HBoxContainer.new()
|
|
legal_footer.alignment = BoxContainer.ALIGNMENT_CENTER
|
|
legal_footer.add_theme_constant_override("separation", 12)
|
|
panel_content.add_child(legal_footer)
|
|
_add_legal_links(legal_footer)
|
|
|
|
|
|
func _build_login_view() -> VBoxContainer:
|
|
var view := _new_form()
|
|
login_input = _add_field(view, "Email or Username", "Enter your email or username")
|
|
password_input = _add_field(view, "Password", "Enter your password", true)
|
|
_add_password_reveal(password_input)
|
|
var forgot := Button.new()
|
|
forgot.text = "Forgot password?"
|
|
forgot.flat = true
|
|
forgot.alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
|
forgot.pressed.connect(func(): _set_mode("reset"))
|
|
view.add_child(forgot)
|
|
var sign_in := _add_primary_button(view, "Sign In", _on_login_pressed)
|
|
password_input.text_submitted.connect(func(_text: String): _on_login_pressed())
|
|
view.add_child(_build_divider())
|
|
for provider in [["steam", "Steam"], ["google", "Google"]]:
|
|
var external := Button.new()
|
|
external.text = "Sign in with %s" % provider[1]
|
|
external.icon = GOOGLE_ICON if provider[0] == "google" else STEAM_LIGHT_ICON
|
|
external.add_theme_constant_override("icon_max_width", 20)
|
|
external.custom_minimum_size = Vector2(0, 68)
|
|
external.visible = false
|
|
external.pressed.connect(_start_external.bind(str(provider[0]), str(provider[1])))
|
|
view.add_child(external)
|
|
external_buttons[provider[0]] = external
|
|
submit_buttons.append(external)
|
|
var create := Button.new()
|
|
create.text = "New to RanaZe? Create an account"
|
|
create.flat = true
|
|
create.pressed.connect(func(): _set_mode("register"))
|
|
view.add_child(create)
|
|
submit_buttons.append(sign_in)
|
|
return view
|
|
|
|
|
|
func _build_register_view() -> VBoxContainer:
|
|
var view := _new_form()
|
|
registration_username_input = _add_field(view, "Username", "3-24 letters, numbers, or underscores")
|
|
registration_email_input = _add_field(view, "Email", "[email protected]")
|
|
registration_password_input = _add_field(view, "Password", "At least 8 characters", true)
|
|
registration_confirm_input = _add_field(view, "Confirm password", "Re-enter your password", true)
|
|
_add_password_reveal(registration_password_input)
|
|
_add_password_reveal(registration_confirm_input)
|
|
registration_legal_check = _add_legal_consent(view)
|
|
_add_primary_button(view, "Create Account", _on_register_pressed)
|
|
_add_back_button(view, "Already have an account? Sign in", "login")
|
|
registration_confirm_input.text_submitted.connect(func(_text: String): _on_register_pressed())
|
|
return view
|
|
|
|
|
|
func _build_reset_view() -> VBoxContainer:
|
|
var view := _new_form()
|
|
reset_login_input = _add_field(view, "Email or Username", "Enter your account email or username")
|
|
_add_primary_button(view, "Send Verification Code", _request_reset)
|
|
reset_confirm_group = _new_form()
|
|
view.add_child(reset_confirm_group)
|
|
reset_code_input = _add_field(reset_confirm_group, "Verification code", "6-digit code")
|
|
reset_code_input.max_length = 6
|
|
reset_password_input = _add_field(reset_confirm_group, "New password", "At least 8 characters", true)
|
|
reset_confirm_input = _add_field(reset_confirm_group, "Confirm new password", "Re-enter your new password", true)
|
|
_add_password_reveal(reset_password_input)
|
|
_add_password_reveal(reset_confirm_input)
|
|
_add_primary_button(reset_confirm_group, "Update Password", _confirm_reset)
|
|
_add_back_button(view, "Back to sign in", "login")
|
|
reset_confirm_group.visible = false
|
|
return view
|
|
|
|
|
|
func _build_steam_view() -> VBoxContainer:
|
|
var view := _new_form()
|
|
var help := Label.new()
|
|
help.text = "Your identity was verified. Create a RanaZe account or link an existing one."
|
|
help.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
view.add_child(help)
|
|
steam_action = OptionButton.new()
|
|
steam_action.add_item("Create a new RanaZe account")
|
|
steam_action.add_item("Link an existing RanaZe account")
|
|
steam_action.item_selected.connect(_on_steam_action_changed)
|
|
view.add_child(steam_action)
|
|
steam_username_input = _add_field(view, "Username or email", "Choose a username")
|
|
steam_email_input = _add_field(view, "Email", "[email protected]")
|
|
steam_password_input = _add_field(view, "Password", "At least 8 characters", true)
|
|
_add_password_reveal(steam_password_input)
|
|
steam_legal_check = _add_legal_consent(view)
|
|
_add_primary_button(view, "Continue", _complete_steam)
|
|
_add_back_button(view, "Cancel external sign in", "login")
|
|
return view
|
|
|
|
|
|
func _add_legal_consent(parent: VBoxContainer) -> CheckBox:
|
|
# A bare CheckBox's own word-wrap left-aligns every wrapped line flush
|
|
# with the checkbox glyph, so a second line reads as un-indented and
|
|
# sloppy. Split the toggle from its label into a row instead - the
|
|
# label's own wrapping then stays confined to its column, and the whole
|
|
# thing sits in a card so it doesn't look like a stray unstyled control
|
|
# next to the app's otherwise fully themed form fields.
|
|
var card := PanelContainer.new()
|
|
card.set_meta("legal_consent_card", true)
|
|
parent.add_child(card)
|
|
var margin := MarginContainer.new()
|
|
margin.add_theme_constant_override("margin_left", 16)
|
|
margin.add_theme_constant_override("margin_right", 16)
|
|
margin.add_theme_constant_override("margin_top", 12)
|
|
margin.add_theme_constant_override("margin_bottom", 12)
|
|
card.add_child(margin)
|
|
var row := HBoxContainer.new()
|
|
row.add_theme_constant_override("separation", 12)
|
|
margin.add_child(row)
|
|
var check := CheckBox.new()
|
|
row.add_child(check)
|
|
var label := Label.new()
|
|
label.text = "I agree to the Terms of Service and acknowledge the Privacy Policy."
|
|
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
label.mouse_filter = Control.MOUSE_FILTER_STOP
|
|
label.gui_input.connect(func(event: InputEvent):
|
|
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
|
check.button_pressed = not check.button_pressed
|
|
)
|
|
row.add_child(label)
|
|
return check
|
|
|
|
|
|
func _add_legal_links(parent: HBoxContainer) -> void:
|
|
for spec in [["Terms of Service", "terms"], ["Privacy Policy", "privacy"]]:
|
|
var link := Button.new()
|
|
link.text = spec[0]
|
|
link.flat = true
|
|
link.pressed.connect(_open_legal_document.bind(str(spec[1])))
|
|
parent.add_child(link)
|
|
|
|
|
|
func _open_legal_document(kind: String) -> void:
|
|
main.open_legal_document(kind)
|
|
|
|
|
|
func _new_form() -> VBoxContainer:
|
|
var form := VBoxContainer.new()
|
|
form.add_theme_constant_override("separation", 10)
|
|
return form
|
|
|
|
|
|
func _build_divider() -> HBoxContainer:
|
|
var row := HBoxContainer.new()
|
|
row.add_theme_constant_override("separation", 22)
|
|
for index in range(3):
|
|
if index == 1:
|
|
var label := Label.new()
|
|
label.text = "or"
|
|
label.add_theme_font_override("font", Brand.font)
|
|
row.add_child(label)
|
|
else:
|
|
var line := HSeparator.new()
|
|
line.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
row.add_child(line)
|
|
return row
|
|
|
|
|
|
func _add_field(parent: VBoxContainer, label_text: String, placeholder: String, secret := false) -> LineEdit:
|
|
var label := Label.new()
|
|
label.text = label_text
|
|
label.add_theme_font_size_override("font_size", 15)
|
|
label.add_theme_font_override("font", Brand.font)
|
|
parent.add_child(label)
|
|
var input := LineEdit.new()
|
|
input.placeholder_text = placeholder
|
|
input.secret = secret
|
|
input.caret_blink = true
|
|
input.caret_blink_interval = 0.5
|
|
input.custom_minimum_size = Vector2(0, 56)
|
|
input.add_theme_font_size_override("font_size", 17)
|
|
input.add_theme_font_override("font", Brand.font)
|
|
parent.add_child(input)
|
|
fields.append(input)
|
|
return input
|
|
|
|
|
|
func _add_password_reveal(input: LineEdit) -> void:
|
|
var reveal := Button.new()
|
|
password_reveal_entries.append({"input": input, "button": reveal})
|
|
reveal.text = ""
|
|
reveal.tooltip_text = "Show password"
|
|
reveal.flat = true
|
|
reveal.focus_mode = Control.FOCUS_NONE
|
|
reveal.custom_minimum_size = Vector2(52, 52)
|
|
reveal.set_anchors_preset(Control.PRESET_CENTER_RIGHT)
|
|
reveal.position = Vector2(-60, -26)
|
|
_refresh_password_reveal(input, reveal)
|
|
reveal.pressed.connect(func():
|
|
input.secret = not input.secret
|
|
reveal.tooltip_text = "Show password" if input.secret else "Hide password"
|
|
_refresh_password_reveal(input, reveal)
|
|
)
|
|
input.add_child(reveal)
|
|
|
|
|
|
func _refresh_password_reveal(input: LineEdit, reveal: Button) -> void:
|
|
reveal.icon = preload("res://assets/icons/lucide/eye.svg") if input.secret else preload("res://assets/icons/lucide/eye-off.svg")
|
|
reveal.expand_icon = false
|
|
reveal.add_theme_constant_override("icon_max_width", 24)
|
|
var icon_color := Brand.muted_color(UIThemeService.is_dark_mode())
|
|
for color_name in ["icon_normal_color", "icon_hover_color", "icon_pressed_color", "icon_focus_color"]:
|
|
reveal.add_theme_color_override(color_name, icon_color)
|
|
|
|
|
|
func _add_primary_button(parent: VBoxContainer, text_value: String, callback: Callable) -> Button:
|
|
var button := Button.new()
|
|
button.text = text_value
|
|
button.custom_minimum_size = Vector2(0, 68)
|
|
button.pressed.connect(callback)
|
|
parent.add_child(button)
|
|
submit_buttons.append(button)
|
|
return button
|
|
|
|
|
|
func _add_back_button(parent: VBoxContainer, text_value: String, target_mode: String) -> void:
|
|
var button := Button.new()
|
|
button.text = text_value
|
|
button.flat = true
|
|
button.pressed.connect(func(): _set_mode(target_mode))
|
|
parent.add_child(button)
|
|
|
|
|
|
func _add_dot_grid(origin: Vector2) -> void:
|
|
for row in range(4):
|
|
for column in range(5):
|
|
var dot := ColorRect.new()
|
|
dot.color = Color(Brand.ROYAL_BLUE, 0.45)
|
|
dot.position = origin + Vector2(column * 30, row * 30)
|
|
dot.size = Vector2(5, 5)
|
|
dot.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
add_child(dot)
|
|
|
|
|
|
func _set_mode(next_mode: String) -> void:
|
|
if mode == "steam" and next_mode != "steam":
|
|
_reset_steam_flow()
|
|
mode = next_mode
|
|
if login_view == null: return
|
|
login_view.visible = mode == "login"
|
|
register_view.visible = mode == "register"
|
|
reset_view.visible = mode == "reset"
|
|
steam_view.visible = mode == "steam"
|
|
match mode:
|
|
"register": heading.text = "Create your account"; subheading.text = "Start studying with RanaZe"
|
|
"reset": heading.text = "Reset your password"; subheading.text = "We will email you a verification code"
|
|
"steam": heading.text = "Finish %s sign in" % external_provider_name; subheading.text = "Connect %s to RanaZe" % external_provider_name
|
|
_: heading.text = "Welcome back"; subheading.text = "Sign in to continue"
|
|
_clear_message()
|
|
_update_responsive_layout()
|
|
|
|
|
|
func _update_responsive_layout() -> void:
|
|
if left_brand == null or layout_updating: return
|
|
layout_updating = true
|
|
var layout_width := size.x
|
|
if layout_width <= 0.0:
|
|
layout_width = get_viewport_rect().size.x
|
|
var layout_height := size.y
|
|
if layout_height <= 0.0:
|
|
layout_height = get_viewport_rect().size.y
|
|
var compact_desktop := layout_width < 900.0
|
|
var mobile_web := false
|
|
if OS.has_feature("web"):
|
|
var browser_width: Variant = JavaScriptBridge.eval("window.innerWidth", true)
|
|
mobile_web = float(browser_width) < 760.0
|
|
compact_layout = compact_desktop or mobile_web
|
|
left_brand.visible = not compact_layout
|
|
if mobile_web:
|
|
theme_button.custom_minimum_size = Vector2(160, 160)
|
|
theme_button.position = Vector2(-190, 20)
|
|
theme_button.add_theme_font_size_override("font_size", 64)
|
|
auth_panel.custom_minimum_size = Vector2(1700, 2300)
|
|
panel_gutter.add_theme_constant_override("margin_right", 96)
|
|
_apply_compact_scale(panel_content, 3.8)
|
|
else:
|
|
theme_button.custom_minimum_size = Vector2(44, 44)
|
|
theme_button.position = Vector2(-58, 14)
|
|
theme_button.add_theme_font_size_override("font_size", 20)
|
|
# 1000/1010 comfortably fits every view (login/register/reset/steam)
|
|
# without the inner ScrollContainer kicking in, on any window tall
|
|
# enough to hold it - but clamp to whatever height is actually
|
|
# available so a short window shrinks the panel (and lets the
|
|
# ScrollContainer take over) instead of the panel overflowing off
|
|
# the top/bottom of the screen.
|
|
var available_panel_height := layout_height - 56.0
|
|
var target_height := 1010.0 if compact_desktop else 1000.0
|
|
var panel_height := clampf(available_panel_height, 400.0, target_height)
|
|
auth_panel.custom_minimum_size = Vector2(640 if compact_desktop else 710, panel_height)
|
|
panel_gutter.add_theme_constant_override("margin_right", 28)
|
|
_apply_compact_scale(panel_content, 1.0)
|
|
_apply_theme()
|
|
layout_updating = false
|
|
|
|
|
|
func _apply_compact_scale(control: Control, factor: float) -> void:
|
|
if not control.has_meta("medmind_base_minimum"):
|
|
control.set_meta("medmind_base_minimum", control.custom_minimum_size)
|
|
var base_minimum: Vector2 = control.get_meta("medmind_base_minimum")
|
|
control.custom_minimum_size = base_minimum * factor
|
|
if control is Label or control is Button or control is LineEdit or control is OptionButton:
|
|
if not control.has_meta("medmind_base_font_size"):
|
|
control.set_meta("medmind_base_font_size", control.get_theme_font_size("font_size"))
|
|
control.add_theme_font_size_override("font_size", int(round(float(control.get_meta("medmind_base_font_size")) * factor)))
|
|
for child in control.get_children():
|
|
if child is Control:
|
|
_apply_compact_scale(child, factor)
|
|
|
|
|
|
func _apply_theme() -> void:
|
|
var dark := UIThemeService.is_dark_mode()
|
|
background.color = Brand.background_color(dark)
|
|
background.material = Brand.background_material(dark)
|
|
auth_panel.add_theme_stylebox_override("panel", Brand.panel_box(dark))
|
|
Brand.set_theme_toggle_icon(theme_button, dark)
|
|
theme_button.tooltip_text = "Use light theme" if dark else "Use dark theme"
|
|
theme_button.add_theme_font_override("font", Brand.font)
|
|
for node in find_children("*", "Label", true, false):
|
|
var label := node as Label
|
|
if label.has_meta("brand_accent"):
|
|
label.add_theme_color_override("font_color", Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE)
|
|
elif label.has_meta("brand_subtitle"):
|
|
label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
|
else:
|
|
label.add_theme_color_override("font_color", Brand.text_color(dark))
|
|
for input in fields:
|
|
_style_auth_field(input, dark)
|
|
for node in find_children("*", "PanelContainer", true, false):
|
|
if node.has_meta("legal_consent_card"):
|
|
(node as PanelContainer).add_theme_stylebox_override("panel", _legal_consent_box(dark))
|
|
for button in find_children("*", "Button", true, false):
|
|
var typed := button as Button
|
|
typed.add_theme_font_override("font", Brand.font)
|
|
typed.add_theme_color_override("font_color", Brand.text_color(dark))
|
|
if not typed.flat:
|
|
typed.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark))
|
|
typed.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true))
|
|
for button in submit_buttons:
|
|
if not button.text.begins_with("Sign in with "):
|
|
button.add_theme_stylebox_override("normal", Brand.gradient_button_box())
|
|
button.add_theme_color_override("font_color", Color.WHITE)
|
|
if external_buttons.has("steam"):
|
|
(external_buttons["steam"] as Button).icon = STEAM_DARK_ICON if dark else STEAM_LIGHT_ICON
|
|
var theme_normal := StyleBoxFlat.new()
|
|
theme_normal.bg_color = Color("111b30") if dark else Brand.LIGHT_SURFACE
|
|
theme_normal.border_color = Color("33415d") if dark else Brand.LIGHT_BORDER
|
|
theme_normal.set_border_width_all(1)
|
|
theme_normal.set_corner_radius_all(22)
|
|
theme_button.add_theme_stylebox_override("normal", theme_normal)
|
|
theme_button.add_theme_stylebox_override("hover", theme_normal)
|
|
theme_button.add_theme_color_override("font_color", Brand.AMBER)
|
|
for entry in password_reveal_entries:
|
|
_refresh_password_reveal(entry["input"], entry["button"])
|
|
subheading.add_theme_color_override("font_color", Brand.muted_color(dark))
|
|
message_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
|
if brand_tagline != null:
|
|
brand_tagline.add_theme_color_override("default_color", Brand.muted_color(dark))
|
|
|
|
|
|
func _legal_consent_box(dark: bool) -> StyleBoxFlat:
|
|
var box := StyleBoxFlat.new()
|
|
box.bg_color = Color("0d172a") if dark else Brand.LIGHT_SURFACE
|
|
box.border_color = Color("33415d") if dark else Brand.LIGHT_BORDER
|
|
box.set_border_width_all(1)
|
|
box.set_corner_radius_all(10)
|
|
return box
|
|
|
|
|
|
func _style_auth_field(input: LineEdit, dark: bool) -> void:
|
|
input.add_theme_color_override("font_color", Brand.text_color(dark))
|
|
input.add_theme_color_override("font_uneditable_color", Brand.text_color(dark))
|
|
input.add_theme_color_override("font_placeholder_color", Brand.muted_color(dark))
|
|
input.add_theme_color_override("caret_color", Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE)
|
|
input.add_theme_stylebox_override("normal", Brand.field_box(dark))
|
|
input.add_theme_stylebox_override("focus", Brand.field_box(dark, true))
|
|
input.add_theme_stylebox_override("read_only", Brand.field_box(dark))
|
|
|
|
|
|
func _on_theme_changed(_is_dark: bool) -> void:
|
|
_apply_theme()
|
|
|
|
|
|
func _on_login_pressed() -> void:
|
|
_send_login(login_input.text.strip_edges(), password_input.text, false)
|
|
|
|
|
|
func _send_login(identifier: String, password: String, automatic: bool) -> void:
|
|
if identifier.is_empty() or password.is_empty(): return _show_error("Email or username and password are required.")
|
|
automatic_login_pending = automatic
|
|
login_attempt_username = identifier
|
|
_send_json("login", "/login", {"username": identifier, "password": password}, "Signing in...")
|
|
|
|
|
|
func _on_login_completed(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
|
_set_busy(false)
|
|
if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300:
|
|
if automatic_login_pending:
|
|
_set_mode("login"); login_input.text = login_attempt_username
|
|
_show_error("Account created, but automatic sign in failed. Please sign in.")
|
|
else: _show_error("Sign in failed. Check your credentials and connection.")
|
|
automatic_login_pending = false
|
|
return
|
|
_accept_session(body, login_attempt_username)
|
|
|
|
|
|
func _on_register_pressed() -> void:
|
|
var username := registration_username_input.text.strip_edges()
|
|
var email := registration_email_input.text.strip_edges()
|
|
var password := registration_password_input.text
|
|
var error := _validate_account(username, email, password, registration_confirm_input.text)
|
|
if not error.is_empty(): return _show_error(error)
|
|
if not registration_legal_check.button_pressed:
|
|
return _show_error("Agree to the Terms of Service and acknowledge the Privacy Policy to create an account.")
|
|
login_attempt_username = username
|
|
_send_json("register", "/register", {"username": username, "email": email, "password": password, "acceptLegal": true}, "Creating account...")
|
|
|
|
|
|
func _on_register_completed(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
|
_set_busy(false)
|
|
if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300:
|
|
return _show_error(_response_message(body, "Account creation failed."))
|
|
_send_login(login_attempt_username, registration_password_input.text, true)
|
|
|
|
|
|
func _request_reset() -> void:
|
|
if reset_login_input.text.strip_edges().is_empty(): return _show_error("Enter your email or username.")
|
|
_send_json("reset_request", "/password-reset/request", {"login": reset_login_input.text.strip_edges()}, "Sending code...")
|
|
|
|
|
|
func _on_reset_requested(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
|
_set_busy(false)
|
|
if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300:
|
|
return _show_error(_response_message(body, "Unable to send a verification code."))
|
|
reset_requested = true
|
|
reset_confirm_group.visible = true
|
|
_show_success("If the account exists, a verification code has been sent.")
|
|
reset_code_input.grab_focus()
|
|
|
|
|
|
func _confirm_reset() -> void:
|
|
if reset_code_input.text.length() != 6: return _show_error("Enter the 6-digit verification code.")
|
|
if reset_password_input.text.length() < 8: return _show_error("Password must be at least 8 characters.")
|
|
if reset_password_input.text != reset_confirm_input.text: return _show_error("Passwords do not match.")
|
|
_send_json("reset_confirm", "/password-reset/confirm", {
|
|
"login": reset_login_input.text.strip_edges(), "code": reset_code_input.text,
|
|
"newPassword": reset_password_input.text
|
|
}, "Updating password...")
|
|
|
|
|
|
func _on_reset_confirmed(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
|
_set_busy(false)
|
|
if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300:
|
|
return _show_error(_response_message(body, "The verification code is invalid or expired."))
|
|
var identifier := reset_login_input.text
|
|
_set_mode("login")
|
|
login_input.text = identifier
|
|
_show_success("Password updated. You can now sign in.")
|
|
|
|
|
|
func _load_external_providers() -> void:
|
|
var request: HTTPRequest = requests["providers"]
|
|
if request.get_http_client_status() == HTTPClient.STATUS_DISCONNECTED:
|
|
request.request("%s/external/providers" % auth_base_url)
|
|
|
|
|
|
func _on_providers_loaded(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
|
if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300:
|
|
return
|
|
var data := _parse_dictionary(body)
|
|
var enabled: Dictionary = {}
|
|
for provider_value in data.get("providers", []):
|
|
if typeof(provider_value) == TYPE_DICTIONARY:
|
|
var provider: Dictionary = provider_value
|
|
enabled[str(provider.get("id", ""))] = true
|
|
for provider_id in external_buttons:
|
|
(external_buttons[provider_id] as Button).visible = enabled.has(provider_id)
|
|
|
|
|
|
func _start_steam() -> void:
|
|
_start_external("steam", "Steam")
|
|
|
|
|
|
func _start_external(provider_id: String, provider_name: String) -> void:
|
|
external_provider_id = provider_id
|
|
external_provider_name = provider_name
|
|
external_verified_email = ""
|
|
var storage_key := _external_storage_key()
|
|
if OS.has_feature("web"):
|
|
var popup_result: Variant = JavaScriptBridge.eval(
|
|
"window.localStorage.removeItem(%s); var ranazeExternalBridge = new URL('/external-redirect.html?key=' + encodeURIComponent(%s) + '&provider=' + encodeURIComponent(%s), window.location.origin).href; window.ranazeExternalWindow = window.open(ranazeExternalBridge, 'ranaze_external', 'popup=yes,width=560,height=720,resizable=yes,scrollbars=yes'); if (window.ranazeExternalWindow) { window.ranazeExternalWindow.blur(); window.focus(); } window.ranazeExternalWindow !== null;" % [JSON.stringify(storage_key), JSON.stringify(storage_key), JSON.stringify(provider_name)],
|
|
true
|
|
)
|
|
steam_popup_opened = bool(popup_result)
|
|
if not steam_popup_opened:
|
|
return _show_error("%s sign in needs a popup. Allow popups for this site, then try again." % provider_name)
|
|
_send_json("steam_start", "/external/%s/start" % provider_id, {"purpose": "login"}, "Opening %s..." % provider_name)
|
|
|
|
|
|
func _on_steam_started(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
|
_set_busy(false)
|
|
if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300:
|
|
_close_steam_popup()
|
|
return _show_error("Unable to start %s sign in." % external_provider_name)
|
|
var data := _parse_dictionary(body)
|
|
steam_transaction_id = str(data.get("transactionId", ""))
|
|
var auth_url := str(data.get("authUrl", ""))
|
|
if steam_transaction_id.is_empty() or auth_url.is_empty():
|
|
_close_steam_popup()
|
|
return _show_error("%s returned an invalid sign-in response." % external_provider_name)
|
|
if OS.has_feature("web"):
|
|
JavaScriptBridge.eval(
|
|
"var ranazeExternalAuthUrl = %s; window.localStorage.setItem(%s, ranazeExternalAuthUrl); if (window.ranazeExternalWindow && !window.ranazeExternalWindow.closed) { try { window.ranazeExternalWindow.location.replace(ranazeExternalAuthUrl); } catch (_) {} window.ranazeExternalWindow.focus(); } else { var ranazeExternalPopup = window.open(ranazeExternalAuthUrl, 'ranaze_external'); if (ranazeExternalPopup) { window.ranazeExternalWindow = ranazeExternalPopup; ranazeExternalPopup.focus(); } else { window.location.assign(ranazeExternalAuthUrl); } }" % [JSON.stringify(auth_url), JSON.stringify(_external_storage_key())],
|
|
true
|
|
)
|
|
else:
|
|
OS.shell_open(auth_url)
|
|
steam_poll_deadline_msec = Time.get_ticks_msec() + 300000
|
|
steam_poll_failures = 0
|
|
_show_success("Complete sign in in the %s window. Waiting for verification..." % external_provider_name)
|
|
poll_timer.start()
|
|
|
|
|
|
func _poll_steam() -> void:
|
|
var status_request: HTTPRequest = requests["steam_status"]
|
|
if steam_transaction_id.is_empty() or status_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: return
|
|
if steam_poll_deadline_msec > 0 and Time.get_ticks_msec() >= steam_poll_deadline_msec:
|
|
_reset_steam_flow()
|
|
return _show_error("Steam sign in timed out. Try again when ready.")
|
|
var error: Error = status_request.request("%s/external/%s/status/%s" % [auth_base_url, external_provider_id, steam_transaction_id])
|
|
if error != OK:
|
|
steam_poll_failures += 1
|
|
if steam_poll_failures >= 3: _show_error("Connection interrupted. Still waiting for %s..." % external_provider_name)
|
|
|
|
|
|
func _on_steam_status(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
|
if result != HTTPRequest.RESULT_SUCCESS or code >= 500 or code == 429:
|
|
steam_poll_failures += 1
|
|
if steam_poll_failures >= 3: _show_error("Connection interrupted. Still waiting for %s..." % external_provider_name)
|
|
return
|
|
if code == 404:
|
|
_reset_steam_flow()
|
|
return _show_error("%s sign in expired. Please try again." % external_provider_name)
|
|
if code < 200 or code >= 300:
|
|
_reset_steam_flow()
|
|
return _show_error(_response_message(body, "%s sign in could not be completed." % external_provider_name))
|
|
steam_poll_failures = 0
|
|
var data := _parse_dictionary(body)
|
|
match str(data.get("status", "pending")):
|
|
"pending": _show_success("Complete sign in in the %s window. Waiting for verification..." % external_provider_name)
|
|
"requires_account":
|
|
poll_timer.stop()
|
|
_close_steam_popup()
|
|
external_verified_email = str(data.get("email", ""))
|
|
steam_email_input.text = external_verified_email
|
|
steam_email_input.editable = external_verified_email.is_empty()
|
|
_set_mode("steam")
|
|
"complete": poll_timer.stop(); _close_steam_popup(); _accept_session(body, str(data.get("username", "")))
|
|
|
|
|
|
func _on_steam_action_changed(index: int) -> void:
|
|
steam_email_input.visible = index == 0
|
|
steam_legal_check.visible = index == 0
|
|
steam_username_input.placeholder_text = "Choose a username" if index == 0 else "Existing email or username"
|
|
|
|
|
|
func _complete_steam() -> void:
|
|
var action := "create" if steam_action.selected == 0 else "link"
|
|
if action == "create":
|
|
var validation := _validate_account(steam_username_input.text.strip_edges(), steam_email_input.text.strip_edges(), steam_password_input.text, steam_password_input.text)
|
|
if not validation.is_empty(): return _show_error(validation)
|
|
if not steam_legal_check.button_pressed:
|
|
return _show_error("Agree to the Terms of Service and acknowledge the Privacy Policy to create an account.")
|
|
elif steam_username_input.text.strip_edges().is_empty() or steam_password_input.text.is_empty():
|
|
return _show_error("Enter your existing account credentials.")
|
|
_send_json("steam_complete", "/external/%s/complete" % external_provider_id, {
|
|
"transactionId": steam_transaction_id, "action": action,
|
|
"username": steam_username_input.text.strip_edges(), "email": steam_email_input.text.strip_edges(),
|
|
"password": steam_password_input.text, "acceptLegal": action == "create"
|
|
}, "Connecting account...")
|
|
|
|
|
|
func _on_steam_completed(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
|
_set_busy(false)
|
|
if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300:
|
|
return _show_error(_response_message(body, "Unable to connect this %s account." % external_provider_name))
|
|
steam_poll_failures = 0
|
|
poll_timer.start()
|
|
_poll_steam()
|
|
|
|
|
|
func _reset_steam_flow() -> void:
|
|
if poll_timer != null: poll_timer.stop()
|
|
_close_steam_popup()
|
|
steam_transaction_id = ""
|
|
steam_poll_deadline_msec = 0
|
|
steam_poll_failures = 0
|
|
external_provider_id = ""
|
|
external_provider_name = ""
|
|
external_verified_email = ""
|
|
|
|
|
|
func _close_steam_popup() -> void:
|
|
if OS.has_feature("web") and steam_popup_opened:
|
|
JavaScriptBridge.eval("window.localStorage.removeItem(%s); if (window.ranazeExternalWindow && !window.ranazeExternalWindow.closed) { window.ranazeExternalWindow.close(); } window.ranazeExternalWindow = null;" % JSON.stringify(_external_storage_key()), true)
|
|
steam_popup_opened = false
|
|
|
|
|
|
func _external_storage_key() -> String:
|
|
return EXTERNAL_AUTH_STORAGE_PREFIX + external_provider_id
|
|
|
|
|
|
func _accept_session(body: PackedByteArray, fallback_username: String) -> void:
|
|
var response := _parse_dictionary(body)
|
|
var token := str(response.get("accessToken", ""))
|
|
if token.is_empty(): return _show_error("Sign in response did not include an access token.")
|
|
var legal_value: Variant = response.get("legal", {})
|
|
var legal: Dictionary = legal_value if typeof(legal_value) == TYPE_DICTIONARY else {}
|
|
AuthState.set_session(str(response.get("username", fallback_username)), token,
|
|
str(response.get("refreshToken", "")), str(response.get("role", "")), str(response.get("exp", "")), legal)
|
|
password_input.text = ""
|
|
_set_busy(false)
|
|
UISoundService.play_sound("correct")
|
|
main.show_screen("legal_consent" if AuthState.requires_legal_acceptance else "dashboard")
|
|
|
|
|
|
func _create_request(key: String, callback: Callable) -> void:
|
|
var request := HTTPRequest.new()
|
|
request.timeout = 20.0
|
|
request.request_completed.connect(callback)
|
|
add_child(request)
|
|
requests[key] = request
|
|
|
|
|
|
func _send_json(key: String, path: String, payload: Dictionary, status: String) -> void:
|
|
_clear_message()
|
|
_set_busy(true)
|
|
message_label.text = status
|
|
var request: HTTPRequest = requests[key]
|
|
var error := request.request(auth_base_url + path, PackedStringArray(["Content-Type: application/json"]), HTTPClient.METHOD_POST, JSON.stringify(payload))
|
|
if error != OK:
|
|
_set_busy(false)
|
|
_show_error("Unable to send the request. Check your connection and try again.")
|
|
|
|
|
|
func _set_busy(busy: bool) -> void:
|
|
for button in submit_buttons: button.disabled = busy
|
|
for field in fields: field.editable = not busy
|
|
|
|
|
|
func _validate_account(username: String, email: String, password: String, confirmation: String) -> String:
|
|
var username_regex := RegEx.new(); username_regex.compile("^[A-Za-z0-9_]{3,24}$")
|
|
if username_regex.search(username) == null: return "Username must be 3-24 letters, numbers, or underscores."
|
|
var email_regex := RegEx.new(); email_regex.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")
|
|
if email_regex.search(email) == null: return "Enter a valid email address."
|
|
if password.length() < 8: return "Password must be at least 8 characters."
|
|
if password != confirmation: return "Passwords do not match."
|
|
return ""
|
|
|
|
|
|
func _parse_dictionary(body: PackedByteArray) -> Dictionary:
|
|
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
|
return parsed if typeof(parsed) == TYPE_DICTIONARY else {}
|
|
|
|
|
|
func _response_message(body: PackedByteArray, fallback: String) -> String:
|
|
var raw := body.get_string_from_utf8().strip_edges()
|
|
var parsed: Variant = JSON.parse_string(raw)
|
|
if typeof(parsed) == TYPE_STRING and not str(parsed).is_empty(): return str(parsed)
|
|
if typeof(parsed) == TYPE_DICTIONARY and parsed.has("message"): return str(parsed.message)
|
|
return fallback
|
|
|
|
|
|
func _clear_message() -> void:
|
|
if message_label != null: message_label.text = ""
|
|
|
|
|
|
func _show_error(value: String) -> void:
|
|
_set_busy(false)
|
|
message_label.text = value
|
|
message_label.add_theme_color_override("font_color", Color("ef4444"))
|
|
UISoundService.play_sound("error")
|
|
|
|
|
|
func _show_success(value: String) -> void:
|
|
message_label.text = value
|
|
message_label.add_theme_color_override("font_color", Brand.SUCCESS)
|