Implement responsive profile and account data controls

This commit is contained in:
hz
2026-07-19 13:39:15 -05:00
parent 7508d4fe1d
commit dacf2b89b2
22 changed files with 1099 additions and 290 deletions
+9
View File
@@ -70,6 +70,15 @@ jobs:
run: | run: |
kubectl create namespace step3-questions --dry-run=client -o yaml | kubectl apply -f - kubectl create namespace step3-questions --dry-run=client -o yaml | kubectl apply -f -
- name: Configure internal API secret
env:
INTERNAL_PURGE_SECRET: ${{ secrets.INTERNAL_PURGE_SECRET }}
run: |
test -n "$INTERNAL_PURGE_SECRET"
kubectl create secret generic medmind-internal-api \
--from-literal=purge-secret="$INTERNAL_PURGE_SECRET" \
--dry-run=client -o yaml | kubectl apply -n step3-questions -f -
- name: Apply Questions deployment & service - name: Apply Questions deployment & service
run: | run: |
kubectl apply -f api/QuestionsApi/k8s/deployment.yaml -n step3-questions kubectl apply -f api/QuestionsApi/k8s/deployment.yaml -n step3-questions
@@ -0,0 +1,58 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Models;
using QuestionsApi.Services;
using System.Security.Claims;
using System.Security.Cryptography;
namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/profile")]
public sealed class ProfileController(UserProfileStore profiles, IConfiguration configuration) : ControllerBase
{
[HttpGet("me/preferences")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> GetPreferences()
{
var profile = await profiles.GetAsync(UserId());
return Ok(profile ?? new UserStudyProfile { UserId = UserId() });
}
[HttpPut("me/preferences")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> UpdatePreferences(UpdateUserStudyProfileRequest request)
{
if (UserStudyProfileValidator.Validate(request) is { } error) return BadRequest(error);
return Ok(await profiles.UpsertAsync(UserId(), request));
}
[HttpGet("me/export")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Export() => Ok(new
{
schemaVersion = 1,
exportedUtc = DateTime.UtcNow,
userId = UserId(),
records = await profiles.ExportAsync(UserId())
});
[HttpDelete("internal/users/{userId}")]
[AllowAnonymous]
public async Task<IActionResult> Purge(string userId)
{
var expected = configuration["InternalPurge:Secret"];
var supplied = Request.Headers["X-MedMind-Internal-Key"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(expected) || !CryptographicOperations.FixedTimeEquals(
System.Text.Encoding.UTF8.GetBytes(expected),
System.Text.Encoding.UTF8.GetBytes(supplied ?? "")))
return Unauthorized();
await profiles.PurgeAsync(userId);
return NoContent();
}
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? throw new UnauthorizedAccessException();
}
@@ -6,6 +6,8 @@ public sealed class DashboardResponse
public int RatedQuestionCount { get; set; } public int RatedQuestionCount { get; set; }
public int CcsRating { get; set; } = 1000; public int CcsRating { get; set; } = 1000;
public int RatedCcsCaseCount { get; set; } public int RatedCcsCaseCount { get; set; }
public int QuestionsCompleted { get; set; }
public int CcsCasesCompleted { get; set; }
public int FirstAttemptAccuracy { get; set; } public int FirstAttemptAccuracy { get; set; }
public int AccuracySampleSize { get; set; } public int AccuracySampleSize { get; set; }
public int QuestionsThisWeek { get; set; } public int QuestionsThisWeek { get; set; }
@@ -0,0 +1,50 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
public sealed class UserStudyProfile
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
public string UserId { get; set; } = "";
public DateTime? TargetExamDate { get; set; }
public string PreparationStage { get; set; } = "pre_dedicated";
public int WeeklyGoalHours { get; set; } = 10;
public string PreferredStudyMode { get; set; } = "mixed";
public int Revision { get; set; }
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
public sealed class UpdateUserStudyProfileRequest
{
public DateTime? TargetExamDate { get; set; }
public string PreparationStage { get; set; } = "pre_dedicated";
public int WeeklyGoalHours { get; set; } = 10;
public string PreferredStudyMode { get; set; } = "mixed";
}
public sealed class DeletedUserTombstone
{
[BsonId]
public string UserId { get; set; } = "";
public DateTime DeletedUtc { get; set; } = DateTime.UtcNow;
}
public static class UserStudyProfileValidator
{
private static readonly HashSet<string> Stages = ["pre_dedicated", "dedicated", "final_review"];
private static readonly HashSet<int> Goals = [5, 10, 15, 20, 25];
private static readonly HashSet<string> Modes = ["mixed", "question_bank", "ccs", "exam_simulation"];
public static string? Validate(UpdateUserStudyProfileRequest request)
{
if (!Stages.Contains(request.PreparationStage)) return "Invalid preparation stage.";
if (!Goals.Contains(request.WeeklyGoalHours)) return "Weekly goal must be 5, 10, 15, 20, or 25 hours.";
if (!Modes.Contains(request.PreferredStudyMode)) return "Invalid preferred study mode.";
if (request.TargetExamDate?.Kind == DateTimeKind.Local)
request.TargetExamDate = request.TargetExamDate.Value.ToUniversalTime();
return null;
}
}
+13
View File
@@ -25,6 +25,7 @@ builder.Services.AddSingleton<UserNotepadStore>();
builder.Services.AddSingleton<QuestionBookmarkStore>(); builder.Services.AddSingleton<QuestionBookmarkStore>();
builder.Services.AddSingleton<ReviewQueueStore>(); builder.Services.AddSingleton<ReviewQueueStore>();
builder.Services.AddSingleton<DashboardStore>(); builder.Services.AddSingleton<DashboardStore>();
builder.Services.AddSingleton<UserProfileStore>();
builder.Services.AddSingleton<ServiceMetrics>(); builder.Services.AddSingleton<ServiceMetrics>();
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>(); builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
@@ -71,6 +72,18 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
ValidateLifetime = true, ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30) ClockSkew = TimeSpan.FromSeconds(30)
}; };
o.Events = new JwtBearerEvents
{
OnTokenValidated = async context =>
{
var userId = context.Principal?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
if (!string.IsNullOrWhiteSpace(userId))
{
var profiles = context.HttpContext.RequestServices.GetRequiredService<UserProfileStore>();
if (await profiles.IsDeletedAsync(userId)) context.Fail("Account deleted");
}
}
};
}); });
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
+5 -1
View File
@@ -39,11 +39,13 @@ public sealed class DashboardStore
var ratingTask = _ratings.Find(value => value.UserId == userId).FirstOrDefaultAsync(); var ratingTask = _ratings.Find(value => value.UserId == userId).FirstOrDefaultAsync();
var ccsRatingTask = _ccsRatings.Find(value => value.UserId == userId).FirstOrDefaultAsync(); var ccsRatingTask = _ccsRatings.Find(value => value.UserId == userId).FirstOrDefaultAsync();
var ccsAttemptsTask = _ccsAttempts.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync(); var ccsAttemptsTask = _ccsAttempts.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync();
var completedCcsCountTask = _ccsAttempts.CountDocumentsAsync(value => value.UserId == userId
&& value.State == CcsAttemptStates.Completed && value.CompletedUtc.HasValue);
var simulationsTask = _simulations.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync(); var simulationsTask = _simulations.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync();
var bookmarkCountTask = _bookmarks.CountDocumentsAsync(value => value.UserId == userId); var bookmarkCountTask = _bookmarks.CountDocumentsAsync(value => value.UserId == userId);
var notepadTask = _notes.Find(value => value.UserId == userId).FirstOrDefaultAsync(); var notepadTask = _notes.Find(value => value.UserId == userId).FirstOrDefaultAsync();
var reviewAttemptsTask = _reviewQueue.GetReviewsSinceAsync(userId, weekStart); var reviewAttemptsTask = _reviewQueue.GetReviewsSinceAsync(userId, weekStart);
await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask, await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask, completedCcsCountTask,
simulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask); simulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask);
var attempts = attemptsTask.Result; var attempts = attemptsTask.Result;
@@ -62,6 +64,8 @@ public sealed class DashboardStore
RatedQuestionCount = ratingTask.Result?.RatedQuestionCount ?? 0, RatedQuestionCount = ratingTask.Result?.RatedQuestionCount ?? 0,
CcsRating = ccsRatingTask.Result?.Elo ?? 1000, CcsRating = ccsRatingTask.Result?.Elo ?? 1000,
RatedCcsCaseCount = ccsRatingTask.Result?.RatedCaseCount ?? 0, RatedCcsCaseCount = ccsRatingTask.Result?.RatedCaseCount ?? 0,
QuestionsCompleted = answeredAttempts.Count,
CcsCasesCompleted = checked((int)completedCcsCountTask.Result),
AccuracySampleSize = recentAnswered.Count, AccuracySampleSize = recentAnswered.Count,
FirstAttemptAccuracy = Percentage(recentAnswered.Count(value => value.IsCorrect), recentAnswered.Count), FirstAttemptAccuracy = Percentage(recentAnswered.Count(value => value.IsCorrect), recentAnswered.Count),
QuestionsThisWeek = answeredAttempts.Count(value => value.CreatedUtc >= weekStart), QuestionsThisWeek = answeredAttempts.Count(value => value.CreatedUtc >= weekStart),
@@ -0,0 +1,99 @@
using MongoDB.Bson;
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class UserProfileStore
{
private readonly IMongoDatabase _database;
private readonly IMongoCollection<UserStudyProfile> _profiles;
private readonly IMongoCollection<DeletedUserTombstone> _deletedUsers;
private static readonly string[] OwnedCollections =
[
"QuestionAttempts", "UserRatings", "QuestionBookmarks", "UserNotepads",
"ReviewQueueItems", "QuestionReviewAttempts", "ReviewQueueBackfillStates",
"CcsAttempts", "CcsUserRatings", "CcsRatedCaseResults", "ExamSimulations",
"Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles"
];
public UserProfileStore(MongoDatabaseProvider mongo)
{
_database = mongo.Database;
_profiles = _database.GetCollection<UserStudyProfile>("UserStudyProfiles");
_deletedUsers = _database.GetCollection<DeletedUserTombstone>("DeletedUserTombstones");
_profiles.Indexes.CreateOne(new CreateIndexModel<UserStudyProfile>(
Builders<UserStudyProfile>.IndexKeys.Ascending(value => value.UserId),
new CreateIndexOptions { Unique = true }));
}
public async Task<UserStudyProfile?> GetAsync(string userId) =>
await _profiles.Find(value => value.UserId == userId).FirstOrDefaultAsync();
public async Task<UserStudyProfile> UpsertAsync(string userId, UpdateUserStudyProfileRequest request)
{
var update = Builders<UserStudyProfile>.Update
.Set(value => value.TargetExamDate, request.TargetExamDate)
.Set(value => value.PreparationStage, request.PreparationStage)
.Set(value => value.WeeklyGoalHours, request.WeeklyGoalHours)
.Set(value => value.PreferredStudyMode, request.PreferredStudyMode)
.Set(value => value.UpdatedUtc, DateTime.UtcNow)
.Inc(value => value.Revision, 1)
.SetOnInsert(value => value.UserId, userId);
return await _profiles.FindOneAndUpdateAsync(
value => value.UserId == userId,
update,
new FindOneAndUpdateOptions<UserStudyProfile> { IsUpsert = true, ReturnDocument = ReturnDocument.After });
}
public Task<bool> IsDeletedAsync(string userId) =>
_deletedUsers.Find(value => value.UserId == userId).AnyAsync();
public async Task<Dictionary<string, List<object?>>> ExportAsync(string userId)
{
var result = new Dictionary<string, List<object?>>(StringComparer.OrdinalIgnoreCase);
foreach (var collectionName in OwnedCollections)
{
var documents = await _database.GetCollection<BsonDocument>(collectionName)
.Find(Builders<BsonDocument>.Filter.Eq("UserId", userId)).ToListAsync();
result[collectionName] = documents.Select(document => Map(document)).ToList();
}
return result;
}
public async Task PurgeAsync(string userId)
{
await _deletedUsers.ReplaceOneAsync(
value => value.UserId == userId,
new DeletedUserTombstone { UserId = userId, DeletedUtc = DateTime.UtcNow },
new ReplaceOptions { IsUpsert = true });
foreach (var collectionName in OwnedCollections)
{
await _database.GetCollection<BsonDocument>(collectionName)
.DeleteManyAsync(Builders<BsonDocument>.Filter.Eq("UserId", userId));
}
foreach (var collectionName in new[] { "QuestionReports", "CcsCaseReports", "AppFeedback" })
{
await _database.GetCollection<BsonDocument>(collectionName).UpdateManyAsync(
Builders<BsonDocument>.Filter.Eq("ReviewedByUserId", userId),
Builders<BsonDocument>.Update.Unset("ReviewedByUserId"));
}
}
private static object? Map(BsonValue value)
{
if (value.IsBsonNull) return null;
if (value.IsBsonDocument)
return value.AsBsonDocument.Elements.ToDictionary(element => element.Name, element => Map(element.Value));
if (value.IsBsonArray) return value.AsBsonArray.Select(Map).ToList();
if (value.IsObjectId) return value.AsObjectId.ToString();
if (value.IsValidDateTime) return value.ToUniversalTime();
if (value.IsDecimal128) return Decimal128.ToDecimal(value.AsDecimal128);
if (value.IsBsonBinaryData) return Convert.ToBase64String(value.AsBsonBinaryData.Bytes);
if (value.IsBsonRegularExpression) return value.AsBsonRegularExpression.ToString();
return BsonTypeMapper.MapToDotNetValue(value);
}
}
+6
View File
@@ -29,6 +29,12 @@ spec:
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- containerPort: 5008 - containerPort: 5008
env:
- name: InternalPurge__Secret
valueFrom:
secretKeyRef:
name: medmind-internal-api
key: purge-secret
resources: resources:
requests: requests:
cpu: 250m cpu: 250m
+26
View File
@@ -0,0 +1,26 @@
# Profile and account operations
The profile screen reads account identity from `GET /api/Auth/me`, study preferences from
`GET /api/profile/me/preferences`, and performance metrics from `GET /api/dashboard`.
## Deployment
Set the same non-empty `INTERNAL_PURGE_SECRET` Gitea Actions secret in both the Step 3 and
Promiscuity repositories. Their deployment workflows create namespace-local
`medmind-internal-api` Kubernetes secrets from that value.
Deploy in this order:
1. Questions API
2. Auth API
3. Godot web client
The Auth API only removes an account after the Questions API confirms that the user's study
records were purged. The purge endpoint is idempotent and creates a tombstone that prevents an
old access token from recreating study data.
## Data export
Exports are versioned JSON files containing account metadata and user-owned study records.
Password hashes, refresh tokens, global question content, and moderation data owned by other
users are excluded.
+2
View File
@@ -32,6 +32,7 @@ NotesService="*res://scripts/autoload/notes_service.gd"
BookmarkService="*res://scripts/autoload/bookmark_service.gd" BookmarkService="*res://scripts/autoload/bookmark_service.gd"
ReviewQueueService="*res://scripts/autoload/review_queue_service.gd" ReviewQueueService="*res://scripts/autoload/review_queue_service.gd"
DashboardService="*res://scripts/autoload/dashboard_service.gd" DashboardService="*res://scripts/autoload/dashboard_service.gd"
AccountProfileService="*res://scripts/autoload/account_profile_service.gd"
[display] [display]
@@ -58,5 +59,6 @@ question_reports_api_url="https://s3q.ranaze.com/api/question-reports"
app_feedback_api_url="https://s3q.ranaze.com/api/app-feedback" app_feedback_api_url="https://s3q.ranaze.com/api/app-feedback"
notes_api_url="https://s3q.ranaze.com/api/notes" notes_api_url="https://s3q.ranaze.com/api/notes"
dashboard_api_url="https://s3q.ranaze.com/api/dashboard" dashboard_api_url="https://s3q.ranaze.com/api/dashboard"
profile_api_url="https://s3q.ranaze.com/api/profile"
review_queue_api_url="https://s3q.ranaze.com/api/review-queue" review_queue_api_url="https://s3q.ranaze.com/api/review-queue"
build_revision="development" build_revision="development"
+229
View File
@@ -0,0 +1,229 @@
extends Node
signal profile_changed
signal request_failed(message: String)
signal preferences_saved
signal password_changed
signal account_deleted
signal archive_saved(path: String)
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
const AUTH_URL := "https://pauth.ranaze.com/api/Auth"
const PROFILE_URL := "https://s3q.ranaze.com/api/profile"
var account: Dictionary = {}
var preferences: Dictionary = _default_preferences()
var auth_request: HTTPRequest
var profile_request: HTTPRequest
var auth_operation := ""
var profile_operation := ""
var auth_token := ""
var profile_token := ""
var preferences_pending := false
func _ready() -> void:
auth_request = HTTPRequest.new()
auth_request.timeout = 25.0
auth_request.request_completed.connect(_on_auth_completed)
add_child(auth_request)
profile_request = HTTPRequest.new()
profile_request.timeout = 30.0
profile_request.request_completed.connect(_on_profile_completed)
add_child(profile_request)
AuthState.auth_changed.connect(_on_auth_changed)
func refresh() -> void:
if not AuthState.can_send_authenticated_request():
return
_load_cache()
_request_auth("identity", HTTPClient.METHOD_GET, "/me")
if preferences_pending:
_request_profile("update_preferences", HTTPClient.METHOD_PUT, "/me/preferences", preferences)
else:
_request_profile("preferences", HTTPClient.METHOD_GET, "/me/preferences")
func update_account(display_name: String, email: String) -> void:
_request_auth("update_account", HTTPClient.METHOD_PATCH, "/me", {
"displayName": display_name.strip_edges(), "email": email.strip_edges()
})
func update_preferences(target_exam_date: String, preparation_stage: String, weekly_goal_hours: int, preferred_mode: String) -> void:
var target_date: Variant = null
if not target_exam_date.strip_edges().is_empty():
target_date = "%sT00:00:00Z" % target_exam_date.strip_edges()
preferences = {
"targetExamDate": target_date,
"preparationStage": preparation_stage,
"weeklyGoalHours": weekly_goal_hours,
"preferredStudyMode": preferred_mode
}
preferences_pending = true
_save_cache(true)
profile_changed.emit()
_request_profile("update_preferences", HTTPClient.METHOD_PUT, "/me/preferences", preferences)
func change_password(current_password: String, new_password: String) -> void:
_request_auth("change_password", HTTPClient.METHOD_POST, "/me/change-password", {
"currentPassword": current_password, "newPassword": new_password
})
func export_archive() -> void:
_request_profile("export", HTTPClient.METHOD_GET, "/me/export")
func delete_account(password: String, confirmation: String) -> void:
_request_auth("delete_account", HTTPClient.METHOD_DELETE, "/me", {
"password": password, "confirmation": confirmation
})
func _request_auth(operation: String, method: HTTPClient.Method, path: String, payload: Variant = null) -> void:
if auth_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
request_failed.emit("Another account request is still in progress.")
return
auth_operation = operation
auth_token = AuthState.access_token
var headers := PackedStringArray(["Authorization: Bearer %s" % auth_token, "Content-Type: application/json"])
var url := "%s%s" % [EndpointConfig.resolve(str(ProjectSettings.get_setting("step3/auth_api_base_url", AUTH_URL)), "/api/Auth").rstrip("/"), path]
var error := auth_request.request(url, headers, method, "" if payload == null else JSON.stringify(payload))
if error != OK:
auth_operation = ""
request_failed.emit("The account service could not be reached.")
func _request_profile(operation: String, method: HTTPClient.Method, path: String, payload: Variant = null) -> void:
if profile_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
request_failed.emit("Another profile request is still in progress.")
return
profile_operation = operation
profile_token = AuthState.access_token
var headers := PackedStringArray(["Authorization: Bearer %s" % profile_token, "Content-Type: application/json"])
var base := EndpointConfig.resolve(str(ProjectSettings.get_setting("step3/profile_api_url", PROFILE_URL)), "/api/profile")
var error := profile_request.request("%s%s" % [base.rstrip("/"), path], headers, method, "" if payload == null else JSON.stringify(payload))
if error != OK:
profile_operation = ""
request_failed.emit("The profile service could not be reached.")
func _on_auth_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
var operation := auth_operation
auth_operation = ""
if response_code == 401:
AuthState.handle_unauthorized(auth_token)
return
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
request_failed.emit(_error_message(body, "The account request failed."))
return
if operation in ["identity", "update_account"]:
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if typeof(parsed) != TYPE_DICTIONARY:
request_failed.emit("The account service returned invalid data.")
return
account = parsed
profile_changed.emit()
elif operation == "change_password":
password_changed.emit()
AuthState.clear_session()
elif operation == "delete_account":
_clear_cache()
account_deleted.emit()
AuthState.clear_session()
func _on_profile_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
var operation := profile_operation
profile_operation = ""
if response_code == 401:
AuthState.handle_unauthorized(profile_token)
return
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
request_failed.emit(_error_message(body, "The profile request failed."))
return
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if typeof(parsed) != TYPE_DICTIONARY:
request_failed.emit("The profile service returned invalid data.")
return
if operation in ["preferences", "update_preferences"]:
preferences = parsed
preferences_pending = false
_save_cache(false)
profile_changed.emit()
if operation == "update_preferences":
preferences_saved.emit()
elif operation == "export":
var archive: Dictionary = parsed
archive["account"] = account.duplicate(true)
_save_archive(JSON.stringify(archive, " "))
func _save_archive(content: String) -> void:
var filename := "medmind-account-export-%s.json" % Time.get_date_string_from_system()
if OS.has_feature("web"):
var encoded := Marshalls.raw_to_base64(content.to_utf8_buffer())
JavaScriptBridge.eval("(function(){const a=document.createElement('a');a.href='data:application/json;base64,%s';a.download=%s;document.body.appendChild(a);a.click();a.remove();})();" % [encoded, JSON.stringify(filename)], true)
archive_saved.emit(filename)
return
var directory := OS.get_system_dir(OS.SYSTEM_DIR_DOWNLOADS)
var path := directory.path_join(filename)
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
request_failed.emit("The archive could not be saved.")
return
file.store_string(content)
archive_saved.emit(path)
func _on_auth_changed() -> void:
if AuthState.is_logged_in:
_load_cache()
else:
account = {}
preferences = _default_preferences()
func _cache_path() -> String:
return "user://profile-%s.json" % AuthState.username.sha256_text().left(16)
func _load_cache() -> void:
if AuthState.username.is_empty() or not FileAccess.file_exists(_cache_path()):
return
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(_cache_path()))
if typeof(parsed) == TYPE_DICTIONARY:
preferences = parsed.get("preferences", _default_preferences())
preferences_pending = bool(parsed.get("pending", false))
func _save_cache(pending: bool) -> void:
if AuthState.username.is_empty():
return
var file := FileAccess.open(_cache_path(), FileAccess.WRITE)
if file != null:
preferences_pending = pending
file.store_string(JSON.stringify({"preferences": preferences, "pending": pending}))
func _clear_cache() -> void:
if not AuthState.username.is_empty() and FileAccess.file_exists(_cache_path()):
DirAccess.remove_absolute(ProjectSettings.globalize_path(_cache_path()))
func _error_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_DICTIONARY:
var detail := str(parsed.get("detail", parsed.get("title", ""))).strip_edges()
if not detail.is_empty():
return detail
var text := raw.trim_prefix("\"").trim_suffix("\"")
return fallback if text.is_empty() else text
static func _default_preferences() -> Dictionary:
return {"targetExamDate": null, "preparationStage": "pre_dedicated", "weeklyGoalHours": 10, "preferredStudyMode": "mixed"}
@@ -0,0 +1 @@
uid://dbgdrurlh4btb
-20
View File
@@ -21,7 +21,6 @@ func _ready() -> void:
size_flags_horizontal = Control.SIZE_EXPAND_FILL size_flags_horizontal = Control.SIZE_EXPAND_FILL
size_flags_vertical = Control.SIZE_EXPAND_FILL size_flags_vertical = Control.SIZE_EXPAND_FILL
add_theme_constant_override("separation", 16) add_theme_constant_override("separation", 16)
_build_header()
_build_filters() _build_filters()
status_label = Label.new() status_label = Label.new()
status_label.add_theme_font_override("font", Brand.font) status_label.add_theme_font_override("font", Brand.font)
@@ -58,25 +57,6 @@ func _ready() -> void:
UIThemeService.theme_changed.connect(func(_dark: bool): _render()) UIThemeService.theme_changed.connect(func(_dark: bool): _render())
func _build_header() -> void:
var eyebrow := Label.new()
eyebrow.text = "STUDY TOOLS"
eyebrow.add_theme_font_override("font", Brand.font)
eyebrow.add_theme_font_size_override("font_size", 13)
eyebrow.add_theme_color_override("font_color", Brand.SKY_BLUE)
add_child(eyebrow)
var title := Label.new()
title.text = "Bookmarks"
title.add_theme_font_override("font", Brand.font)
title.add_theme_font_size_override("font_size", 32)
add_child(title)
var subtitle := Label.new()
subtitle.text = "Review saved questions without changing your rating or progression."
subtitle.add_theme_font_override("font", Brand.font)
subtitle.add_theme_color_override("font_color", Brand.muted_color(UIThemeService.is_dark_mode()))
add_child(subtitle)
func _build_filters() -> void: func _build_filters() -> void:
var row := HFlowContainer.new() var row := HFlowContainer.new()
row.add_theme_constant_override("separation", 10) row.add_theme_constant_override("separation", 10)
+7 -5
View File
@@ -21,15 +21,17 @@ var weekly_summary: VBoxContainer
var metric_values: Dictionary = {} var metric_values: Dictionary = {}
var metric_details: Dictionary = {} var metric_details: Dictionary = {}
var pending_review_launch := false var pending_review_launch := false
var review_queue_service: Variant
func _ready() -> void: func _ready() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
review_queue_service = get_node("/root/ReviewQueueService")
DashboardService.dashboard_changed.connect(_render) DashboardService.dashboard_changed.connect(_render)
DashboardService.dashboard_failed.connect(_show_error) DashboardService.dashboard_failed.connect(_show_error)
ReviewQueueService.question_loaded.connect(_on_review_question_loaded) review_queue_service.question_loaded.connect(_on_review_question_loaded)
ReviewQueueService.question_failed.connect(_on_review_question_failed) review_queue_service.question_failed.connect(_on_review_question_failed)
ReviewQueueService.pass_completed.connect(_on_review_pass_completed) review_queue_service.pass_completed.connect(_on_review_pass_completed)
UIThemeService.theme_changed.connect(_rebuild) UIThemeService.theme_changed.connect(_rebuild)
resized.connect(_update_responsive) resized.connect(_update_responsive)
_build() _build()
@@ -225,7 +227,7 @@ func _render_focus(systems: Array) -> void:
progress.custom_minimum_size.y = 8 progress.custom_minimum_size.y = 8
_style_progress(progress, Brand.PURPLE) _style_progress(progress, Brand.PURPLE)
focus_content.add_child(progress) focus_content.add_child(progress)
var practice := _button("Practice this system", "chevron-right") var practice := _button("Practice this system")
practice.pressed.connect(func(): main.show_screen("selection", "question_bank")) practice.pressed.connect(func(): main.show_screen("selection", "question_bank"))
focus_content.add_child(practice) focus_content.add_child(practice)
@@ -333,7 +335,7 @@ func _start_review_queue() -> void:
return return
pending_review_launch = true pending_review_launch = true
_show_loading_message("Preparing your review queue...") _show_loading_message("Preparing your review queue...")
ReviewQueueService.start_pass() review_queue_service.start_pass()
_render_review(DashboardService.data) _render_review(DashboardService.data)
+11 -5
View File
@@ -471,11 +471,7 @@ func _apply_theme() -> void:
else: else:
label.add_theme_color_override("font_color", Brand.text_color(dark)) label.add_theme_color_override("font_color", Brand.text_color(dark))
for input in fields: for input in fields:
input.add_theme_color_override("font_color", Brand.text_color(dark)) _style_auth_field(input, 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))
for button in find_children("*", "Button", true, false): for button in find_children("*", "Button", true, false):
var typed := button as Button var typed := button as Button
typed.add_theme_font_override("font", Brand.font) typed.add_theme_font_override("font", Brand.font)
@@ -503,6 +499,16 @@ func _apply_theme() -> void:
brand_tagline.add_theme_color_override("default_color", Brand.muted_color(dark)) brand_tagline.add_theme_color_override("default_color", Brand.muted_color(dark))
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: func _on_theme_changed(_is_dark: bool) -> void:
_apply_theme() _apply_theme()
+1 -1
View File
@@ -381,12 +381,12 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void
_add_nav_button(container, "exams", "Exam Simulations", "clipboard-list", true) _add_nav_button(container, "exams", "Exam Simulations", "clipboard-list", true)
_add_nav_button(container, "bookmarks", "Bookmarks", "bookmark", true) _add_nav_button(container, "bookmarks", "Bookmarks", "bookmark", true)
_add_nav_button(container, "notes", "Notes", "notebook-pen", true) _add_nav_button(container, "notes", "Notes", "notebook-pen", true)
_add_nav_button(container, "performance", "Profile", "user-round", true)
var spacer := Control.new() var spacer := Control.new()
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
container.add_child(spacer) container.add_child(spacer)
container.add_child(HSeparator.new()) container.add_child(HSeparator.new())
_add_nav_button(container, "performance", "Profile", "user-round", true)
_add_nav_button(container, "settings", "Settings", "settings", true) _add_nav_button(container, "settings", "Settings", "settings", true)
_add_nav_button(container, "logout", "Log out", "log-out", true) _add_nav_button(container, "logout", "Log out", "log-out", true)
-12
View File
@@ -1,6 +1,5 @@
extends VBoxContainer extends VBoxContainer
const Brand := preload("res://scripts/ui/medmind_style.gd")
const NotesEditor := preload("res://scripts/ui/notes_editor.gd") const NotesEditor := preload("res://scripts/ui/notes_editor.gd")
var main var main
@@ -11,17 +10,6 @@ func _ready() -> void:
size_flags_horizontal = Control.SIZE_EXPAND_FILL size_flags_horizontal = Control.SIZE_EXPAND_FILL
size_flags_vertical = Control.SIZE_EXPAND_FILL size_flags_vertical = Control.SIZE_EXPAND_FILL
add_theme_constant_override("separation", 16) add_theme_constant_override("separation", 16)
var eyebrow := Label.new()
eyebrow.text = "STUDY TOOLS"
eyebrow.add_theme_font_override("font", Brand.font)
eyebrow.add_theme_font_size_override("font_size", 13)
eyebrow.add_theme_color_override("font_color", Brand.SKY_BLUE)
add_child(eyebrow)
var title := Label.new()
title.text = "Notes"
title.add_theme_font_override("font", Brand.font)
title.add_theme_font_size_override("font_size", 32)
add_child(title)
notes_editor = NotesEditor.new() notes_editor = NotesEditor.new()
add_child(notes_editor) add_child(notes_editor)
+522 -219
View File
@@ -1,266 +1,569 @@
extends VBoxContainer extends Control
const Style := preload("res://scripts/ui/ui_style.gd") const Brand := preload("res://scripts/ui/medmind_style.gd")
var main: Control var main: Control
var profile_text: RichTextLabel var scroll: ScrollContainer
var reset_qbank_button: Button var page: VBoxContainer
var reset_qbank_pending := false var columns: GridContainer
var profile_name: Label
var profile_identity: Label
var profile_meta: Label
var identity_values: Dictionary = {}
var metric_values: Dictionary = {}
var systems_list: VBoxContainer
var status_label: Label
var outcome_score: SpinBox var outcome_score: SpinBox
var outcome_date: LineEdit var outcome_date: LineEdit
var outcome_consent: CheckBox var outcome_consent: CheckBox
var outcome_status: Label var outcome_status: Label
var outcome_delete_button: Button var outcome_delete: Button
var outcome_grid: GridContainer
var ccs_rating_label: Label
func _ready() -> void: func _ready() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT) set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_theme_constant_override("separation", 14) AccountProfileService.profile_changed.connect(_render)
AccountProfileService.request_failed.connect(_show_error)
var title := Label.new() AccountProfileService.preferences_saved.connect(func(): _show_saved("Study identity saved."))
title.text = "Progress Profile" AccountProfileService.archive_saved.connect(func(path: String): _show_saved("Archive saved: %s" % path))
Style.set_label(title, 34, Style.CYAN) DashboardService.dashboard_changed.connect(_render)
add_child(title) DashboardService.dashboard_failed.connect(_show_error)
ExamSimulationService.outcome_changed.connect(_render_outcome)
var scroll := ScrollContainer.new() ExamSimulationService.request_failed.connect(_show_error)
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL UIThemeService.theme_changed.connect(_rebuild)
add_child(scroll) resized.connect(_update_responsive)
var content := VBoxContainer.new() _build()
content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
content.add_theme_constant_override("separation", 14)
scroll.add_child(content)
ccs_rating_label = Label.new()
Style.set_label(ccs_rating_label, 20, Style.CYAN)
content.add_child(ccs_rating_label)
var panel := PanelContainer.new()
panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Color("405475")))
panel.custom_minimum_size.y = 300
content.add_child(panel)
profile_text = RichTextLabel.new()
profile_text.bbcode_enabled = true
profile_text.size_flags_vertical = Control.SIZE_EXPAND_FILL
panel.add_child(profile_text)
_build_outcome_panel(content)
var row := HBoxContainer.new()
var back := Button.new()
back.text = "Back"
back.pressed.connect(func(): main.show_screen("selection", "step3"))
row.add_child(back)
var reset := Button.new()
reset.text = "Reset Progress"
Style.style_chip(reset, Style.RED)
reset.pressed.connect(func():
ProfileService.reset_profile()
on_show()
)
row.add_child(reset)
var simulations := Button.new()
simulations.text = "Simulation History"
Style.style_chip(simulations, Style.CYAN)
simulations.pressed.connect(func(): main.show_screen("simulation_history"))
row.add_child(simulations)
var ccs_history := Button.new()
ccs_history.text = "CCS History"
Style.style_chip(ccs_history, Style.CYAN)
ccs_history.pressed.connect(func(): main.show_screen("ccs_history"))
row.add_child(ccs_history)
reset_qbank_button = Button.new()
reset_qbank_button.text = "Reset QBank"
Style.style_chip(reset_qbank_button, Style.AMBER)
reset_qbank_button.pressed.connect(_on_reset_qbank_pressed)
row.add_child(reset_qbank_button)
add_child(row)
if not ExamSimulationService.outcome_changed.is_connected(_refresh_outcome):
ExamSimulationService.outcome_changed.connect(_refresh_outcome)
if not ExamSimulationService.request_failed.is_connected(_on_outcome_request_failed):
ExamSimulationService.request_failed.connect(_on_outcome_request_failed)
if not CCSService.rating_changed.is_connected(_refresh_ccs_rating):
CCSService.rating_changed.connect(_refresh_ccs_rating)
resized.connect(_refresh_outcome_layout)
_refresh_outcome_layout()
func on_show(_payload: Variant = null) -> void: func on_show(_payload: Variant = null) -> void:
_reset_qbank_confirmation() _render()
_refresh_ccs_rating() AccountProfileService.refresh()
var profile := ProfileService.get_profile() DashboardService.refresh()
var text := "[b]Overall ELO %d[/b]\n%d rated questions\nCurrent streak: %d\nBest streak: %d\nToday: %d/%d questions\n\n[b]Category ELO[/b]\n" % [
QuestionBankService.overall_elo,
QuestionBankService.rated_question_count,
profile["current_streak"],
profile["best_streak"],
profile.get("daily_questions_answered", 0),
profile.get("daily_goal_questions", 10)
]
if QuestionBankService.category_elos.is_empty():
text += "No rated categories yet.\n"
else:
var categories := QuestionBankService.category_elos.keys()
categories.sort()
for category in categories:
text += "- %s: %d\n" % [category, int(QuestionBankService.category_elos[category])]
text += "\n[b]System Accuracy[/b]\n"
if profile["system_stats"].is_empty():
text += "No completed questions yet.\n"
else:
for system_name in profile["system_stats"].keys():
var stats: Dictionary = profile["system_stats"][system_name]
var seen := int(stats.get("seen", 0))
var correct := int(stats.get("correct", 0))
var accuracy := int(round(float(correct) / float(max(seen, 1)) * 100.0))
text += "- %s: %d/%d (%d%%)\n" % [system_name, correct, seen, accuracy]
text += "\n[b]Recent Blocks[/b]\n"
for block in profile["block_history"]:
text += "- %s: %s %d/%d\n" % [
block.get("timestamp", ""),
block.get("system", ""),
block.get("correct", 0),
block.get("total", 0)
]
profile_text.text = text
CCSService.load_rating() CCSService.load_rating()
outcome_status.text = "Loading official outcome..."
ExamSimulationService.load_outcome() ExamSimulationService.load_outcome()
func _refresh_ccs_rating() -> void: func _build() -> void:
ccs_rating_label.text = "CCS ELO %d | %d rated cases" % [CCSService.rating.get("elo", 1000), CCSService.rating.get("ratedCaseCount", 0)] for child in get_children():
remove_child(child)
child.free()
identity_values.clear()
metric_values.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)
page = VBoxContainer.new()
page.size_flags_horizontal = Control.SIZE_EXPAND_FILL
page.add_theme_constant_override("separation", 14)
scroll.add_child(page)
status_label = _label("", 13, Brand.muted_color(_dark()))
status_label.visible = false
page.add_child(status_label)
columns = GridContainer.new()
columns.columns = 2
columns.size_flags_horizontal = Control.SIZE_EXPAND_FILL
columns.add_theme_constant_override("h_separation", 14)
columns.add_theme_constant_override("v_separation", 14)
page.add_child(columns)
var left := VBoxContainer.new()
left.size_flags_horizontal = Control.SIZE_EXPAND_FILL
left.add_theme_constant_override("separation", 14)
columns.add_child(left)
left.add_child(_profile_summary_card())
left.add_child(_study_identity_card())
left.add_child(_performance_card())
left.add_child(_systems_card())
var right := VBoxContainer.new()
right.custom_minimum_size.x = 410
right.size_flags_horizontal = Control.SIZE_EXPAND_FILL
right.add_theme_constant_override("separation", 14)
columns.add_child(right)
right.add_child(_outcome_card())
right.add_child(_account_actions_card())
_update_responsive()
_render()
func _build_outcome_panel(parent: VBoxContainer) -> void: func _profile_summary_card() -> PanelContainer:
var panel := PanelContainer.new() var body := VBoxContainer.new()
panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Color("405475"))) body.add_theme_constant_override("separation", 14)
parent.add_child(panel) body.add_child(_section_header("Profile Summary", "Your MedMind account"))
var section := VBoxContainer.new() var row := HBoxContainer.new()
section.add_theme_constant_override("separation", 10) row.add_theme_constant_override("separation", 18)
panel.add_child(section) var avatar := PanelContainer.new()
var heading := Label.new() avatar.custom_minimum_size = Vector2(74, 74)
heading.text = "Official Step 3 Outcome" avatar.add_theme_stylebox_override("panel", Brand.icon_circle_box(_dark(), Brand.PURPLE, 37, true))
Style.set_label(heading, 22, Style.TEXT) var avatar_icon := TextureRect.new()
section.add_child(heading) avatar_icon.texture = load("res://assets/icons/lucide/user-round.svg")
var note := Label.new() avatar_icon.custom_minimum_size = Vector2(34, 34)
note.text = "After receiving your official result, you may voluntarily submit it. You can edit or delete it at any time; calibration uses de-identified aggregates." avatar_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
note.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART avatar_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
Style.set_label(note, 15, Style.MUTED) avatar.add_child(avatar_icon)
section.add_child(note) row.add_child(avatar)
outcome_grid = GridContainer.new() var copy := VBoxContainer.new()
outcome_grid.add_theme_constant_override("h_separation", 10) copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL
outcome_grid.add_theme_constant_override("v_separation", 10) profile_name = _label("Loading profile...", 22, Brand.text_color(_dark()), true)
section.add_child(outcome_grid) copy.add_child(profile_name)
profile_identity = _label("", 14, Brand.muted_color(_dark()))
copy.add_child(profile_identity)
profile_meta = _label("", 13, Brand.muted_color(_dark()))
copy.add_child(profile_meta)
row.add_child(copy)
var edit := _button("Edit profile", "pencil", false)
edit.size_flags_vertical = Control.SIZE_SHRINK_CENTER
edit.pressed.connect(_open_profile_editor)
row.add_child(edit)
body.add_child(row)
return _card(body)
func _study_identity_card() -> PanelContainer:
var body := VBoxContainer.new()
body.add_theme_constant_override("separation", 12)
var header := _header_with_action("Study Identity", "Your preparation plan", "Edit", _open_study_editor)
body.add_child(header)
var grid := GridContainer.new()
grid.columns = 2
grid.add_theme_constant_override("h_separation", 20)
grid.add_theme_constant_override("v_separation", 12)
for item in [
["exam", "Target exam date"], ["stage", "Current stage"],
["goal", "Weekly study goal"], ["mode", "Preferred study mode"]
]:
var box := VBoxContainer.new()
box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
box.add_child(_label(item[1], 12, Brand.muted_color(_dark())))
var value := _label("--", 15, Brand.text_color(_dark()), true)
identity_values[item[0]] = value
box.add_child(value)
grid.add_child(box)
body.add_child(grid)
return _card(body)
func _performance_card() -> PanelContainer:
var body := VBoxContainer.new()
body.add_theme_constant_override("separation", 12)
body.add_child(_section_header("Performance Summary", "Your cumulative MedMind activity"))
var grid := GridContainer.new()
grid.columns = 3
grid.add_theme_constant_override("h_separation", 10)
grid.add_theme_constant_override("v_separation", 10)
for item in [
["qbank", "Question Bank rating", Brand.ROYAL_BLUE], ["ccs", "CCS rating", Brand.PURPLE],
["accuracy", "First-attempt accuracy", Brand.TEAL], ["questions", "Questions completed", Brand.ROYAL_BLUE],
["cases", "CCS cases completed", Brand.PURPLE], ["streak", "Current streak", Brand.AMBER]
]:
var metric := VBoxContainer.new()
metric.size_flags_horizontal = Control.SIZE_EXPAND_FILL
metric.add_child(_label(item[1], 11, Brand.muted_color(_dark())))
var value := _label("--", 23, item[2], true)
metric_values[item[0]] = value
metric.add_child(value)
grid.add_child(metric)
body.add_child(grid)
return _card(body)
func _systems_card() -> PanelContainer:
var body := VBoxContainer.new()
body.add_theme_constant_override("separation", 10)
var header := _header_with_action("System Performance", "Accuracy by Step 3 system", "View analytics", func(): main.show_screen("dashboard"))
body.add_child(header)
systems_list = VBoxContainer.new()
systems_list.add_theme_constant_override("separation", 8)
body.add_child(systems_list)
return _card(body)
func _outcome_card() -> PanelContainer:
var body := VBoxContainer.new()
body.add_theme_constant_override("separation", 10)
body.add_child(_section_header("Official Step 3 Outcome", "Optionally help calibrate future predictions"))
outcome_score = SpinBox.new() outcome_score = SpinBox.new()
outcome_score.min_value = 1 outcome_score.min_value = 1
outcome_score.max_value = 300 outcome_score.max_value = 300
outcome_score.value = 200 outcome_score.value = 200
outcome_score.custom_minimum_size = Vector2(120, 44) outcome_score.custom_minimum_size.y = 44
outcome_score.size_flags_horizontal = Control.SIZE_EXPAND_FILL body.add_child(_field("Official score", outcome_score))
outcome_grid.add_child(outcome_score)
outcome_date = LineEdit.new() outcome_date = LineEdit.new()
outcome_date.placeholder_text = "Exam date: YYYY-MM-DD" outcome_date.placeholder_text = "YYYY-MM-DD"
outcome_date.custom_minimum_size = Vector2(220, 44) _style_field(outcome_date)
outcome_date.size_flags_horizontal = Control.SIZE_EXPAND_FILL body.add_child(_field("Exam date", outcome_date))
outcome_grid.add_child(outcome_date)
outcome_consent = CheckBox.new() outcome_consent = CheckBox.new()
outcome_consent.text = "I consent to calibration use" outcome_consent.text = "I consent to de-identified calibration use"
outcome_consent.size_flags_horizontal = Control.SIZE_EXPAND_FILL body.add_child(outcome_consent)
outcome_grid.add_child(outcome_consent) var row := HBoxContainer.new()
var actions := HBoxContainer.new() row.add_theme_constant_override("separation", 8)
actions.add_theme_constant_override("separation", 8) var save := _button("Save outcome", "circle-check", true)
outcome_grid.add_child(actions) save.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var save_button := Button.new() save.pressed.connect(_save_outcome)
save_button.text = "Save outcome" row.add_child(save)
Style.style_chip(save_button, Style.CYAN) outcome_delete = _button("Delete", "x", false)
save_button.pressed.connect(_save_outcome) outcome_delete.pressed.connect(ExamSimulationService.delete_outcome)
actions.add_child(save_button) row.add_child(outcome_delete)
outcome_delete_button = Button.new() body.add_child(row)
outcome_delete_button.text = "Delete outcome" outcome_status = _label("No official outcome is stored.", 12, Brand.muted_color(_dark()))
Style.style_chip(outcome_delete_button, Style.RED)
outcome_delete_button.pressed.connect(_delete_outcome)
actions.add_child(outcome_delete_button)
outcome_status = Label.new()
outcome_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART outcome_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
Style.set_label(outcome_status, 15, Style.MUTED) body.add_child(outcome_status)
section.add_child(outcome_status) return _card(body)
func _refresh_outcome_layout() -> void: func _account_actions_card() -> PanelContainer:
if outcome_grid != null: var body := VBoxContainer.new()
outcome_grid.columns = 1 if size.x < 900.0 else 4 body.add_theme_constant_override("separation", 9)
body.add_child(_section_header("Account Actions", "Security and data controls"))
var password := _action_button("Change password", "settings")
password.pressed.connect(_open_password_dialog)
body.add_child(password)
var export := _action_button("Export my data", "copy")
export.pressed.connect(func(): AccountProfileService.export_archive())
body.add_child(export)
var delete := _action_button("Delete account permanently", "x", Brand.MAGENTA)
delete.pressed.connect(_open_delete_dialog)
body.add_child(delete)
return _card(body)
func _render() -> void:
if profile_name == null:
return
var account: Dictionary = AccountProfileService.account
var prefs: Dictionary = AccountProfileService.preferences
var username := str(account.get("username", AuthState.username))
var display_name := str(account.get("displayName", "")).strip_edges()
profile_name.text = username if display_name.is_empty() else display_name
var email := str(account.get("email", "")).strip_edges()
profile_identity.text = "@%s" % username if email.is_empty() else "@%s | %s" % [username, email]
var role := str(account.get("role", AuthState.role)).capitalize()
var created := _date_only(str(account.get("createdUtc", "")))
profile_meta.text = role if created.is_empty() else "%s | Member since %s" % [role, created]
(identity_values["exam"] as Label).text = _date_only(str(prefs.get("targetExamDate", "")), "Not scheduled")
(identity_values["stage"] as Label).text = _friendly(str(prefs.get("preparationStage", "pre_dedicated")))
(identity_values["goal"] as Label).text = "%d hours / week" % int(prefs.get("weeklyGoalHours", 10))
(identity_values["mode"] as Label).text = _friendly(str(prefs.get("preferredStudyMode", "mixed")))
var data: Dictionary = DashboardService.data
_set_metric("qbank", str(int(data.get("questionBankRating", QuestionBankService.overall_elo))))
_set_metric("ccs", str(int(data.get("ccsRating", CCSService.rating.get("elo", 1000)))))
_set_metric("accuracy", "%d%%" % int(data.get("firstAttemptAccuracy", 0)))
_set_metric("questions", str(int(data.get("questionsCompleted", 0))))
_set_metric("cases", str(int(data.get("ccsCasesCompleted", 0))))
_set_metric("streak", "%d days" % int(data.get("currentStreak", 0)))
_render_systems(data.get("systems", []))
_render_outcome()
func _render_systems(systems: Array = DashboardService.data.get("systems", [])) -> void:
if systems_list == null:
return
_clear(systems_list)
if systems.is_empty():
systems_list.add_child(_label("Complete questions to build your system breakdown.", 13, Brand.muted_color(_dark())))
return
var sorted := systems.duplicate(true)
sorted.sort_custom(func(a: Dictionary, b: Dictionary): return int(a.get("total", 0)) > int(b.get("total", 0)))
for item in sorted.slice(0, mini(8, sorted.size())):
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 10)
var title := _label(str(item.get("system", "General")), 13, Brand.text_color(_dark()))
title.custom_minimum_size.x = 210
title.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
row.add_child(title)
var bar := ProgressBar.new()
bar.show_percentage = false
bar.value = int(item.get("accuracy", 0))
bar.custom_minimum_size = Vector2(160, 7)
bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
bar.size_flags_vertical = Control.SIZE_SHRINK_CENTER
_style_progress(bar, Brand.ROYAL_BLUE)
row.add_child(bar)
var detail := _label("%d%% %d / %d" % [int(item.get("accuracy", 0)), int(item.get("correct", 0)), int(item.get("total", 0))], 12, Brand.muted_color(_dark()))
detail.custom_minimum_size.x = 110
row.add_child(detail)
systems_list.add_child(row)
func _render_outcome() -> void:
if outcome_status == null:
return
var outcome: Dictionary = ExamSimulationService.outcome
if outcome.is_empty():
outcome_status.text = "No official outcome is stored."
outcome_delete.visible = false
return
outcome_score.value = int(outcome.get("officialScore", 200))
outcome_date.text = _date_only(str(outcome.get("examDate", "")))
outcome_consent.button_pressed = true
outcome_status.text = "Saved score %d for %s." % [int(outcome_score.value), outcome_date.text]
outcome_delete.visible = true
func _open_profile_editor() -> void:
var dialog := _dialog("Edit profile")
var display := LineEdit.new()
display.placeholder_text = "Display name"
display.text = str(AccountProfileService.account.get("displayName", ""))
_style_field(display)
dialog.add_field(display)
var email := LineEdit.new()
email.placeholder_text = "Email address"
email.text = str(AccountProfileService.account.get("email", ""))
_style_field(email)
dialog.add_field(email)
dialog.confirmed.connect(func(): AccountProfileService.update_account(display.text, email.text))
add_child(dialog)
dialog.popup_centered(Vector2i(520, 260))
func _open_study_editor() -> void:
var prefs: Dictionary = AccountProfileService.preferences
var dialog := _dialog("Edit study identity")
var exam := LineEdit.new()
exam.placeholder_text = "Target exam date: YYYY-MM-DD"
exam.text = _date_only(str(prefs.get("targetExamDate", "")))
_style_field(exam)
dialog.add_field(exam)
var stage := OptionButton.new()
_add_options(stage, [["Pre-dedicated", "pre_dedicated"], ["Dedicated", "dedicated"], ["Final review", "final_review"]], str(prefs.get("preparationStage", "pre_dedicated")))
dialog.add_field(stage)
var goal := OptionButton.new()
for hours in [5, 10, 15, 20, 25]: goal.add_item("%d hours per week" % hours, hours)
_select_id(goal, int(prefs.get("weeklyGoalHours", 10)))
dialog.add_field(goal)
var mode := OptionButton.new()
_add_options(mode, [["Mixed", "mixed"], ["Question Bank", "question_bank"], ["CCS", "ccs"], ["Exam simulation", "exam_simulation"]], str(prefs.get("preferredStudyMode", "mixed")))
dialog.add_field(mode)
dialog.confirmed.connect(func():
AccountProfileService.update_preferences(exam.text, str(stage.get_item_metadata(stage.selected)), goal.get_selected_id(), str(mode.get_item_metadata(mode.selected))))
add_child(dialog)
dialog.popup_centered(Vector2i(540, 390))
func _open_password_dialog() -> void:
var dialog := _dialog("Change password")
var current := _password_field("Current password")
var replacement := _password_field("New password (8+ characters)")
var confirmation := _password_field("Confirm new password")
dialog.add_field(current)
dialog.add_field(replacement)
dialog.add_field(confirmation)
dialog.confirmed.connect(func():
if replacement.text != confirmation.text or replacement.text.length() < 8:
_show_error("New passwords must match and contain at least 8 characters.")
return
AccountProfileService.change_password(current.text, replacement.text))
add_child(dialog)
dialog.popup_centered(Vector2i(520, 330))
func _open_delete_dialog() -> void:
var dialog := _dialog("Permanently delete account")
var warning := _label("This permanently removes your account and all MedMind study data. This cannot be undone.", 13, Brand.MAGENTA)
warning.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
dialog.add_field(warning)
var password := _password_field("Current password")
dialog.add_field(password)
var confirmation := LineEdit.new()
confirmation.placeholder_text = "Type DELETE"
_style_field(confirmation)
dialog.add_field(confirmation)
dialog.confirmed.connect(func(): AccountProfileService.delete_account(password.text, confirmation.text))
add_child(dialog)
dialog.popup_centered(Vector2i(540, 330))
func _save_outcome() -> void:
var error := validate_outcome(int(outcome_score.value), outcome_date.text, outcome_consent.button_pressed)
if not error.is_empty():
_show_error(error)
return
ExamSimulationService.save_outcome(int(outcome_score.value), outcome_date.text.strip_edges())
static func validate_outcome(score: int, exam_date: String, consent: bool) -> String: static func validate_outcome(score: int, exam_date: String, consent: bool) -> String:
if score < 1 or score > 300: if score < 1 or score > 300:
return "Enter an official score from 1 through 300." return "Enter an official score from 1 through 300."
if RegEx.create_from_string("^\\d{4}-\\d{2}-\\d{2}$").search(exam_date.strip_edges()) == null:
return "Enter the exam date as YYYY-MM-DD."
if not consent: if not consent:
return "Consent is required before saving an outcome." return "Consent is required before saving an outcome."
var date_pattern := RegEx.new()
date_pattern.compile("^\\d{4}-\\d{2}-\\d{2}$")
if date_pattern.search(exam_date) == null:
return "Enter the exam date as YYYY-MM-DD."
return "" return ""
func _save_outcome() -> void: func _update_responsive() -> void:
var validation_error := validate_outcome(int(outcome_score.value), outcome_date.text.strip_edges(), outcome_consent.button_pressed) if columns != null:
if not validation_error.is_empty(): columns.columns = 1 if size.x < 1050.0 else 2
outcome_status.text = validation_error
return
outcome_status.text = "Saving..."
ExamSimulationService.save_outcome(int(outcome_score.value), outcome_date.text.strip_edges())
func _delete_outcome() -> void: func _rebuild(_is_dark: bool) -> void:
outcome_status.text = "Deleting..." _build()
ExamSimulationService.delete_outcome()
func _refresh_outcome() -> void: func _show_error(message: String) -> void:
if ExamSimulationService.outcome.is_empty(): status_label.text = message
outcome_score.value = 200 status_label.add_theme_color_override("font_color", Brand.MAGENTA)
outcome_date.text = "" status_label.visible = true
outcome_consent.button_pressed = false
outcome_delete_button.visible = false
outcome_status.text = "No official outcome is stored."
return
outcome_score.value = int(ExamSimulationService.outcome.get("officialScore", 200))
outcome_date.text = str(ExamSimulationService.outcome.get("examDate", "")).left(10)
outcome_consent.button_pressed = true
outcome_delete_button.visible = true
outcome_status.text = "Official outcome saved."
func _on_outcome_request_failed(message: String) -> void: func _show_saved(message: String) -> void:
if visible: status_label.text = message
outcome_status.text = message status_label.add_theme_color_override("font_color", Brand.SUCCESS)
status_label.visible = true
get_tree().create_timer(3.0).timeout.connect(func(): if is_instance_valid(status_label): status_label.visible = false)
func _on_reset_qbank_pressed() -> void: func _card(child: Control) -> PanelContainer:
if not reset_qbank_pending: var panel := PanelContainer.new()
reset_qbank_pending = true panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
reset_qbank_button.text = "Confirm Reset QBank" var box := Brand.content_card_box(_dark(), false)
Style.style_chip(reset_qbank_button, Style.RED) box.content_margin_left = 20
return box.content_margin_right = 20
box.content_margin_top = 18
ProfileService.reset_qbank_memory() box.content_margin_bottom = 18
if BlockSessionService.core_status_message == "All available questions cleared.": panel.add_theme_stylebox_override("panel", box)
BlockSessionService.core_status_message = "" panel.add_child(child)
_reset_qbank_confirmation() return panel
on_show()
func _reset_qbank_confirmation() -> void: func _section_header(title: String, subtitle: String) -> VBoxContainer:
reset_qbank_pending = false var box := VBoxContainer.new()
if reset_qbank_button != null: box.add_child(_label(title, 18, Brand.text_color(_dark()), true))
reset_qbank_button.text = "Reset QBank" box.add_child(_label(subtitle, 12, Brand.muted_color(_dark())))
Style.style_chip(reset_qbank_button, Style.AMBER) return box
func _header_with_action(title: String, subtitle: String, action: String, callback: Callable) -> HBoxContainer:
var row := HBoxContainer.new()
var heading := _section_header(title, subtitle)
heading.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(heading)
var button := _button(action, "pencil", false)
button.pressed.connect(callback)
row.add_child(button)
return row
func _button(text: String, icon_name: String = "", primary: bool = false) -> 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", 13)
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", 17)
if primary:
button.add_theme_stylebox_override("normal", Brand.gradient_button_box())
button.add_theme_color_override("font_color", Color.WHITE)
else:
button.add_theme_stylebox_override("normal", Brand.secondary_button_box(_dark()))
return button
func _action_button(text: String, icon_name: String, color: Color = Color.TRANSPARENT) -> Button:
var button := _button(text, icon_name, false)
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
if color != Color.TRANSPARENT:
button.add_theme_color_override("font_color", color)
button.add_theme_color_override("icon_normal_color", color)
return button
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)
return label
func _field(caption: String, control: Control) -> VBoxContainer:
var box := VBoxContainer.new()
box.add_child(_label(caption, 12, Brand.muted_color(_dark())))
box.add_child(control)
return box
func _dialog(title: String) -> ProfileDialog:
var dialog := ProfileDialog.new()
dialog.title = title
dialog.ok_button_text = "Save"
dialog.add_theme_font_override("font", Brand.font)
dialog.fields.custom_minimum_size.x = 450
return dialog
func _password_field(placeholder: String) -> LineEdit:
var field := LineEdit.new()
field.placeholder_text = placeholder
field.secret = true
field.secret_character = "•"
_style_field(field)
return field
func _style_field(field: LineEdit) -> void:
field.custom_minimum_size.y = 46
field.add_theme_font_override("font", Brand.font)
field.add_theme_stylebox_override("normal", Brand.field_box(_dark()))
field.add_theme_stylebox_override("focus", Brand.field_box(_dark(), true))
func _style_progress(bar: ProgressBar, accent: Color) -> void:
var background := StyleBoxFlat.new()
background.bg_color = Color("26324a") if _dark() 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 _set_metric(key: String, value: String) -> void:
if metric_values.has(key): (metric_values[key] as Label).text = value
func _clear(container: Container) -> void:
for child in container.get_children():
container.remove_child(child)
child.free()
func _date_only(value: String, fallback: String = "") -> String:
if value.is_empty() or value == "<null>": return fallback
return value.left(10)
func _friendly(value: String) -> String:
return value.replace("_", " ").capitalize()
func _dark() -> bool:
return UIThemeService.is_dark_mode()
func _add_options(control: OptionButton, options: Array, selected_value: String) -> void:
for option in options:
control.add_item(str(option[0]))
control.set_item_metadata(control.item_count - 1, option[1])
if str(option[1]) == selected_value: control.select(control.item_count - 1)
func _select_id(control: OptionButton, id: int) -> void:
for index in control.item_count:
if control.get_item_id(index) == id: control.select(index)
class ProfileDialog extends AcceptDialog:
var fields: VBoxContainer
func _init() -> void:
fields = VBoxContainer.new()
fields.add_theme_constant_override("separation", 10)
add_child(fields)
func add_field(node: Control) -> void:
fields.add_child(node)
-8
View File
@@ -7,7 +7,6 @@ const REVIEW_ICON := preload("res://assets/icons/lucide/chart-no-axes-column-inc
var main: Control var main: Control
var page: VBoxContainer var page: VBoxContainer
var title_label: Label
var active_section: PanelContainer var active_section: PanelContainer
var active_content: VBoxContainer var active_content: VBoxContainer
var history_section: PanelContainer var history_section: PanelContainer
@@ -51,12 +50,6 @@ func _build_ui() -> void:
page.add_theme_constant_override("separation", 18) page.add_theme_constant_override("separation", 18)
scroll.add_child(page) scroll.add_child(page)
title_label = Label.new()
title_label.text = "Exams"
Brand.apply_font(title_label)
title_label.add_theme_font_size_override("font_size", 27)
page.add_child(title_label)
active_section = PanelContainer.new() active_section = PanelContainer.new()
active_section.name = "ActiveSimulationSection" active_section.name = "ActiveSimulationSection"
page.add_child(active_section) page.add_child(active_section)
@@ -161,7 +154,6 @@ func refresh_theme_controls() -> void:
if page == null: if page == null:
return return
var dark := UIThemeService.is_dark_mode() var dark := UIThemeService.is_dark_mode()
title_label.add_theme_color_override("font_color", Brand.text_color(dark))
active_section.add_theme_stylebox_override("panel", _section_box(dark)) active_section.add_theme_stylebox_override("panel", _section_box(dark))
history_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)) status_label.add_theme_color_override("font_color", Brand.muted_color(dark))
+15 -8
View File
@@ -148,40 +148,47 @@ static func style_chip(button: Button, color: Color) -> void:
static func refresh_tree(root: Control, was_dark: bool) -> void: static func refresh_tree(root: Control, was_dark: bool) -> void:
apply_app_theme(root) apply_app_theme(root)
_translate_control(root, DARK_PALETTE if was_dark else LIGHT_PALETTE, LIGHT_PALETTE if was_dark else DARK_PALETTE) _translate_control(
root,
DARK_PALETTE if was_dark else LIGHT_PALETTE,
LIGHT_PALETTE if was_dark else DARK_PALETTE,
was_dark
)
static func _translate_control(control: Control, source: Dictionary, target: Dictionary) -> void: static func _translate_control(control: Control, source: Dictionary, target: Dictionary, from_dark: bool) -> void:
if control is ColorRect: if control is ColorRect:
control.color = _translate_color(control.color, source, target) control.color = _translate_color(control.color, source, target, from_dark)
for color_name in ["font_color", "font_hover_color", "font_pressed_color", "font_disabled_color", "font_selected_color", "default_color", "font_uneditable_color"]: for color_name in ["font_color", "font_hover_color", "font_pressed_color", "font_disabled_color", "font_selected_color", "default_color", "font_uneditable_color"]:
if control.has_theme_color_override(color_name): if control.has_theme_color_override(color_name):
control.add_theme_color_override(color_name, _translate_color(control.get_theme_color(color_name), source, target)) control.add_theme_color_override(color_name, _translate_color(control.get_theme_color(color_name), source, target, from_dark))
for style_name in ["panel", "normal", "hover", "pressed", "disabled", "focus", "fill", "background", "read_only"]: for style_name in ["panel", "normal", "hover", "pressed", "disabled", "focus", "fill", "background", "read_only"]:
if control.has_theme_stylebox_override(style_name): if control.has_theme_stylebox_override(style_name):
var box := control.get_theme_stylebox(style_name) var box := control.get_theme_stylebox(style_name)
if box is StyleBoxFlat: if box is StyleBoxFlat:
var translated := box.duplicate() as StyleBoxFlat var translated := box.duplicate() as StyleBoxFlat
translated.bg_color = _translate_color(translated.bg_color, source, target) translated.bg_color = _translate_color(translated.bg_color, source, target, from_dark)
translated.border_color = _translate_color(translated.border_color, source, target) translated.border_color = _translate_color(translated.border_color, source, target, from_dark)
control.add_theme_stylebox_override(style_name, translated) control.add_theme_stylebox_override(style_name, translated)
for child in control.get_children(): for child in control.get_children():
if child is Control: if child is Control:
_translate_control(child, source, target) _translate_control(child, source, target, from_dark)
static func _translate_color(color: Color, source: Dictionary, target: Dictionary) -> Color: static func _translate_color(color: Color, source: Dictionary, target: Dictionary, from_dark: bool) -> Color:
for key in source: for key in source:
var candidate: Color = source[key] var candidate: Color = source[key]
if color.is_equal_approx(candidate): if color.is_equal_approx(candidate):
var replacement: Color = target[key] var replacement: Color = target[key]
replacement.a = color.a replacement.a = color.a
return replacement return replacement
if from_dark:
for dark_color in CUSTOM_DARK_TO_LIGHT: for dark_color in CUSTOM_DARK_TO_LIGHT:
if color.is_equal_approx(dark_color): if color.is_equal_approx(dark_color):
var replacement: Color = CUSTOM_DARK_TO_LIGHT[dark_color] var replacement: Color = CUSTOM_DARK_TO_LIGHT[dark_color]
replacement.a = color.a replacement.a = color.a
return replacement return replacement
else:
for dark_color in CUSTOM_DARK_TO_LIGHT: for dark_color in CUSTOM_DARK_TO_LIGHT:
var light_color: Color = CUSTOM_DARK_TO_LIGHT[dark_color] var light_color: Color = CUSTOM_DARK_TO_LIGHT[dark_color]
if color.is_equal_approx(light_color): if color.is_equal_approx(light_color):
+11
View File
@@ -28,6 +28,12 @@ func _initialize() -> void:
_assert(light_card.border_color.is_equal_approx(Brand.ROYAL_BLUE), "active light cards should use the primary blue border") _assert(light_card.border_color.is_equal_approx(Brand.ROYAL_BLUE), "active light cards should use the primary blue border")
var light_field := Brand.field_box(false) var light_field := Brand.field_box(false)
_assert(light_field.border_color.is_equal_approx(Brand.LIGHT_BORDER), "light fields should use the semantic cool-gray border") _assert(light_field.border_color.is_equal_approx(Brand.LIGHT_BORDER), "light fields should use the semantic cool-gray border")
var explicitly_light_panel := PanelContainer.new()
explicitly_light_panel.add_theme_stylebox_override("panel", Brand.content_card_box(false, false))
root.add_child(explicitly_light_panel)
Style.refresh_tree(root, true)
var preserved_light_box := explicitly_light_panel.get_theme_stylebox("panel") as StyleBoxFlat
_assert(preserved_light_box.bg_color.is_equal_approx(Color.WHITE), "a screen's explicit light styles should not be translated back to dark")
var theme_toggle := Button.new() var theme_toggle := Button.new()
Brand.set_theme_toggle_icon(theme_toggle, true) Brand.set_theme_toggle_icon(theme_toggle, true)
_assert(theme_toggle.text.is_empty() and theme_toggle.icon == Brand.SUN_ICON, "dark mode should use the bundled sun icon") _assert(theme_toggle.text.is_empty() and theme_toggle.icon == Brand.SUN_ICON, "dark mode should use the bundled sun icon")
@@ -50,6 +56,11 @@ func _initialize() -> void:
var auth_field: LineEdit = login._add_field(field_parent, "Email", "[email protected]") var auth_field: LineEdit = login._add_field(field_parent, "Email", "[email protected]")
_assert(auth_field.caret_blink, "authentication fields should display a blinking caret") _assert(auth_field.caret_blink, "authentication fields should display a blinking caret")
_assert(is_equal_approx(auth_field.caret_blink_interval, 0.5), "authentication field caret should blink at a visible interval") _assert(is_equal_approx(auth_field.caret_blink_interval, 0.5), "authentication field caret should blink at a visible interval")
login._style_auth_field(auth_field, false)
var editable_box := auth_field.get_theme_stylebox("normal") as StyleBoxFlat
var read_only_box := auth_field.get_theme_stylebox("read_only") as StyleBoxFlat
_assert(read_only_box.bg_color.is_equal_approx(editable_box.bg_color), "authentication fields should retain their surface while a request is pending")
_assert(is_equal_approx(read_only_box.content_margin_left, editable_box.content_margin_left), "authentication fields should retain their text inset while a request is pending")
login.free() login.free()
theme_toggle.free() theme_toggle.free()
+21
View File
@@ -72,6 +72,27 @@ func _run() -> void:
var question_bank_nav := main_screen.sidebar.find_child("NavQuestionBank", true, false) as Button var question_bank_nav := main_screen.sidebar.find_child("NavQuestionBank", true, false) as Button
_assert(question_bank_nav != null and question_bank_nav.text == "Question Bank", "the Practice navigation item should be replaced by Question Bank") _assert(question_bank_nav != null and question_bank_nav.text == "Question Bank", "the Practice navigation item should be replaced by Question Bank")
_assert(main_screen.sidebar.find_child("NavPractice", true, false) == null, "the sidebar should not retain a Practice navigation item") _assert(main_screen.sidebar.find_child("NavPractice", true, false) == null, "the sidebar should not retain a Practice navigation item")
var profile_nav := main_screen.sidebar.find_child("NavPerformance", true, false) as Button
var settings_nav := main_screen.sidebar.find_child("NavSettings", true, false) as Button
_assert(profile_nav != null and settings_nav != null and profile_nav.get_index() + 1 == settings_nav.get_index(), "Profile should sit directly above Settings in the lower navigation group")
var theme_service = root.get_node("UIThemeService")
var prior_dark_mode: bool = theme_service.is_dark_mode()
theme_service.set_dark_mode(true)
await process_frame
theme_service.set_dark_mode(false)
await process_frame
var dashboard = main_screen.screens.get("dashboard")
var dashboard_card := dashboard.metric_grid.get_parent() as PanelContainer
var dashboard_box := dashboard_card.get_theme_stylebox("panel") as StyleBoxFlat
_assert(dashboard_box.bg_color.is_equal_approx(Color.WHITE), "dashboard cards should remain light after the shell refresh")
var simulations = main_screen.screens.get("simulation_history")
var simulation_box := simulations.active_section.get_theme_stylebox("panel") as StyleBoxFlat
_assert(simulation_box.bg_color.is_equal_approx(simulations._section_box(false).bg_color), "exam simulation panels should remain light after the shell refresh")
var notes_screen = main_screen.screens.get("notes")
var notes_box := notes_screen.notes_editor.editor.get_theme_stylebox("normal") as StyleBoxFlat
_assert(notes_box.bg_color.is_equal_approx(Color.WHITE), "the Notes editor should remain light after the shell refresh")
theme_service.set_dark_mode(prior_dark_mode)
await process_frame
host.queue_free() host.queue_free()
await process_frame await process_frame