Frontend half of the dashboard stat-card redesign, matching the provided mockup. The 4 metrics used to be plain rows sharing one bordered box (_add_metric -> a single metric_grid inside one _card). Replaced with 4 independently-_card-wrapped panels in a responsive GridContainer (4 cols / 2 compact, same threshold _update_responsive already used). Each card now has: a colored icon badge, title + info glyph, a big value, a colored trend/status line, a small visual, and a footer caption: - Question Bank Rating / CCS Rating: a new minimal DashboardSparkline (dashboard_sparkline.gd) fed from the current rating and the week-ago snapshot (reconstructed client-side from the delta the API already returns - no separate history endpoint needed). Trend line and caption come from the new delta/percentile fields; both degrade to a plain single-point sparkline and a rated-count caption when no snapshot exists yet. - Exam Readiness: a new DashboardSegmentBar (dashboard_segment_bar.gd - nothing like a segmented pill progress indicator existed anywhere in the codebase) instead of a sparkline, colored by the High/Medium/Low label; shows a neutral "complete a simulation" state when the user has none yet rather than a fabricated readiness. - Average Score: reuses the existing ProgressBar/_style_progress, relabeled from "First-Attempt Accuracy". Dropped the old "Study Time" card (not part of the mockup - the underlying data still drives the weekly activity chart below, it just no longer gets a dedicated top-line number) and the now-fully-unused _format_duration. Verified headlessly: instantiated the real screen against fake DashboardService data, confirmed every card's value/trend/caption text and the segment-bar/progress-bar values match the mockup exactly, and rendered actual screenshots of both the populated and "no data yet" fallback states.
676 lines
28 KiB
GDScript
676 lines
28 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")
|
|
const DashboardSparkline := preload("res://scripts/ui/dashboard_sparkline.gd")
|
|
const DashboardSegmentBar := preload("res://scripts/ui/dashboard_segment_bar.gd")
|
|
|
|
var main: Control
|
|
var scroll: ScrollContainer
|
|
var content: VBoxContainer
|
|
var status_label: Label
|
|
var stat_card_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 metric_trends: Dictionary = {}
|
|
var qbank_sparkline: Control
|
|
var ccs_sparkline: Control
|
|
var readiness_segment_bar: Control
|
|
var accuracy_progress_bar: ProgressBar
|
|
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()
|
|
metric_trends.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)
|
|
stat_card_grid = GridContainer.new()
|
|
stat_card_grid.columns = 4
|
|
stat_card_grid.add_theme_constant_override("h_separation", 12)
|
|
stat_card_grid.add_theme_constant_override("v_separation", 12)
|
|
content.add_child(stat_card_grid)
|
|
|
|
qbank_sparkline = DashboardSparkline.new()
|
|
qbank_sparkline.custom_minimum_size = Vector2(0, 34)
|
|
_add_stat_card("qbank", "Question Bank Rating", "chart-no-axes-column-increasing", Brand.ROYAL_BLUE, qbank_sparkline)
|
|
|
|
ccs_sparkline = DashboardSparkline.new()
|
|
ccs_sparkline.custom_minimum_size = Vector2(0, 34)
|
|
_add_stat_card("ccs", "CCS Rating", "stethoscope", Brand.SUCCESS, ccs_sparkline)
|
|
|
|
readiness_segment_bar = DashboardSegmentBar.new()
|
|
readiness_segment_bar.custom_minimum_size = Vector2(0, 10)
|
|
_add_stat_card("readiness", "Exam Readiness", "activity", Brand.PURPLE, readiness_segment_bar)
|
|
|
|
accuracy_progress_bar = ProgressBar.new()
|
|
accuracy_progress_bar.show_percentage = false
|
|
accuracy_progress_bar.min_value = 0.0
|
|
accuracy_progress_bar.max_value = 100.0
|
|
accuracy_progress_bar.custom_minimum_size = Vector2(0, 10)
|
|
_style_progress(accuracy_progress_bar, Brand.AMBER)
|
|
_add_stat_card("accuracy", "Average Score", "circle-check", Brand.AMBER, accuracy_progress_bar)
|
|
|
|
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
|
|
var trend: Array = data.get("performanceTrend", [])
|
|
_render_rating_card("qbank", qbank_sparkline, Brand.ROYAL_BLUE, data.get("questionBankRating", 1000),
|
|
data.get("questionBankRatingDeltaThisWeek"), data.get("questionBankPercentile"), "%d rated questions" % int(data.get("ratedQuestionCount", 0)),
|
|
_trend_series(trend, "questionBankRating"))
|
|
_render_rating_card("ccs", ccs_sparkline, Brand.SUCCESS, data.get("ccsRating", 1000),
|
|
data.get("ccsRatingDeltaThisWeek"), data.get("ccsPercentile"), "%d rated cases" % int(data.get("ratedCcsCaseCount", 0)),
|
|
_trend_series(trend, "ccsRating"))
|
|
_render_readiness_card(data)
|
|
_render_accuracy_card(data)
|
|
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 _trend_series(trend: Array, field: String) -> Array:
|
|
var series: Array = []
|
|
for point in trend:
|
|
var value = point.get(field)
|
|
if value != null:
|
|
series.append(float(value))
|
|
return series
|
|
|
|
|
|
func _render_rating_card(key: String, sparkline: Control, accent: Color, rating_value: Variant, delta_value: Variant, percentile_value: Variant, fallback_caption: String, series: Array) -> void:
|
|
var dark := UIThemeService.is_dark_mode()
|
|
var rating := int(rating_value)
|
|
var trend_text := ""
|
|
var trend_color := Brand.muted_color(dark)
|
|
var caption := fallback_caption
|
|
if delta_value != null:
|
|
var delta := int(delta_value)
|
|
var arrow := "↗" if delta >= 0 else "↘"
|
|
var sign := "+" if delta >= 0 else ""
|
|
trend_text = "%s %s%d this week" % [arrow, sign, delta]
|
|
trend_color = Brand.SUCCESS if delta >= 0 else Brand.MAGENTA
|
|
if percentile_value != null:
|
|
caption = "Top %d%% of users" % int(percentile_value)
|
|
_set_stat_card(key, str(rating), trend_text, trend_color, caption)
|
|
if series.size() < 2:
|
|
sparkline.set_values([float(rating)], accent, Vector2(rating - 10.0, rating + 10.0))
|
|
else:
|
|
var low: float = series.min()
|
|
var high: float = series.max()
|
|
var spread := maxf(10.0, (high - low) * 0.5)
|
|
sparkline.set_values(series, accent, Vector2(low - spread, high + spread))
|
|
|
|
|
|
func _render_readiness_card(data: Dictionary) -> void:
|
|
var dark := UIThemeService.is_dark_mode()
|
|
var label_value = data.get("examReadinessLabel")
|
|
if label_value == null:
|
|
_set_stat_card("readiness", "--", "", Brand.muted_color(dark), "Complete a simulation to see this")
|
|
readiness_segment_bar.set_progress(0, 4, Brand.muted_color(dark), dark)
|
|
return
|
|
var label := str(label_value)
|
|
var pass_percent := int(data.get("examReadinessProjectedPassPercent", 0))
|
|
var status_color := Brand.SUCCESS if label == "High" else (Brand.AMBER if label == "Medium" else Brand.MAGENTA)
|
|
var caption := "You're on track!" if label != "Low" else "Keep practicing"
|
|
_set_stat_card("readiness", label, "Projected pass: %d%%" % pass_percent, status_color, caption)
|
|
(metric_values.get("readiness") as Label).add_theme_color_override("font_color", status_color)
|
|
readiness_segment_bar.set_progress(int(round(pass_percent / 100.0 * 4.0)), 4, status_color, dark)
|
|
|
|
|
|
func _render_accuracy_card(data: Dictionary) -> void:
|
|
var dark := UIThemeService.is_dark_mode()
|
|
var accuracy := int(data.get("firstAttemptAccuracy", 0))
|
|
var delta_value = data.get("accuracyDeltaVsPreviousSample")
|
|
var trend_text := ""
|
|
var trend_color := Brand.muted_color(dark)
|
|
if delta_value != null:
|
|
var delta := int(delta_value)
|
|
var arrow := "↗" if delta >= 0 else "↘"
|
|
var sign := "+" if delta >= 0 else ""
|
|
trend_text = "%s %s%d%% vs last 100" % [arrow, sign, delta]
|
|
trend_color = Brand.SUCCESS if delta >= 0 else Brand.MAGENTA
|
|
_set_stat_card("accuracy", "%d%%" % accuracy, trend_text, trend_color, "Last %d questions" % int(data.get("accuracySampleSize", 0)))
|
|
accuracy_progress_bar.value = accuracy
|
|
|
|
|
|
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_stat_card(key: String, title: String, icon_name: String, accent: Color, bottom_visual: Control) -> void:
|
|
var dark := UIThemeService.is_dark_mode()
|
|
var card_content := VBoxContainer.new()
|
|
card_content.custom_minimum_size = Vector2(220, 0)
|
|
card_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
card_content.add_theme_constant_override("separation", 8)
|
|
|
|
var header := HBoxContainer.new()
|
|
header.add_theme_constant_override("separation", 10)
|
|
card_content.add_child(header)
|
|
var icon_panel := PanelContainer.new()
|
|
icon_panel.custom_minimum_size = Vector2(40, 40)
|
|
icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
|
icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, accent, 20, true))
|
|
var icon := TextureRect.new()
|
|
icon.texture = load("res://assets/icons/lucide/%s.svg" % icon_name)
|
|
icon.custom_minimum_size = Vector2(18, 18)
|
|
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
|
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
|
icon_panel.add_child(icon)
|
|
header.add_child(icon_panel)
|
|
var title_label := _label(title, 14, Brand.text_color(dark))
|
|
title_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
|
title_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
|
header.add_child(title_label)
|
|
var info_label := _label("ⓘ", 13, Brand.muted_color(dark))
|
|
info_label.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
|
|
header.add_child(info_label)
|
|
|
|
var value := _label("--", 28, Brand.text_color(dark), true)
|
|
card_content.add_child(value)
|
|
var trend := _label("", 13, Brand.muted_color(dark))
|
|
card_content.add_child(trend)
|
|
card_content.add_child(bottom_visual)
|
|
var caption := _label("Loading", 12, Brand.muted_color(dark))
|
|
card_content.add_child(caption)
|
|
|
|
metric_values[key] = value
|
|
metric_trends[key] = trend
|
|
metric_details[key] = caption
|
|
stat_card_grid.add_child(_card(card_content, 16))
|
|
|
|
|
|
func _set_stat_card(key: String, value: String, trend_text: String, trend_color: Color, caption: String) -> void:
|
|
(metric_values.get(key) as Label).text = value
|
|
var trend_label: Label = metric_trends.get(key)
|
|
if trend_label != null:
|
|
trend_label.text = trend_text
|
|
trend_label.add_theme_color_override("font_color", trend_color)
|
|
(metric_details.get(key) as Label).text = caption
|
|
|
|
|
|
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 stat_card_grid == null:
|
|
return
|
|
var compact := size.x < 1050.0
|
|
stat_card_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 _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))
|