765 lines
28 KiB
GDScript
765 lines
28 KiB
GDScript
extends VBoxContainer
|
||
|
||
const Style := preload("res://scripts/ui/ui_style.gd")
|
||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||
const CLOCK_ICON := preload("res://assets/icons/lucide/clock-3.svg")
|
||
const REVIEW_ICON := preload("res://assets/icons/lucide/chart-no-axes-column-increasing.svg")
|
||
|
||
var main: Control
|
||
var page: VBoxContainer
|
||
var active_section: PanelContainer
|
||
var active_content: VBoxContainer
|
||
var history_section: PanelContainer
|
||
var history_header: BoxContainer
|
||
var table_header: HBoxContainer
|
||
var history_list: VBoxContainer
|
||
var status_label: Label
|
||
var load_more_button: Button
|
||
var filter_option: OptionButton
|
||
var sort_option: OptionButton
|
||
var abandon_dialog: ConfirmationDialog
|
||
var pending_simulation_id := ""
|
||
var pending_launch := false
|
||
var pending_abandon := false
|
||
var history_status := "all"
|
||
var history_sort := "newest"
|
||
var compact_layout := false
|
||
|
||
|
||
func _ready() -> void:
|
||
set_anchors_preset(Control.PRESET_FULL_RECT)
|
||
size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
add_theme_constant_override("separation", 0)
|
||
_build_ui()
|
||
_connect_services()
|
||
resized.connect(_update_responsive_layout)
|
||
UIThemeService.theme_changed.connect(func(_dark: bool): refresh_theme_controls())
|
||
call_deferred("_update_responsive_layout")
|
||
|
||
|
||
func _build_ui() -> void:
|
||
var scroll := ScrollContainer.new()
|
||
scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||
add_child(scroll)
|
||
|
||
page = VBoxContainer.new()
|
||
page.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
page.add_theme_constant_override("separation", 18)
|
||
scroll.add_child(page)
|
||
|
||
active_section = PanelContainer.new()
|
||
active_section.name = "ActiveSimulationSection"
|
||
page.add_child(active_section)
|
||
active_content = VBoxContainer.new()
|
||
active_content.add_theme_constant_override("separation", 10)
|
||
active_section.add_child(active_content)
|
||
|
||
history_section = PanelContainer.new()
|
||
history_section.name = "SimulationHistorySection"
|
||
history_section.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||
page.add_child(history_section)
|
||
var history_content := VBoxContainer.new()
|
||
history_content.add_theme_constant_override("separation", 12)
|
||
history_section.add_child(history_content)
|
||
|
||
history_header = BoxContainer.new()
|
||
history_header.vertical = false
|
||
history_header.add_theme_constant_override("separation", 10)
|
||
history_content.add_child(history_header)
|
||
var heading := Label.new()
|
||
heading.text = "Simulation History"
|
||
heading.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
Brand.apply_font(heading)
|
||
heading.add_theme_font_size_override("font_size", 21)
|
||
history_header.add_child(heading)
|
||
|
||
filter_option = OptionButton.new()
|
||
filter_option.name = "HistoryFilter"
|
||
filter_option.custom_minimum_size = Vector2(190, 42)
|
||
filter_option.add_item("All Simulations")
|
||
filter_option.set_item_metadata(0, "all")
|
||
filter_option.add_item("Completed")
|
||
filter_option.set_item_metadata(1, "completed")
|
||
filter_option.add_item("Abandoned")
|
||
filter_option.set_item_metadata(2, "abandoned")
|
||
filter_option.item_selected.connect(_on_filter_selected)
|
||
history_header.add_child(filter_option)
|
||
|
||
sort_option = OptionButton.new()
|
||
sort_option.name = "HistorySort"
|
||
sort_option.custom_minimum_size = Vector2(170, 42)
|
||
sort_option.add_item("Newest First")
|
||
sort_option.set_item_metadata(0, "newest")
|
||
sort_option.add_item("Oldest First")
|
||
sort_option.set_item_metadata(1, "oldest")
|
||
sort_option.item_selected.connect(_on_sort_selected)
|
||
history_header.add_child(sort_option)
|
||
|
||
status_label = Label.new()
|
||
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
Brand.apply_font(status_label)
|
||
status_label.add_theme_font_size_override("font_size", 15)
|
||
history_content.add_child(status_label)
|
||
|
||
table_header = _build_table_header()
|
||
history_content.add_child(table_header)
|
||
history_list = VBoxContainer.new()
|
||
history_list.add_theme_constant_override("separation", 0)
|
||
history_content.add_child(history_list)
|
||
|
||
load_more_button = Button.new()
|
||
load_more_button.text = "Show more"
|
||
load_more_button.visible = false
|
||
load_more_button.custom_minimum_size = Vector2(150, 42)
|
||
load_more_button.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
|
||
load_more_button.pressed.connect(_load_more)
|
||
history_content.add_child(load_more_button)
|
||
|
||
abandon_dialog = ConfirmationDialog.new()
|
||
abandon_dialog.title = "Abandon simulation?"
|
||
abandon_dialog.dialog_text = "This attempt cannot be resumed and will not receive a predicted score. Completed blocks will remain visible in your history."
|
||
abandon_dialog.ok_button_text = "Abandon Simulation"
|
||
abandon_dialog.cancel_button_text = "Keep Simulation"
|
||
abandon_dialog.confirmed.connect(_confirm_abandon)
|
||
add_child(abandon_dialog)
|
||
refresh_theme_controls()
|
||
|
||
|
||
func _connect_services() -> void:
|
||
if not ExamSimulationService.history_changed.is_connected(_render_history):
|
||
ExamSimulationService.history_changed.connect(_render_history)
|
||
if not ExamSimulationService.readiness_changed.is_connected(_render_active_section):
|
||
ExamSimulationService.readiness_changed.connect(_render_active_section)
|
||
if not ExamSimulationService.state_changed.is_connected(_on_simulation_loaded):
|
||
ExamSimulationService.state_changed.connect(_on_simulation_loaded)
|
||
if not ExamSimulationService.request_failed.is_connected(_on_request_failed):
|
||
ExamSimulationService.request_failed.connect(_on_request_failed)
|
||
if not ExamSimulationService.exam_token_required.is_connected(_on_exam_token_required):
|
||
ExamSimulationService.exam_token_required.connect(_on_exam_token_required)
|
||
|
||
|
||
func on_show(_payload: Variant = null) -> void:
|
||
pending_simulation_id = ""
|
||
pending_launch = false
|
||
pending_abandon = false
|
||
status_label.text = "Loading simulation history..."
|
||
load_more_button.disabled = true
|
||
ExamSimulationService.load_readiness()
|
||
ExamSimulationService.load_active()
|
||
_load_history_page()
|
||
|
||
|
||
func refresh_theme_controls() -> void:
|
||
if page == null:
|
||
return
|
||
var dark := UIThemeService.is_dark_mode()
|
||
active_section.add_theme_stylebox_override("panel", _section_box(dark))
|
||
history_section.add_theme_stylebox_override("panel", _section_box(dark))
|
||
status_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||
_style_option(filter_option, dark)
|
||
_style_option(sort_option, dark)
|
||
_style_outline_button(load_more_button, Brand.PURPLE, dark)
|
||
_render_active_section()
|
||
_render_history()
|
||
|
||
|
||
func _render_active_section() -> void:
|
||
if active_content == null:
|
||
return
|
||
_clear(active_content)
|
||
var dark := UIThemeService.is_dark_mode()
|
||
var section_title := Label.new()
|
||
section_title.text = "Active Simulation" if _has_active_simulation() else "Step 3 Simulation"
|
||
Brand.apply_font(section_title)
|
||
section_title.add_theme_font_size_override("font_size", 18)
|
||
section_title.add_theme_color_override("font_color", Brand.PURPLE if not dark else Color("9b73ff"))
|
||
active_content.add_child(section_title)
|
||
|
||
var card := PanelContainer.new()
|
||
card.add_theme_stylebox_override("panel", _active_card_box(dark))
|
||
active_content.add_child(card)
|
||
var card_layout := BoxContainer.new()
|
||
card_layout.name = "ActiveCardLayout"
|
||
card_layout.vertical = compact_layout
|
||
card_layout.add_theme_constant_override("separation", 18)
|
||
card.add_child(card_layout)
|
||
|
||
var icon_panel := PanelContainer.new()
|
||
icon_panel.custom_minimum_size = Vector2(94, 94)
|
||
icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||
icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.PURPLE, 8))
|
||
card_layout.add_child(icon_panel)
|
||
var clock := TextureRect.new()
|
||
clock.texture = CLOCK_ICON
|
||
clock.custom_minimum_size = Vector2(44, 44)
|
||
clock.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||
clock.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||
clock.modulate = Brand.PURPLE if not dark else Color("9668ff")
|
||
icon_panel.add_child(clock)
|
||
|
||
var details := VBoxContainer.new()
|
||
details.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
details.add_theme_constant_override("separation", 5)
|
||
card_layout.add_child(details)
|
||
var active := _has_active_simulation()
|
||
var heading := Label.new()
|
||
heading.text = "Step 3 Day 1 Simulation"
|
||
Brand.apply_font(heading)
|
||
heading.add_theme_font_size_override("font_size", 19)
|
||
heading.add_theme_color_override("font_color", Brand.text_color(dark))
|
||
details.add_child(heading)
|
||
|
||
if active:
|
||
_build_active_details(details, dark)
|
||
else:
|
||
_build_start_details(details, dark)
|
||
|
||
var actions := VBoxContainer.new()
|
||
actions.name = "ActiveActions"
|
||
actions.custom_minimum_size = Vector2(265, 0) if not compact_layout else Vector2.ZERO
|
||
actions.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||
actions.add_theme_constant_override("separation", 10)
|
||
card_layout.add_child(actions)
|
||
var primary := Button.new()
|
||
primary.name = "PrimarySimulationAction"
|
||
primary.text = "Resume Simulation" if active else "Start Simulation"
|
||
primary.custom_minimum_size = Vector2(0, 48)
|
||
primary.disabled = pending_launch or (not active and not bool(ExamSimulationService.readiness.get("ready", false)))
|
||
_style_gradient_button(primary)
|
||
primary.pressed.connect(_launch_or_resume)
|
||
actions.add_child(primary)
|
||
if active:
|
||
var abandon := Button.new()
|
||
abandon.name = "AbandonSimulation"
|
||
abandon.text = "Abandon Simulation"
|
||
abandon.custom_minimum_size = Vector2(0, 46)
|
||
_style_outline_button(abandon, Color("ff5f76"), dark)
|
||
abandon.pressed.connect(func(): abandon_dialog.popup_centered(Vector2i(520, 220)))
|
||
actions.add_child(abandon)
|
||
|
||
var notice := PanelContainer.new()
|
||
notice.add_theme_stylebox_override("panel", _notice_box(dark))
|
||
active_content.add_child(notice)
|
||
var notice_label := Label.new()
|
||
notice_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
notice_label.text = "Abandoning will end your active simulation; completed blocks remain in history, but no score will be generated." if active else _readiness_message()
|
||
Brand.apply_font(notice_label)
|
||
notice_label.add_theme_font_size_override("font_size", 14)
|
||
notice_label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||
notice.add_child(notice_label)
|
||
|
||
|
||
func _build_active_details(details: VBoxContainer, dark: bool) -> void:
|
||
var simulation := ExamSimulationService.simulation
|
||
var summary := _active_history_summary()
|
||
var started := str(summary.get("createdUtc", ""))
|
||
var started_label := Label.new()
|
||
started_label.text = "Started: %s" % _format_local_datetime(started)
|
||
_style_muted_label(started_label, dark, 14)
|
||
details.add_child(started_label)
|
||
|
||
var progress_title := Label.new()
|
||
progress_title.text = "Progress"
|
||
_style_muted_label(progress_title, dark, 13)
|
||
details.add_child(progress_title)
|
||
var completed := completed_blocks(simulation if not simulation.is_empty() else summary)
|
||
var total := int((simulation if not simulation.is_empty() else summary).get("totalBlocks", 12))
|
||
var progress_row := HBoxContainer.new()
|
||
progress_row.add_theme_constant_override("separation", 12)
|
||
details.add_child(progress_row)
|
||
var bar := ProgressBar.new()
|
||
bar.show_percentage = false
|
||
bar.max_value = max(1, total)
|
||
bar.value = completed
|
||
bar.custom_minimum_size = Vector2(120, 8)
|
||
bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
bar.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||
bar.add_theme_stylebox_override("background", _progress_box(Color("28344f") if dark else Color("e2e8f0")))
|
||
bar.add_theme_stylebox_override("fill", _progress_box(Brand.PURPLE))
|
||
progress_row.add_child(bar)
|
||
var percent := Label.new()
|
||
percent.text = "%d%%" % progress_percent(completed, total)
|
||
_style_muted_label(percent, dark, 13)
|
||
progress_row.add_child(percent)
|
||
var progress_copy := Label.new()
|
||
progress_copy.text = "%d of %d blocks complete • %s" % [completed, total, remaining_time_text(simulation if not simulation.is_empty() else summary)]
|
||
_style_muted_label(progress_copy, dark, 14)
|
||
details.add_child(progress_copy)
|
||
|
||
|
||
func _build_start_details(details: VBoxContainer, dark: bool) -> void:
|
||
var copy := Label.new()
|
||
copy.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
copy.text = "A full-length, 12-block simulation with a provisional predicted Step 3 score. Progress is saved between blocks."
|
||
_style_muted_label(copy, dark, 15)
|
||
details.add_child(copy)
|
||
|
||
|
||
func _render_history() -> void:
|
||
if history_list == null or not visible:
|
||
return
|
||
_clear(history_list)
|
||
var items: Array = ExamSimulationService.history
|
||
if items.is_empty():
|
||
status_label.text = "No simulations match this filter."
|
||
else:
|
||
status_label.text = ""
|
||
for raw_summary in items:
|
||
var summary: Dictionary = raw_summary
|
||
if str(summary.get("state", "")) in ["block_active", "between_blocks"]:
|
||
continue
|
||
history_list.add_child(_build_history_row(summary))
|
||
load_more_button.visible = not ExamSimulationService.history_next_cursor.is_empty()
|
||
load_more_button.disabled = false
|
||
table_header.visible = not compact_layout and not items.is_empty()
|
||
|
||
|
||
func _build_table_header() -> HBoxContainer:
|
||
var dark := UIThemeService.is_dark_mode()
|
||
var row := HBoxContainer.new()
|
||
row.add_theme_constant_override("separation", 10)
|
||
for definition in [["Simulation", 2.2], ["Date", 1.15], ["Status", 1.0], ["Blocks", 0.8], ["Score", 1.15], ["Pass chance", 1.0], ["Actions", 1.0]]:
|
||
var label := Label.new()
|
||
label.text = definition[0]
|
||
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
label.size_flags_stretch_ratio = definition[1]
|
||
Brand.apply_font(label)
|
||
label.add_theme_font_size_override("font_size", 13)
|
||
label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||
row.add_child(label)
|
||
return row
|
||
|
||
|
||
func _build_history_row(summary: Dictionary) -> Control:
|
||
var dark := UIThemeService.is_dark_mode()
|
||
var panel := PanelContainer.new()
|
||
panel.add_theme_stylebox_override("panel", _row_box(dark))
|
||
var state := str(summary.get("state", ""))
|
||
if compact_layout:
|
||
var card := VBoxContainer.new()
|
||
card.add_theme_constant_override("separation", 8)
|
||
panel.add_child(card)
|
||
var top := HBoxContainer.new()
|
||
card.add_child(top)
|
||
top.add_child(_simulation_cell(summary, dark))
|
||
var badge := _status_badge(state, dark)
|
||
top.add_child(badge)
|
||
var metrics := Label.new()
|
||
metrics.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
metrics.text = "%s • %d/%d blocks\n%s • %s" % [_format_local_datetime(str(summary.get("createdUtc", ""))), completed_blocks(summary), int(summary.get("totalBlocks", 12)), _score_text(summary), _pass_chance_text(summary)]
|
||
_style_muted_label(metrics, dark, 14)
|
||
card.add_child(metrics)
|
||
if state == "completed":
|
||
card.add_child(_review_button(summary, dark))
|
||
return panel
|
||
|
||
var row := HBoxContainer.new()
|
||
row.custom_minimum_size = Vector2(0, 66)
|
||
row.add_theme_constant_override("separation", 10)
|
||
panel.add_child(row)
|
||
_add_table_cell(row, _simulation_cell(summary, dark), 2.2)
|
||
_add_table_cell(row, _plain_cell(_format_local_datetime(str(summary.get("createdUtc", ""))), dark), 1.15)
|
||
_add_table_cell(row, _status_badge(state, dark), 1.0)
|
||
_add_table_cell(row, _plain_cell("%d / %d" % [completed_blocks(summary), int(summary.get("totalBlocks", 12))], dark), 0.8)
|
||
_add_table_cell(row, _plain_cell(_score_text(summary), dark), 1.15)
|
||
_add_table_cell(row, _plain_cell(_pass_chance_text(summary), dark), 1.0)
|
||
var action: Control
|
||
if state == "completed":
|
||
action = _review_button(summary, dark)
|
||
else:
|
||
action = _plain_cell("-", dark)
|
||
_add_table_cell(row, action, 1.0)
|
||
return panel
|
||
|
||
|
||
func _simulation_cell(summary: Dictionary, dark: bool) -> Control:
|
||
var column := VBoxContainer.new()
|
||
column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
var name_label := Label.new()
|
||
name_label.text = "Step 3 Day 1 Simulation%s" % (" (Sample)" if bool(summary.get("isSample", false)) else "")
|
||
Brand.apply_font(name_label)
|
||
name_label.add_theme_font_size_override("font_size", 15)
|
||
name_label.add_theme_color_override("font_color", Brand.text_color(dark))
|
||
column.add_child(name_label)
|
||
var blocks := Label.new()
|
||
blocks.text = "%d blocks" % int(summary.get("totalBlocks", 12))
|
||
_style_muted_label(blocks, dark, 12)
|
||
column.add_child(blocks)
|
||
return column
|
||
|
||
|
||
func _plain_cell(text_value: String, dark: bool) -> Label:
|
||
var label := Label.new()
|
||
label.text = text_value
|
||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||
Brand.apply_font(label)
|
||
label.add_theme_font_size_override("font_size", 14)
|
||
label.add_theme_color_override("font_color", Brand.text_color(dark))
|
||
return label
|
||
|
||
|
||
func _status_badge(state: String, dark: bool) -> Label:
|
||
var label := Label.new()
|
||
var accent := Brand.SUCCESS if state == "completed" else Brand.SLATE
|
||
label.text = "Completed" if state == "completed" else "Abandoned"
|
||
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||
label.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||
Brand.apply_font(label)
|
||
label.add_theme_font_size_override("font_size", 12)
|
||
label.add_theme_color_override("font_color", accent if dark else accent.darkened(0.2))
|
||
label.add_theme_stylebox_override("normal", Brand.badge_box(dark, accent))
|
||
return label
|
||
|
||
|
||
func _review_button(summary: Dictionary, dark: bool) -> Button:
|
||
var button := Button.new()
|
||
button.text = "Review"
|
||
button.icon = REVIEW_ICON
|
||
button.expand_icon = true
|
||
button.add_theme_constant_override("icon_max_width", 17)
|
||
button.custom_minimum_size = Vector2(105, 38)
|
||
button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||
_style_outline_button(button, Brand.PURPLE, dark)
|
||
button.pressed.connect(_open_simulation.bind(str(summary.get("id", ""))))
|
||
return button
|
||
|
||
|
||
func _add_table_cell(row: HBoxContainer, control: Control, ratio: float) -> void:
|
||
control.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||
control.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||
control.size_flags_stretch_ratio = ratio
|
||
row.add_child(control)
|
||
|
||
|
||
func _open_simulation(simulation_id: String) -> void:
|
||
pending_launch = false
|
||
pending_simulation_id = simulation_id
|
||
status_label.text = "Opening simulation results..."
|
||
ExamSimulationService.load_simulation(simulation_id)
|
||
|
||
|
||
func _on_simulation_loaded() -> void:
|
||
if not visible:
|
||
return
|
||
if pending_abandon:
|
||
if ExamSimulationService.simulation.is_empty():
|
||
pending_abandon = false
|
||
ExamSimulationService.load_readiness()
|
||
ExamSimulationService.load_active()
|
||
_load_history_page()
|
||
_render_active_section()
|
||
return
|
||
if pending_launch:
|
||
pending_launch = false
|
||
_route_loaded_simulation()
|
||
return
|
||
if not pending_simulation_id.is_empty():
|
||
pending_simulation_id = ""
|
||
_route_loaded_simulation()
|
||
return
|
||
_render_active_section()
|
||
|
||
|
||
func _route_loaded_simulation() -> void:
|
||
if ExamSimulationService.simulation.is_empty():
|
||
_on_request_failed("The simulation is no longer available.")
|
||
return
|
||
var state := str(ExamSimulationService.simulation.get("state", ""))
|
||
if state == "completed":
|
||
main.show_screen("simulation_results", {"simulation": ExamSimulationService.simulation, "return_screen": "simulation_history"})
|
||
elif state == "block_active":
|
||
BlockSessionService.start_simulation_block(
|
||
ExamSimulationService.get_current_questions(),
|
||
int(ExamSimulationService.simulation.get("currentBlock", 0)),
|
||
int(ExamSimulationService.simulation.get("totalBlocks", 12)),
|
||
ExamSimulationService.get_block_deadline_unix())
|
||
BlockSessionService.restore_simulation_responses(ExamSimulationService.simulation.get("responses", []))
|
||
main.show_screen("player")
|
||
elif state == "between_blocks":
|
||
main.show_screen("simulation_break")
|
||
else:
|
||
_on_request_failed("This simulation can no longer be resumed.")
|
||
|
||
|
||
func _launch_or_resume() -> void:
|
||
pending_launch = true
|
||
pending_simulation_id = ""
|
||
_render_active_section()
|
||
if _has_active_simulation():
|
||
if not ExamSimulationService.simulation.is_empty():
|
||
pending_launch = false
|
||
_route_loaded_simulation()
|
||
else:
|
||
ExamSimulationService.load_active()
|
||
else:
|
||
ExamSimulationService.create_simulation()
|
||
|
||
|
||
func _confirm_abandon() -> void:
|
||
if not _has_active_simulation() or ExamSimulationService.simulation.is_empty():
|
||
return
|
||
pending_abandon = true
|
||
ExamSimulationService.abandon()
|
||
_render_active_section()
|
||
|
||
|
||
func _on_filter_selected(index: int) -> void:
|
||
history_status = str(filter_option.get_item_metadata(index))
|
||
_load_history_page()
|
||
|
||
|
||
func _on_sort_selected(index: int) -> void:
|
||
history_sort = str(sort_option.get_item_metadata(index))
|
||
_load_history_page()
|
||
|
||
|
||
func _load_history_page() -> void:
|
||
status_label.text = "Loading simulation history..."
|
||
load_more_button.disabled = true
|
||
ExamSimulationService.load_history("", history_status, history_sort)
|
||
|
||
|
||
func _load_more() -> void:
|
||
if ExamSimulationService.history_next_cursor.is_empty():
|
||
return
|
||
load_more_button.disabled = true
|
||
ExamSimulationService.load_history(ExamSimulationService.history_next_cursor, history_status, history_sort)
|
||
|
||
|
||
func _on_request_failed(message: String) -> void:
|
||
if not visible:
|
||
return
|
||
pending_launch = false
|
||
pending_simulation_id = ""
|
||
pending_abandon = false
|
||
status_label.text = message
|
||
load_more_button.disabled = false
|
||
_render_active_section()
|
||
|
||
|
||
func _on_exam_token_required() -> void:
|
||
pending_launch = false
|
||
main.show_screen("rz_store")
|
||
|
||
|
||
func _update_responsive_layout() -> void:
|
||
var use_compact := size.x < 940
|
||
if compact_layout == use_compact:
|
||
return
|
||
compact_layout = use_compact
|
||
history_header.vertical = compact_layout
|
||
filter_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL if compact_layout else Control.SIZE_SHRINK_END
|
||
sort_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL if compact_layout else Control.SIZE_SHRINK_END
|
||
_render_active_section()
|
||
_render_history()
|
||
|
||
|
||
func _has_active_simulation() -> bool:
|
||
return has_active_simulation(ExamSimulationService.readiness, ExamSimulationService.simulation)
|
||
|
||
|
||
func _active_history_summary() -> Dictionary:
|
||
for raw_summary in ExamSimulationService.history:
|
||
var summary: Dictionary = raw_summary
|
||
if str(summary.get("state", "")) in ["block_active", "between_blocks"]:
|
||
return summary
|
||
return ExamSimulationService.simulation
|
||
|
||
|
||
func _readiness_message() -> String:
|
||
if bool(ExamSimulationService.readiness.get("ready", false)):
|
||
return "The question bank is ready. Start when you have enough uninterrupted time for a full simulation."
|
||
var available := int(ExamSimulationService.readiness.get("availableQuestions", 0))
|
||
var required := int(ExamSimulationService.readiness.get("requiredQuestions", 232))
|
||
return "Simulation unlocks when %d published questions are available. Currently %d/%d." % [required, available, required]
|
||
|
||
|
||
func _score_text(summary: Dictionary) -> String:
|
||
if summary.get("predictedScore", null) == null:
|
||
return "—"
|
||
return "%d (%d–%d)" % [int(summary.get("predictedScore", 0)), int(summary.get("rangeLow", 0)), int(summary.get("rangeHigh", 0))]
|
||
|
||
|
||
func _pass_chance_text(summary: Dictionary) -> String:
|
||
if summary.get("passProbability", null) == null:
|
||
return "—"
|
||
return "%d%%" % int(round(float(summary.get("passProbability", 0.0)) * 100.0))
|
||
|
||
|
||
func _format_local_datetime(value: String) -> String:
|
||
if value.is_empty():
|
||
return "—"
|
||
var unix := Time.get_unix_time_from_datetime_string(value)
|
||
var timezone: Dictionary = Time.get_time_zone_from_system()
|
||
var local := Time.get_datetime_dict_from_unix_time(int(unix) + int(timezone.get("bias", 0)) * 60)
|
||
return format_datetime_parts(local)
|
||
|
||
|
||
static func format_datetime_parts(parts: Dictionary) -> String:
|
||
const MONTHS := ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
||
var month_index: int = clamp(int(parts.get("month", 1)) - 1, 0, 11)
|
||
var hour_24 := int(parts.get("hour", 0))
|
||
var suffix := "AM" if hour_24 < 12 else "PM"
|
||
var hour_12 := hour_24 % 12
|
||
if hour_12 == 0:
|
||
hour_12 = 12
|
||
return "%s %d, %d\n%d:%02d %s" % [MONTHS[month_index], int(parts.get("day", 1)), int(parts.get("year", 1970)), hour_12, int(parts.get("minute", 0)), suffix]
|
||
|
||
|
||
static func completed_blocks(summary: Dictionary) -> int:
|
||
var state := str(summary.get("state", ""))
|
||
var current := int(summary.get("currentBlock", -1))
|
||
var total: int = max(0, int(summary.get("totalBlocks", 12)))
|
||
if state == "completed":
|
||
return total
|
||
if state == "between_blocks":
|
||
return clamp(current + 1, 0, total)
|
||
if state == "block_active":
|
||
return clamp(current, 0, total)
|
||
return clamp(current + 1, 0, total)
|
||
|
||
|
||
static func progress_percent(completed: int, total: int) -> int:
|
||
if total <= 0:
|
||
return 0
|
||
return int(round(float(completed) * 100.0 / float(total)))
|
||
|
||
|
||
static func remaining_time_text(summary: Dictionary, now_unix: float = -1.0) -> String:
|
||
var total: int = max(0, int(summary.get("totalBlocks", 12)))
|
||
var current := int(summary.get("currentBlock", -1))
|
||
var state := str(summary.get("state", ""))
|
||
var seconds := 0
|
||
if state == "block_active":
|
||
var future_blocks: int = max(0, total - current - 1)
|
||
seconds = future_blocks * 1800
|
||
var deadline := str(summary.get("blockDeadlineUtc", ""))
|
||
if not deadline.is_empty():
|
||
var current_unix := Time.get_unix_time_from_system() if now_unix < 0.0 else now_unix
|
||
seconds += max(0, int(Time.get_unix_time_from_datetime_string(deadline) - current_unix))
|
||
else:
|
||
seconds += 1800
|
||
else:
|
||
seconds = max(0, total - completed_blocks(summary)) * 1800
|
||
var hours := int(floor(float(seconds) / 3600.0))
|
||
var minutes := int(floor(float(seconds % 3600) / 60.0))
|
||
if hours > 0 and minutes > 0:
|
||
return "~%dh %dm remaining" % [hours, minutes]
|
||
if hours > 0:
|
||
return "~%dh remaining" % hours
|
||
return "~%dm remaining" % minutes
|
||
|
||
|
||
static func has_active_simulation(readiness: Dictionary, simulation: Dictionary) -> bool:
|
||
if bool(readiness.get("hasActiveSimulation", false)):
|
||
return true
|
||
if simulation.is_empty():
|
||
return false
|
||
return str(simulation.get("state", "")) in ["between_blocks", "block_active"]
|
||
|
||
|
||
static func _action_for_state(state: String) -> String:
|
||
if state == "completed":
|
||
return "results"
|
||
if state == "block_active" or state == "between_blocks":
|
||
return "resume"
|
||
return "none"
|
||
|
||
|
||
func _style_muted_label(label: Label, dark: bool, font_size: int) -> void:
|
||
Brand.apply_font(label)
|
||
label.add_theme_font_size_override("font_size", font_size)
|
||
label.add_theme_color_override("font_color", Brand.muted_color(dark))
|
||
|
||
|
||
func _style_option(option: OptionButton, dark: bool) -> void:
|
||
Brand.apply_font(option)
|
||
option.add_theme_font_size_override("font_size", 14)
|
||
option.add_theme_color_override("font_color", Brand.text_color(dark))
|
||
for state in ["normal", "hover", "pressed"]:
|
||
option.add_theme_stylebox_override(state, Brand.secondary_button_box(dark, state != "normal"))
|
||
|
||
|
||
func _style_gradient_button(button: Button) -> void:
|
||
Brand.apply_font(button)
|
||
button.add_theme_font_size_override("font_size", 15)
|
||
button.add_theme_stylebox_override("normal", Brand.gradient_button_box())
|
||
button.add_theme_stylebox_override("hover", Brand.gradient_button_box())
|
||
button.add_theme_stylebox_override("pressed", Brand.gradient_button_box())
|
||
button.add_theme_color_override("font_color", Color.WHITE)
|
||
button.add_theme_color_override("font_hover_color", Color.WHITE)
|
||
|
||
|
||
func _style_outline_button(button: Button, accent: Color, dark: bool) -> void:
|
||
Brand.apply_font(button)
|
||
button.add_theme_font_size_override("font_size", 14)
|
||
var normal := Style.button_box(Color(0, 0, 0, 0), accent)
|
||
var hover := Style.button_box(Color(accent, 0.12), accent)
|
||
button.add_theme_stylebox_override("normal", normal)
|
||
button.add_theme_stylebox_override("hover", hover)
|
||
button.add_theme_stylebox_override("pressed", hover)
|
||
button.add_theme_color_override("font_color", accent if dark else accent.darkened(0.08))
|
||
button.add_theme_color_override("font_hover_color", accent)
|
||
button.add_theme_color_override("icon_normal_color", accent)
|
||
|
||
|
||
func _section_box(dark: bool) -> StyleBoxFlat:
|
||
var box := Brand.content_card_box(dark, false)
|
||
box.content_margin_left = 16
|
||
box.content_margin_right = 16
|
||
box.content_margin_top = 14
|
||
box.content_margin_bottom = 14
|
||
return box
|
||
|
||
|
||
func _active_card_box(dark: bool) -> StyleBoxFlat:
|
||
var box := Brand.content_card_box(dark, true, Color("7455ff"))
|
||
box.bg_color = Color("101a38") if dark else Color("f8f7ff")
|
||
box.border_color = Color("3d326f") if dark else Color("d9d1ff")
|
||
box.content_margin_left = 16
|
||
box.content_margin_right = 16
|
||
box.content_margin_top = 16
|
||
box.content_margin_bottom = 16
|
||
return box
|
||
|
||
|
||
func _notice_box(dark: bool) -> StyleBoxFlat:
|
||
var box := StyleBoxFlat.new()
|
||
box.bg_color = Color("101a32") if dark else Color("f5f7ff")
|
||
box.border_color = Color("29365d") if dark else Color("dbe3f4")
|
||
box.set_border_width_all(1)
|
||
box.set_corner_radius_all(7)
|
||
box.content_margin_left = 12
|
||
box.content_margin_right = 12
|
||
box.content_margin_top = 9
|
||
box.content_margin_bottom = 9
|
||
return box
|
||
|
||
|
||
func _row_box(dark: bool) -> StyleBoxFlat:
|
||
var box := StyleBoxFlat.new()
|
||
box.bg_color = Color("0c162b") if dark else Color("ffffff")
|
||
box.border_color = Color("273450") if dark else Color("e3e8f1")
|
||
box.border_width_top = 1
|
||
box.content_margin_left = 12
|
||
box.content_margin_right = 12
|
||
box.content_margin_top = 10
|
||
box.content_margin_bottom = 10
|
||
return box
|
||
|
||
|
||
func _progress_box(color: Color) -> StyleBoxFlat:
|
||
var box := StyleBoxFlat.new()
|
||
box.bg_color = color
|
||
box.set_corner_radius_all(4)
|
||
return box
|
||
|
||
|
||
func _clear(container: Node) -> void:
|
||
for child in container.get_children():
|
||
container.remove_child(child)
|
||
child.queue_free()
|