570 lines
23 KiB
GDScript
570 lines
23 KiB
GDScript
extends Control
|
|
|
|
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
|
const TrendChart := preload("res://scripts/ui/dashboard_chart.gd")
|
|
const ActivityChart := preload("res://scripts/ui/dashboard_activity_chart.gd")
|
|
|
|
var main: Control
|
|
var scroll: ScrollContainer
|
|
var content: VBoxContainer
|
|
var status_label: Label
|
|
var metric_grid: GridContainer
|
|
var analytics_grid: GridContainer
|
|
var lower_grid: GridContainer
|
|
var trend_chart: Control
|
|
var rating_toggle: Button
|
|
var accuracy_toggle: Button
|
|
var activity_chart: Control
|
|
var system_list: VBoxContainer
|
|
var focus_content: VBoxContainer
|
|
var review_list: VBoxContainer
|
|
var recent_list: VBoxContainer
|
|
var weekly_summary: VBoxContainer
|
|
var metric_values: Dictionary = {}
|
|
var metric_details: Dictionary = {}
|
|
var pending_review_launch := false
|
|
var review_queue_service: Variant
|
|
var trend_mode := "rating"
|
|
|
|
|
|
func _ready() -> void:
|
|
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
review_queue_service = get_node("/root/ReviewQueueService")
|
|
DashboardService.dashboard_changed.connect(_render)
|
|
DashboardService.dashboard_failed.connect(_show_error)
|
|
review_queue_service.question_loaded.connect(_on_review_question_loaded)
|
|
review_queue_service.question_failed.connect(_on_review_question_failed)
|
|
review_queue_service.pass_completed.connect(_on_review_pass_completed)
|
|
UIThemeService.theme_changed.connect(_rebuild)
|
|
resized.connect(_update_responsive)
|
|
_build()
|
|
|
|
|
|
func on_show(_payload: Variant = null) -> void:
|
|
if DashboardService.data.is_empty():
|
|
_show_loading()
|
|
else:
|
|
_render()
|
|
DashboardService.refresh()
|
|
|
|
|
|
func _build() -> void:
|
|
for child in get_children():
|
|
remove_child(child)
|
|
child.free()
|
|
metric_values.clear()
|
|
metric_details.clear()
|
|
scroll = ScrollContainer.new()
|
|
scroll.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
|
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
|
add_child(scroll)
|
|
content = VBoxContainer.new()
|
|
content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
content.add_theme_constant_override("separation", 14)
|
|
scroll.add_child(content)
|
|
|
|
status_label = _label("", 14, Brand.muted_color(UIThemeService.is_dark_mode()))
|
|
status_label.visible = false
|
|
content.add_child(status_label)
|
|
metric_grid = GridContainer.new()
|
|
metric_grid.columns = 4
|
|
metric_grid.add_theme_constant_override("h_separation", 0)
|
|
metric_grid.add_theme_constant_override("v_separation", 8)
|
|
content.add_child(_card(metric_grid, 12))
|
|
_add_metric("qbank", "Question Bank Rating", "chart-no-axes-column-increasing", Brand.ROYAL_BLUE)
|
|
_add_metric("ccs", "CCS Rating", "stethoscope", Brand.PURPLE)
|
|
_add_metric("accuracy", "First-Attempt Accuracy", "activity", Brand.ROYAL_BLUE)
|
|
_add_metric("time", "Study Time", "clock-3", Brand.PURPLE)
|
|
|
|
analytics_grid = GridContainer.new()
|
|
analytics_grid.columns = 2
|
|
analytics_grid.add_theme_constant_override("h_separation", 12)
|
|
analytics_grid.add_theme_constant_override("v_separation", 12)
|
|
content.add_child(analytics_grid)
|
|
var performance := VBoxContainer.new()
|
|
performance.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
performance.add_theme_constant_override("separation", 10)
|
|
var trend_header := HBoxContainer.new()
|
|
trend_header.add_child(_section_title("Performance trend"))
|
|
var trend_space := Control.new()
|
|
trend_space.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
trend_header.add_child(trend_space)
|
|
var trend_group := ButtonGroup.new()
|
|
trend_group.allow_unpress = false
|
|
rating_toggle = _trend_toggle("Rating", "rating", trend_group)
|
|
accuracy_toggle = _trend_toggle("Correct %", "accuracy", trend_group)
|
|
trend_header.add_child(rating_toggle)
|
|
trend_header.add_child(accuracy_toggle)
|
|
performance.add_child(trend_header)
|
|
trend_chart = TrendChart.new()
|
|
trend_chart.custom_minimum_size = Vector2(520, 190)
|
|
trend_chart.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
performance.add_child(trend_chart)
|
|
performance.add_child(HSeparator.new())
|
|
var system_header := HBoxContainer.new()
|
|
system_header.add_child(_section_title("Performance by system"))
|
|
var header_space := Control.new()
|
|
header_space.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
system_header.add_child(header_space)
|
|
var breakdown := Button.new()
|
|
breakdown.text = "View full breakdown"
|
|
breakdown.flat = true
|
|
breakdown.pressed.connect(func(): main.show_screen("profile"))
|
|
system_header.add_child(breakdown)
|
|
performance.add_child(system_header)
|
|
system_list = VBoxContainer.new()
|
|
system_list.add_theme_constant_override("separation", 8)
|
|
performance.add_child(system_list)
|
|
analytics_grid.add_child(_card(performance, 18))
|
|
|
|
var right_column := VBoxContainer.new()
|
|
right_column.add_theme_constant_override("separation", 12)
|
|
right_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
analytics_grid.add_child(right_column)
|
|
focus_content = VBoxContainer.new()
|
|
focus_content.add_theme_constant_override("separation", 10)
|
|
right_column.add_child(_card(focus_content, 16))
|
|
review_list = VBoxContainer.new()
|
|
review_list.add_theme_constant_override("separation", 7)
|
|
right_column.add_child(_card(review_list, 16))
|
|
|
|
lower_grid = GridContainer.new()
|
|
lower_grid.columns = 2
|
|
lower_grid.add_theme_constant_override("h_separation", 12)
|
|
lower_grid.add_theme_constant_override("v_separation", 12)
|
|
content.add_child(lower_grid)
|
|
var weekly := VBoxContainer.new()
|
|
weekly.add_theme_constant_override("separation", 8)
|
|
weekly.add_child(_section_title("Weekly activity"))
|
|
var weekly_row := HBoxContainer.new()
|
|
weekly_row.add_theme_constant_override("separation", 18)
|
|
weekly.add_child(weekly_row)
|
|
activity_chart = ActivityChart.new()
|
|
activity_chart.custom_minimum_size = Vector2(360, 150)
|
|
activity_chart.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
weekly_row.add_child(activity_chart)
|
|
weekly_summary = VBoxContainer.new()
|
|
weekly_summary.custom_minimum_size.x = 180
|
|
weekly_summary.add_theme_constant_override("separation", 10)
|
|
weekly_row.add_child(weekly_summary)
|
|
lower_grid.add_child(_card(weekly, 16))
|
|
recent_list = VBoxContainer.new()
|
|
recent_list.add_theme_constant_override("separation", 6)
|
|
lower_grid.add_child(_card(recent_list, 16))
|
|
_update_responsive()
|
|
if not DashboardService.data.is_empty():
|
|
_render()
|
|
|
|
|
|
func _rebuild(_is_dark: bool = UIThemeService.is_dark_mode()) -> void:
|
|
_build()
|
|
|
|
|
|
func _render() -> void:
|
|
var data: Dictionary = DashboardService.data
|
|
status_label.visible = false
|
|
_set_metric("qbank", str(int(data.get("questionBankRating", 1000))), "%d rated questions" % int(data.get("ratedQuestionCount", 0)))
|
|
_set_metric("ccs", str(int(data.get("ccsRating", 1000))), "%d rated cases" % int(data.get("ratedCcsCaseCount", 0)))
|
|
_set_metric("accuracy", "%d%%" % int(data.get("firstAttemptAccuracy", 0)), "Last %d questions" % int(data.get("accuracySampleSize", 0)))
|
|
_set_metric("time", _format_duration(int(data.get("studyTimeSecondsThisWeek", 0))), "This week")
|
|
trend_chart.set_series(data.get("performanceTrend", []), trend_mode, UIThemeService.is_dark_mode())
|
|
_render_systems(data.get("systems", []))
|
|
_render_focus(data.get("systems", []))
|
|
_render_review(data)
|
|
var weekly: Array = data.get("weeklyActivity", [])
|
|
activity_chart.set_days(weekly, UIThemeService.is_dark_mode())
|
|
_render_weekly(data, weekly)
|
|
_render_recent(data.get("recentActivity", []))
|
|
|
|
|
|
func _render_systems(systems_value: Array) -> void:
|
|
_clear(system_list)
|
|
var systems := systems_value.duplicate(true)
|
|
if systems.is_empty():
|
|
system_list.add_child(_label("Answer questions to build your system breakdown.", 14, Brand.muted_color(UIThemeService.is_dark_mode())))
|
|
return
|
|
var attempted: Array = systems.filter(func(item: Dictionary): return int(item.get("total", 0)) > 0)
|
|
var strongest_system := ""
|
|
var weakest_system := ""
|
|
if not attempted.is_empty():
|
|
var strongest: Dictionary = attempted[0]
|
|
var weakest: Dictionary = attempted[0]
|
|
for item in attempted:
|
|
if int(item.get("accuracy", 0)) > int(strongest.get("accuracy", 0)) or (
|
|
int(item.get("accuracy", 0)) == int(strongest.get("accuracy", 0))
|
|
and int(item.get("total", 0)) > int(strongest.get("total", 0))):
|
|
strongest = item
|
|
if int(item.get("accuracy", 0)) < int(weakest.get("accuracy", 0)) or (
|
|
int(item.get("accuracy", 0)) == int(weakest.get("accuracy", 0))
|
|
and int(item.get("total", 0)) > int(weakest.get("total", 0))):
|
|
weakest = item
|
|
strongest_system = str(strongest.get("system", ""))
|
|
weakest_system = str(weakest.get("system", ""))
|
|
for item in systems:
|
|
var row := HBoxContainer.new()
|
|
row.add_theme_constant_override("separation", 10)
|
|
var system_name_label := _label(str(item.get("system", "General")), 13, Brand.text_color(UIThemeService.is_dark_mode()))
|
|
system_name_label.custom_minimum_size.x = 170
|
|
system_name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
system_name_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
row.add_child(system_name_label)
|
|
var bar := ProgressBar.new()
|
|
bar.show_percentage = false
|
|
bar.value = int(item.get("accuracy", 0))
|
|
bar.custom_minimum_size = Vector2(80, 7)
|
|
bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
bar.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
|
_style_progress(bar, Brand.PURPLE if str(item.get("system", "")) == weakest_system else Brand.ROYAL_BLUE)
|
|
row.add_child(bar)
|
|
var total := int(item.get("total", 0))
|
|
var percentage_text := "%d%%" % int(item.get("accuracy", 0)) if total > 0 else "—"
|
|
var percentage := _label(percentage_text, 14, Brand.ROYAL_BLUE, true)
|
|
percentage.custom_minimum_size.x = 48
|
|
row.add_child(percentage)
|
|
var count := _label("%d / %d" % [int(item.get("correct", 0)), total], 12, Brand.muted_color(UIThemeService.is_dark_mode()))
|
|
count.custom_minimum_size.x = 58
|
|
row.add_child(count)
|
|
var system_name := str(item.get("system", ""))
|
|
var marker := "Strongest" if system_name == strongest_system else ("Needs work" if system_name == weakest_system else "")
|
|
var marker_label := _label(marker, 11, Brand.SUCCESS if marker == "Strongest" else Brand.MAGENTA)
|
|
marker_label.custom_minimum_size.x = 72
|
|
row.add_child(marker_label)
|
|
system_list.add_child(row)
|
|
|
|
|
|
func _render_focus(systems: Array) -> void:
|
|
_clear(focus_content)
|
|
focus_content.add_child(_section_title("Study focus"))
|
|
if systems.is_empty():
|
|
focus_content.add_child(_label("Complete a few questions to receive a focused recommendation.", 14, Brand.muted_color(UIThemeService.is_dark_mode())))
|
|
return
|
|
var weakest: Dictionary = systems[0]
|
|
for item in systems:
|
|
if int(item.get("accuracy", 0)) < int(weakest.get("accuracy", 0)):
|
|
weakest = item
|
|
focus_content.add_child(_label("Recommended focus", 12, Brand.muted_color(UIThemeService.is_dark_mode())))
|
|
focus_content.add_child(_label(str(weakest.get("system", "General")), 23, Brand.PURPLE, true))
|
|
focus_content.add_child(_label("%d%% accuracy across %d questions" % [int(weakest.get("accuracy", 0)), int(weakest.get("total", 0))], 13, Brand.text_color(UIThemeService.is_dark_mode())))
|
|
var progress := ProgressBar.new()
|
|
progress.show_percentage = false
|
|
progress.value = int(weakest.get("accuracy", 0))
|
|
progress.custom_minimum_size.y = 8
|
|
_style_progress(progress, Brand.PURPLE)
|
|
focus_content.add_child(progress)
|
|
var practice := _button("Practice this system")
|
|
practice.pressed.connect(func(): main.show_screen("selection", "question_bank"))
|
|
focus_content.add_child(practice)
|
|
|
|
|
|
func _render_review(data: Dictionary) -> void:
|
|
_clear(review_list)
|
|
review_list.add_child(_section_title("Review queue"))
|
|
var missed_count := int(data.get("missedQuestionCount", 0))
|
|
var missed_action := _review_action(
|
|
"%d missed questions" % missed_count if missed_count > 0 else "Review queue clear",
|
|
"x",
|
|
Brand.MAGENTA,
|
|
"")
|
|
missed_action.disabled = missed_count <= 0 or pending_review_launch
|
|
missed_action.pressed.connect(_start_review_queue)
|
|
review_list.add_child(missed_action)
|
|
if Brand.is_uat_environment():
|
|
var dynamic_due := int(data.get("dynamicReviewsDue", 0))
|
|
review_list.add_child(_review_action(
|
|
"%d dynamic concepts due" % dynamic_due if dynamic_due > 0 else "Dynamic concepts up to date",
|
|
"activity", Brand.PURPLE if dynamic_due > 0 else Brand.ROYAL_BLUE, "dynamic_questions"))
|
|
if Brand.is_uat_environment():
|
|
var flashcards_due := int(data.get("flashcardsDue", 0))
|
|
review_list.add_child(_review_action(
|
|
"%d flashcards due" % flashcards_due if flashcards_due > 0 else "Flashcards up to date",
|
|
"clipboard-plus", Brand.TEAL if flashcards_due > 0 else Brand.ROYAL_BLUE, "flashcards"))
|
|
review_list.add_child(_review_action("%d bookmarks" % int(data.get("bookmarkCount", 0)), "bookmark", Brand.ROYAL_BLUE, "bookmarks"))
|
|
review_list.add_child(_review_action("%d notes" % int(data.get("noteCount", 0)), "notebook-pen", Brand.AMBER, "notes"))
|
|
|
|
|
|
func _render_weekly(data: Dictionary, days: Array) -> void:
|
|
_clear(weekly_summary)
|
|
var questions := 0
|
|
var cases := 0
|
|
for day in days:
|
|
questions += int(day.get("questions", 0))
|
|
cases += int(day.get("ccsCases", 0))
|
|
weekly_summary.add_child(_summary_stat(str(questions), "Questions answered", "notebook-pen", Brand.ROYAL_BLUE))
|
|
weekly_summary.add_child(_summary_stat(str(cases), "CCS cases completed", "stethoscope", Brand.PURPLE))
|
|
weekly_summary.add_child(_summary_stat("%d-day" % int(data.get("currentStreak", 0)), "Current streak", "sun", Brand.AMBER))
|
|
|
|
|
|
func _render_recent(items: Array) -> void:
|
|
_clear(recent_list)
|
|
recent_list.add_child(_section_title("Recent activity"))
|
|
if items.is_empty():
|
|
recent_list.add_child(_label("Your completed study activity will appear here.", 14, Brand.muted_color(UIThemeService.is_dark_mode())))
|
|
return
|
|
for item in items:
|
|
var row := HBoxContainer.new()
|
|
row.custom_minimum_size.y = 34
|
|
row.add_theme_constant_override("separation", 9)
|
|
var type := str(item.get("type", "question"))
|
|
var icon_name := "stethoscope" if type == "ccs" else ("clipboard-list" if type == "simulation" else "activity")
|
|
var icon := TextureRect.new()
|
|
icon.texture = load("res://assets/icons/lucide/%s.svg" % icon_name)
|
|
icon.custom_minimum_size = Vector2(20, 20)
|
|
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
|
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
|
row.add_child(icon)
|
|
var title := _label(str(item.get("title", "Study activity")), 13, Brand.text_color(UIThemeService.is_dark_mode()))
|
|
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
title.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
|
row.add_child(title)
|
|
row.add_child(_label(str(item.get("detail", "")), 13, Brand.ROYAL_BLUE, true))
|
|
row.add_child(_label(_relative_time(str(item.get("occurredUtc", ""))), 11, Brand.muted_color(UIThemeService.is_dark_mode())))
|
|
recent_list.add_child(row)
|
|
|
|
|
|
func _add_metric(key: String, title: String, icon_name: String, accent: Color) -> void:
|
|
var cell := HBoxContainer.new()
|
|
cell.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
cell.custom_minimum_size = Vector2(230, 96)
|
|
cell.add_theme_constant_override("separation", 12)
|
|
var icon_panel := PanelContainer.new()
|
|
icon_panel.custom_minimum_size = Vector2(48, 48)
|
|
icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
|
icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(UIThemeService.is_dark_mode(), accent, 24))
|
|
var icon := TextureRect.new()
|
|
icon.texture = load("res://assets/icons/lucide/%s.svg" % icon_name)
|
|
icon.custom_minimum_size = Vector2(22, 22)
|
|
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
|
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
|
icon_panel.add_child(icon)
|
|
cell.add_child(icon_panel)
|
|
var copy := VBoxContainer.new()
|
|
copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
|
copy.add_child(_label(title, 15, Brand.text_color(UIThemeService.is_dark_mode())))
|
|
var value := _label("--", 29, Brand.text_color(UIThemeService.is_dark_mode()), true)
|
|
copy.add_child(value)
|
|
var detail := _label("Loading", 13, Brand.muted_color(UIThemeService.is_dark_mode()))
|
|
copy.add_child(detail)
|
|
metric_values[key] = value
|
|
metric_details[key] = detail
|
|
cell.add_child(copy)
|
|
metric_grid.add_child(cell)
|
|
|
|
|
|
func _set_metric(key: String, value: String, detail: String) -> void:
|
|
(metric_values.get(key) as Label).text = value
|
|
(metric_details.get(key) as Label).text = detail
|
|
|
|
|
|
func _review_action(text: String, icon_name: String, accent: Color, route: String) -> Button:
|
|
var button := _button(text, icon_name, false)
|
|
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
|
button.add_theme_color_override("icon_normal_color", accent)
|
|
if not route.is_empty():
|
|
button.pressed.connect(func(): main._on_navigation_pressed(route))
|
|
return button
|
|
|
|
|
|
func _start_review_queue() -> void:
|
|
if pending_review_launch:
|
|
return
|
|
pending_review_launch = true
|
|
_show_loading_message("Preparing your review queue...")
|
|
review_queue_service.start_pass()
|
|
_render_review(DashboardService.data)
|
|
|
|
|
|
func _on_review_question_loaded(question: Dictionary) -> void:
|
|
if not pending_review_launch or not visible:
|
|
return
|
|
pending_review_launch = false
|
|
if BlockSessionService.start_review_question(question):
|
|
main.show_screen("player")
|
|
else:
|
|
_show_error("The review question could not be prepared.")
|
|
|
|
|
|
func _on_review_question_failed(message: String) -> void:
|
|
if not pending_review_launch or not visible:
|
|
return
|
|
pending_review_launch = false
|
|
_show_error(message)
|
|
_render_review(DashboardService.data)
|
|
|
|
|
|
func _on_review_pass_completed(_summary: Dictionary) -> void:
|
|
if not pending_review_launch or not visible:
|
|
return
|
|
pending_review_launch = false
|
|
DashboardService.refresh()
|
|
_show_loading_message("Your review queue is clear.")
|
|
|
|
|
|
func _show_loading_message(message: String) -> void:
|
|
status_label.text = message
|
|
status_label.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode()))
|
|
status_label.visible = true
|
|
|
|
|
|
func _summary_stat(value: String, caption: String, icon_name: String, accent: Color) -> HBoxContainer:
|
|
var row := HBoxContainer.new()
|
|
row.add_theme_constant_override("separation", 10)
|
|
var icon := TextureRect.new()
|
|
icon.texture = load("res://assets/icons/lucide/%s.svg" % icon_name)
|
|
icon.custom_minimum_size = Vector2(24, 24)
|
|
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
|
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
|
row.add_child(icon)
|
|
var copy := VBoxContainer.new()
|
|
copy.add_child(_label(value, 20, accent, true))
|
|
copy.add_child(_label(caption, 11, Brand.muted_color(UIThemeService.is_dark_mode())))
|
|
row.add_child(copy)
|
|
return row
|
|
|
|
|
|
func _card(child: Control, margin: int) -> PanelContainer:
|
|
var panel := PanelContainer.new()
|
|
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
panel.add_theme_stylebox_override("panel", _card_box(margin))
|
|
panel.add_child(child)
|
|
return panel
|
|
|
|
|
|
func _card_box(margin: int) -> StyleBoxFlat:
|
|
var box := StyleBoxFlat.new()
|
|
var dark := UIThemeService.is_dark_mode()
|
|
box.bg_color = Color("081426") if dark else Color.WHITE
|
|
box.border_color = Color("26344f") if dark else Brand.LIGHT_BORDER
|
|
box.set_border_width_all(1)
|
|
box.set_corner_radius_all(8)
|
|
box.content_margin_left = margin
|
|
box.content_margin_right = margin
|
|
box.content_margin_top = margin
|
|
box.content_margin_bottom = margin
|
|
return box
|
|
|
|
|
|
func _button(text: String, icon_name: String = "", primary: bool = true) -> Button:
|
|
var button := Button.new()
|
|
button.text = text
|
|
button.custom_minimum_size = Vector2(0, 42)
|
|
button.add_theme_font_override("font", Brand.font)
|
|
button.add_theme_font_size_override("font_size", 14)
|
|
if not icon_name.is_empty():
|
|
button.icon = load("res://assets/icons/lucide/%s.svg" % icon_name)
|
|
button.add_theme_constant_override("icon_max_width", 18)
|
|
if primary:
|
|
var normal := StyleBoxFlat.new()
|
|
normal.bg_color = Brand.ROYAL_BLUE
|
|
normal.set_corner_radius_all(6)
|
|
var hover := normal.duplicate() as StyleBoxFlat
|
|
hover.bg_color = Color("3474ee")
|
|
button.add_theme_stylebox_override("normal", normal)
|
|
button.add_theme_stylebox_override("hover", hover)
|
|
button.add_theme_stylebox_override("pressed", normal)
|
|
button.add_theme_color_override("font_color", Color.WHITE)
|
|
button.add_theme_color_override("icon_normal_color", Color.WHITE)
|
|
else:
|
|
button.add_theme_stylebox_override("normal", Brand.secondary_button_box(UIThemeService.is_dark_mode()))
|
|
return button
|
|
|
|
|
|
func _trend_toggle(text: String, mode: String, group: ButtonGroup) -> Button:
|
|
var button := Button.new()
|
|
button.text = text
|
|
button.toggle_mode = true
|
|
button.button_group = group
|
|
button.button_pressed = trend_mode == mode
|
|
button.custom_minimum_size = Vector2(86, 32)
|
|
button.add_theme_font_override("font", Brand.font)
|
|
button.add_theme_font_size_override("font_size", 12)
|
|
var normal := Brand.secondary_button_box(UIThemeService.is_dark_mode())
|
|
var selected := StyleBoxFlat.new()
|
|
selected.bg_color = Brand.ROYAL_BLUE
|
|
selected.set_corner_radius_all(6)
|
|
button.add_theme_stylebox_override("normal", normal)
|
|
button.add_theme_stylebox_override("hover", normal)
|
|
button.add_theme_stylebox_override("pressed", selected)
|
|
button.add_theme_color_override("font_pressed_color", Color.WHITE)
|
|
button.pressed.connect(_set_trend_mode.bind(mode))
|
|
return button
|
|
|
|
|
|
func _set_trend_mode(mode: String) -> void:
|
|
trend_mode = mode
|
|
if trend_chart != null and not DashboardService.data.is_empty():
|
|
trend_chart.set_series(DashboardService.data.get("performanceTrend", []), trend_mode, UIThemeService.is_dark_mode())
|
|
|
|
|
|
func _label(text: String, font_size: int, color: Color, bold: bool = false) -> Label:
|
|
var label := Label.new()
|
|
label.text = text
|
|
label.add_theme_font_override("font", Brand.font)
|
|
label.add_theme_font_size_override("font_size", font_size)
|
|
label.add_theme_color_override("font_color", color)
|
|
if bold:
|
|
label.add_theme_constant_override("outline_size", 0)
|
|
return label
|
|
|
|
|
|
func _section_title(text: String) -> Label:
|
|
return _label(text, 16, Brand.text_color(UIThemeService.is_dark_mode()), true)
|
|
|
|
|
|
func _style_progress(bar: ProgressBar, accent: Color) -> void:
|
|
var background := StyleBoxFlat.new()
|
|
background.bg_color = Color("26324a") if UIThemeService.is_dark_mode() else Color("e2e8f0")
|
|
background.set_corner_radius_all(4)
|
|
var fill := StyleBoxFlat.new()
|
|
fill.bg_color = accent
|
|
fill.set_corner_radius_all(4)
|
|
bar.add_theme_stylebox_override("background", background)
|
|
bar.add_theme_stylebox_override("fill", fill)
|
|
|
|
|
|
func _show_loading() -> void:
|
|
if status_label != null:
|
|
status_label.text = "Loading your progress..."
|
|
status_label.visible = true
|
|
|
|
|
|
func _show_error(message: String) -> void:
|
|
status_label.text = message
|
|
status_label.add_theme_color_override("font_color", Brand.MAGENTA)
|
|
status_label.visible = true
|
|
|
|
|
|
func _update_responsive() -> void:
|
|
if metric_grid == null:
|
|
return
|
|
var compact := size.x < 1050.0
|
|
metric_grid.columns = 2 if compact else 4
|
|
analytics_grid.columns = 1 if compact else 2
|
|
lower_grid.columns = 1 if compact else 2
|
|
if trend_chart != null:
|
|
trend_chart.custom_minimum_size.x = 300.0 if compact else 520.0
|
|
|
|
|
|
func _clear(container: Container) -> void:
|
|
for child in container.get_children():
|
|
container.remove_child(child)
|
|
child.free()
|
|
|
|
|
|
func _format_duration(total_seconds: int) -> String:
|
|
var hours := int(floor(float(total_seconds) / 3600.0))
|
|
var minutes := int(floor(float(total_seconds % 3600) / 60.0))
|
|
return "%dh %02dm" % [hours, minutes]
|
|
|
|
|
|
func _relative_time(iso_value: String) -> String:
|
|
var cleaned := iso_value.trim_suffix("Z")
|
|
var occurred := Time.get_unix_time_from_datetime_string(cleaned)
|
|
if occurred <= 0:
|
|
return ""
|
|
var elapsed := maxi(0, int(Time.get_unix_time_from_system() - occurred))
|
|
if elapsed < 3600:
|
|
return "%dm ago" % maxi(1, int(floor(float(elapsed) / 60.0)))
|
|
if elapsed < 86400:
|
|
return "%dh ago" % int(floor(float(elapsed) / 3600.0))
|
|
return "%dd ago" % int(floor(float(elapsed) / 86400.0))
|