Implement responsive profile and account data controls
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
+26
-19
@@ -148,46 +148,53 @@ 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
|
||||||
for dark_color in CUSTOM_DARK_TO_LIGHT:
|
if from_dark:
|
||||||
if color.is_equal_approx(dark_color):
|
for dark_color in CUSTOM_DARK_TO_LIGHT:
|
||||||
var replacement: Color = CUSTOM_DARK_TO_LIGHT[dark_color]
|
if color.is_equal_approx(dark_color):
|
||||||
replacement.a = color.a
|
var replacement: Color = CUSTOM_DARK_TO_LIGHT[dark_color]
|
||||||
return replacement
|
replacement.a = color.a
|
||||||
for dark_color in CUSTOM_DARK_TO_LIGHT:
|
return replacement
|
||||||
var light_color: Color = CUSTOM_DARK_TO_LIGHT[dark_color]
|
else:
|
||||||
if color.is_equal_approx(light_color):
|
for dark_color in CUSTOM_DARK_TO_LIGHT:
|
||||||
var replacement: Color = dark_color
|
var light_color: Color = CUSTOM_DARK_TO_LIGHT[dark_color]
|
||||||
replacement.a = color.a
|
if color.is_equal_approx(light_color):
|
||||||
return replacement
|
var replacement: Color = dark_color
|
||||||
|
replacement.a = color.a
|
||||||
|
return replacement
|
||||||
return color
|
return color
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user