Sync text zoom preference through the account instead of local settings
Local user://settings.cfg meant zoom level didn't follow a student across devices/browsers. Question bank and CCS text scale now live on the user's study profile in Mongo (QuestionTextScale/CcsTextScale), synced through a new PUT /api/profile/me/text-scale endpoint and the existing preferences GET/cache already used for study preferences. Both question_player.gd and ccs_service.gd read the synced value at startup and react to AccountProfileService.profile_changed so a value pulled down after login (or changed from another session) applies without a restart. Backend validates the 0.85-1.35 range and updates only the field(s) provided via a targeted $set, so it can't clobber the other scale or the unrelated study-profile preferences.
This commit is contained in:
@@ -0,0 +1,22 @@
|
|||||||
|
using QuestionsApi.Models;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace QuestionsApi.Tests;
|
||||||
|
|
||||||
|
public sealed class UserProfileModelsTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null, null, true)]
|
||||||
|
[InlineData(1.0, 1.0, true)]
|
||||||
|
[InlineData(0.85, 1.35, true)]
|
||||||
|
[InlineData(0.84, null, false)]
|
||||||
|
[InlineData(null, 1.36, false)]
|
||||||
|
public void UpdateTextScaleRequestValidator_EnforcesRange(double? questionScale, double? ccsScale, bool expectedValid)
|
||||||
|
{
|
||||||
|
var request = new UpdateTextScaleRequest { QuestionTextScale = questionScale, CcsTextScale = ccsScale };
|
||||||
|
|
||||||
|
var error = UpdateTextScaleRequestValidator.Validate(request);
|
||||||
|
|
||||||
|
Assert.Equal(expectedValid, error is null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,14 @@ public sealed class ProfileController(UserProfileStore profiles, IConfiguration
|
|||||||
return Ok(await profiles.UpsertAsync(UserId(), request));
|
return Ok(await profiles.UpsertAsync(UserId(), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPut("me/text-scale")]
|
||||||
|
[Authorize(Roles = "USER,SUPER")]
|
||||||
|
public async Task<IActionResult> UpdateTextScale(UpdateTextScaleRequest request)
|
||||||
|
{
|
||||||
|
if (UpdateTextScaleRequestValidator.Validate(request) is { } error) return BadRequest(error);
|
||||||
|
return Ok(await profiles.UpdateTextScaleAsync(UserId(), request));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("me/export")]
|
[HttpGet("me/export")]
|
||||||
[Authorize(Roles = "USER,SUPER")]
|
[Authorize(Roles = "USER,SUPER")]
|
||||||
public async Task<IActionResult> Export() => Ok(new
|
public async Task<IActionResult> Export() => Ok(new
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ public sealed class UserStudyProfile
|
|||||||
public string PreparationStage { get; set; } = "pre_dedicated";
|
public string PreparationStage { get; set; } = "pre_dedicated";
|
||||||
public int WeeklyGoalHours { get; set; } = 10;
|
public int WeeklyGoalHours { get; set; } = 10;
|
||||||
public string PreferredStudyMode { get; set; } = "mixed";
|
public string PreferredStudyMode { get; set; } = "mixed";
|
||||||
|
public double QuestionTextScale { get; set; } = 1.0;
|
||||||
|
public double CcsTextScale { get; set; } = 1.0;
|
||||||
public int Revision { get; set; }
|
public int Revision { get; set; }
|
||||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
@@ -25,6 +27,12 @@ public sealed class UpdateUserStudyProfileRequest
|
|||||||
public string PreferredStudyMode { get; set; } = "mixed";
|
public string PreferredStudyMode { get; set; } = "mixed";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class UpdateTextScaleRequest
|
||||||
|
{
|
||||||
|
public double? QuestionTextScale { get; set; }
|
||||||
|
public double? CcsTextScale { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class DeletedUserTombstone
|
public sealed class DeletedUserTombstone
|
||||||
{
|
{
|
||||||
[BsonId]
|
[BsonId]
|
||||||
@@ -48,3 +56,18 @@ public static class UserStudyProfileValidator
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class UpdateTextScaleRequestValidator
|
||||||
|
{
|
||||||
|
private const double Min = 0.85;
|
||||||
|
private const double Max = 1.35;
|
||||||
|
|
||||||
|
public static string? Validate(UpdateTextScaleRequest request)
|
||||||
|
{
|
||||||
|
if (request.QuestionTextScale is { } questionScale && (questionScale < Min || questionScale > Max))
|
||||||
|
return "Question text scale must be between 0.85 and 1.35.";
|
||||||
|
if (request.CcsTextScale is { } ccsScale && (ccsScale < Min || ccsScale > Max))
|
||||||
|
return "CCS text scale must be between 0.85 and 1.35.";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,6 +50,20 @@ public sealed class UserProfileStore
|
|||||||
new FindOneAndUpdateOptions<UserStudyProfile> { IsUpsert = true, ReturnDocument = ReturnDocument.After });
|
new FindOneAndUpdateOptions<UserStudyProfile> { IsUpsert = true, ReturnDocument = ReturnDocument.After });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<UserStudyProfile> UpdateTextScaleAsync(string userId, UpdateTextScaleRequest request)
|
||||||
|
{
|
||||||
|
var update = Builders<UserStudyProfile>.Update.Set(value => value.UpdatedUtc, DateTime.UtcNow);
|
||||||
|
if (request.QuestionTextScale is { } questionScale)
|
||||||
|
update = update.Set(value => value.QuestionTextScale, questionScale);
|
||||||
|
if (request.CcsTextScale is { } ccsScale)
|
||||||
|
update = update.Set(value => value.CcsTextScale, ccsScale);
|
||||||
|
update = update.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) =>
|
public Task<bool> IsDeletedAsync(string userId) =>
|
||||||
_deletedUsers.Find(value => value.UserId == userId).AnyAsync();
|
_deletedUsers.Find(value => value.UserId == userId).AnyAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,20 @@ func update_preferences(target_exam_date: String, preparation_stage: String, wee
|
|||||||
_request_profile("update_preferences", HTTPClient.METHOD_PUT, "/me/preferences", preferences)
|
_request_profile("update_preferences", HTTPClient.METHOD_PUT, "/me/preferences", preferences)
|
||||||
|
|
||||||
|
|
||||||
|
func update_text_scale(question_text_scale: Variant = null, ccs_text_scale: Variant = null) -> void:
|
||||||
|
var payload := {}
|
||||||
|
if question_text_scale != null:
|
||||||
|
payload["questionTextScale"] = question_text_scale
|
||||||
|
preferences["questionTextScale"] = question_text_scale
|
||||||
|
if ccs_text_scale != null:
|
||||||
|
payload["ccsTextScale"] = ccs_text_scale
|
||||||
|
preferences["ccsTextScale"] = ccs_text_scale
|
||||||
|
if payload.is_empty():
|
||||||
|
return
|
||||||
|
_save_cache(preferences_pending)
|
||||||
|
_request_profile("update_text_scale", HTTPClient.METHOD_PUT, "/me/text-scale", payload)
|
||||||
|
|
||||||
|
|
||||||
func change_password(current_password: String, new_password: String) -> void:
|
func change_password(current_password: String, new_password: String) -> void:
|
||||||
_request_auth("change_password", HTTPClient.METHOD_POST, "/me/change-password", {
|
_request_auth("change_password", HTTPClient.METHOD_POST, "/me/change-password", {
|
||||||
"currentPassword": current_password, "newPassword": new_password
|
"currentPassword": current_password, "newPassword": new_password
|
||||||
@@ -192,7 +206,7 @@ func _on_profile_completed(result: int, response_code: int, _headers: PackedStri
|
|||||||
if typeof(parsed) != TYPE_DICTIONARY:
|
if typeof(parsed) != TYPE_DICTIONARY:
|
||||||
request_failed.emit("The profile service returned invalid data.")
|
request_failed.emit("The profile service returned invalid data.")
|
||||||
return
|
return
|
||||||
if operation in ["preferences", "update_preferences"]:
|
if operation in ["preferences", "update_preferences", "update_text_scale"]:
|
||||||
preferences = parsed
|
preferences = parsed
|
||||||
preferences_pending = false
|
preferences_pending = false
|
||||||
_save_cache(false)
|
_save_cache(false)
|
||||||
@@ -345,4 +359,7 @@ func _error_message(body: PackedByteArray, fallback: String) -> String:
|
|||||||
|
|
||||||
|
|
||||||
static func _default_preferences() -> Dictionary:
|
static func _default_preferences() -> Dictionary:
|
||||||
return {"targetExamDate": null, "preparationStage": "pre_dedicated", "weeklyGoalHours": 10, "preferredStudyMode": "mixed"}
|
return {
|
||||||
|
"targetExamDate": null, "preparationStage": "pre_dedicated", "weeklyGoalHours": 10, "preferredStudyMode": "mixed",
|
||||||
|
"questionTextScale": 1.0, "ccsTextScale": 1.0
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,13 +13,11 @@ signal attempt_abandoned
|
|||||||
signal report_submitted(report_id: String)
|
signal report_submitted(report_id: String)
|
||||||
signal report_failed(message: String)
|
signal report_failed(message: String)
|
||||||
signal request_failed(message: String)
|
signal request_failed(message: String)
|
||||||
|
signal text_scale_changed
|
||||||
|
|
||||||
const TEXT_SCALE_MIN := 0.85
|
const TEXT_SCALE_MIN := 0.85
|
||||||
const TEXT_SCALE_MAX := 1.35
|
const TEXT_SCALE_MAX := 1.35
|
||||||
const TEXT_SCALE_STEP := 0.1
|
const TEXT_SCALE_STEP := 0.1
|
||||||
const TEXT_SCALE_SETTINGS_PATH := "user://settings.cfg"
|
|
||||||
const TEXT_SCALE_SETTINGS_SECTION := "study"
|
|
||||||
const TEXT_SCALE_SETTINGS_KEY := "ccs_text_scale"
|
|
||||||
|
|
||||||
var api_url := DEFAULT_API_URL
|
var api_url := DEFAULT_API_URL
|
||||||
var request: HTTPRequest
|
var request: HTTPRequest
|
||||||
@@ -40,11 +38,16 @@ var request_access_token := ""
|
|||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
api_url = EndpointConfig.resolve(str(ProjectSettings.get_setting("step3/ccs_api_url", DEFAULT_API_URL)), "/api/ccs")
|
api_url = EndpointConfig.resolve(str(ProjectSettings.get_setting("step3/ccs_api_url", DEFAULT_API_URL)), "/api/ccs")
|
||||||
text_scale = _load_text_scale_preference()
|
|
||||||
request = HTTPRequest.new()
|
request = HTTPRequest.new()
|
||||||
request.request_completed.connect(_on_request_completed)
|
request.request_completed.connect(_on_request_completed)
|
||||||
add_child(request)
|
add_child(request)
|
||||||
AuthState.auth_changed.connect(_on_auth_changed)
|
AuthState.auth_changed.connect(_on_auth_changed)
|
||||||
|
call_deferred("_connect_account_profile_service")
|
||||||
|
|
||||||
|
|
||||||
|
func _connect_account_profile_service() -> void:
|
||||||
|
text_scale = _synced_text_scale()
|
||||||
|
AccountProfileService.profile_changed.connect(_on_account_profile_changed)
|
||||||
|
|
||||||
|
|
||||||
func load_cases() -> void:
|
func load_cases() -> void:
|
||||||
@@ -104,23 +107,20 @@ func submit_report(attempt_id: String, category: String, details: String) -> voi
|
|||||||
|
|
||||||
func adjust_text_scale(delta: float) -> void:
|
func adjust_text_scale(delta: float) -> void:
|
||||||
text_scale = clampf(text_scale + delta, TEXT_SCALE_MIN, TEXT_SCALE_MAX)
|
text_scale = clampf(text_scale + delta, TEXT_SCALE_MIN, TEXT_SCALE_MAX)
|
||||||
_save_text_scale_preference(text_scale)
|
AccountProfileService.update_text_scale(null, text_scale)
|
||||||
|
|
||||||
|
|
||||||
func _load_text_scale_preference() -> float:
|
func _synced_text_scale() -> float:
|
||||||
var config := ConfigFile.new()
|
var saved: float = AccountProfileService.preferences.get("ccsTextScale", 1.0)
|
||||||
if config.load(TEXT_SCALE_SETTINGS_PATH) == OK:
|
return clampf(saved, TEXT_SCALE_MIN, TEXT_SCALE_MAX)
|
||||||
var saved: float = config.get_value(TEXT_SCALE_SETTINGS_SECTION, TEXT_SCALE_SETTINGS_KEY, 1.0)
|
|
||||||
return clampf(saved, TEXT_SCALE_MIN, TEXT_SCALE_MAX)
|
|
||||||
return 1.0
|
|
||||||
|
|
||||||
|
|
||||||
func _save_text_scale_preference(value: float) -> void:
|
func _on_account_profile_changed() -> void:
|
||||||
var config := ConfigFile.new()
|
var synced := _synced_text_scale()
|
||||||
config.load(TEXT_SCALE_SETTINGS_PATH)
|
if is_equal_approx(synced, text_scale):
|
||||||
config.set_value(TEXT_SCALE_SETTINGS_SECTION, TEXT_SCALE_SETTINGS_KEY, value)
|
return
|
||||||
if config.save(TEXT_SCALE_SETTINGS_PATH) != OK:
|
text_scale = synced
|
||||||
push_warning("Unable to save CCS text scale preference.")
|
text_scale_changed.emit()
|
||||||
|
|
||||||
|
|
||||||
func toggle_highlight(attempt_id: String, region_id: String, start: int, end: int) -> void:
|
func toggle_highlight(attempt_id: String, region_id: String, start: int, end: int) -> void:
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ func _ready() -> void:
|
|||||||
resized.connect(_update_responsive_layout)
|
resized.connect(_update_responsive_layout)
|
||||||
if not theme_service.theme_changed.is_connected(_on_theme_changed):
|
if not theme_service.theme_changed.is_connected(_on_theme_changed):
|
||||||
theme_service.theme_changed.connect(_on_theme_changed)
|
theme_service.theme_changed.connect(_on_theme_changed)
|
||||||
|
if not CCSService.text_scale_changed.is_connected(_apply_text_scale):
|
||||||
|
CCSService.text_scale_changed.connect(_apply_text_scale)
|
||||||
_apply_theme()
|
_apply_theme()
|
||||||
call_deferred("_update_responsive_layout")
|
call_deferred("_update_responsive_layout")
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ func _ready() -> void:
|
|||||||
status = Label.new(); Style.set_label(status, 14, Style.RED); row.add_child(status)
|
status = Label.new(); Style.set_label(status, 14, Style.RED); row.add_child(status)
|
||||||
CCSService.request_failed.connect(_request_failed)
|
CCSService.request_failed.connect(_request_failed)
|
||||||
if not theme_service.theme_changed.is_connected(_on_theme_changed): theme_service.theme_changed.connect(_on_theme_changed)
|
if not theme_service.theme_changed.is_connected(_on_theme_changed): theme_service.theme_changed.connect(_on_theme_changed)
|
||||||
|
if not CCSService.text_scale_changed.is_connected(_apply_text_scale): CCSService.text_scale_changed.connect(_apply_text_scale)
|
||||||
|
|
||||||
func _study_label(region: String) -> RichTextLabel:
|
func _study_label(region: String) -> RichTextLabel:
|
||||||
var label := RichTextLabel.new(); label.bbcode_enabled = true; label.fit_content = true; label.selection_enabled = true; label.set_meta("ccs_region", region); label.gui_input.connect(_text_input.bind(label)); return label
|
var label := RichTextLabel.new(); label.bbcode_enabled = true; label.fit_content = true; label.selection_enabled = true; label.set_meta("ccs_region", region); label.gui_input.connect(_text_input.bind(label)); return label
|
||||||
|
|||||||
@@ -18,9 +18,6 @@ const CORRECT_ADVANCE_DELAY_SECONDS := 1.25
|
|||||||
const TEXT_SCALE_MIN := 0.85
|
const TEXT_SCALE_MIN := 0.85
|
||||||
const TEXT_SCALE_MAX := 1.35
|
const TEXT_SCALE_MAX := 1.35
|
||||||
const TEXT_SCALE_STEP := 0.1
|
const TEXT_SCALE_STEP := 0.1
|
||||||
const TEXT_SCALE_SETTINGS_PATH := "user://settings.cfg"
|
|
||||||
const TEXT_SCALE_SETTINGS_SECTION := "study"
|
|
||||||
const TEXT_SCALE_SETTINGS_KEY := "question_text_scale"
|
|
||||||
const REPORT_DETAILS_MIN := 10
|
const REPORT_DETAILS_MIN := 10
|
||||||
const REPORT_DETAILS_MAX := 2000
|
const REPORT_DETAILS_MAX := 2000
|
||||||
const MEDIA_ZOOM_MIN := 0.25
|
const MEDIA_ZOOM_MIN := 0.25
|
||||||
@@ -149,7 +146,9 @@ var shake_origin := Vector2.ZERO
|
|||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
theme_service = get_node("/root/UIThemeService")
|
theme_service = get_node("/root/UIThemeService")
|
||||||
question_text_scale = _load_text_scale_preference()
|
question_text_scale = _synced_question_text_scale()
|
||||||
|
if not AccountProfileService.profile_changed.is_connected(_on_account_profile_changed):
|
||||||
|
AccountProfileService.profile_changed.connect(_on_account_profile_changed)
|
||||||
set_anchors_preset(Control.PRESET_FULL_RECT)
|
set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
add_theme_constant_override("separation", 10)
|
add_theme_constant_override("separation", 10)
|
||||||
if not QuestionBankService.choice_percentages_updated.is_connected(_on_choice_percentages_updated):
|
if not QuestionBankService.choice_percentages_updated.is_connected(_on_choice_percentages_updated):
|
||||||
@@ -2125,26 +2124,25 @@ func _adjust_question_text_scale(delta: float) -> void:
|
|||||||
if is_equal_approx(next_scale, question_text_scale):
|
if is_equal_approx(next_scale, question_text_scale):
|
||||||
return
|
return
|
||||||
question_text_scale = next_scale
|
question_text_scale = next_scale
|
||||||
_save_text_scale_preference(next_scale)
|
AccountProfileService.update_text_scale(next_scale)
|
||||||
_refresh_scaled_fonts(self)
|
_refresh_scaled_fonts(self)
|
||||||
_render_stem()
|
_render_stem()
|
||||||
_apply_saved_choice_state()
|
_apply_saved_choice_state()
|
||||||
|
|
||||||
|
|
||||||
func _load_text_scale_preference() -> float:
|
func _synced_question_text_scale() -> float:
|
||||||
var config := ConfigFile.new()
|
var saved: float = AccountProfileService.preferences.get("questionTextScale", 1.0)
|
||||||
if config.load(TEXT_SCALE_SETTINGS_PATH) == OK:
|
return clamp(saved, TEXT_SCALE_MIN, TEXT_SCALE_MAX)
|
||||||
var saved: float = config.get_value(TEXT_SCALE_SETTINGS_SECTION, TEXT_SCALE_SETTINGS_KEY, 1.0)
|
|
||||||
return clamp(saved, TEXT_SCALE_MIN, TEXT_SCALE_MAX)
|
|
||||||
return 1.0
|
|
||||||
|
|
||||||
|
|
||||||
func _save_text_scale_preference(value: float) -> void:
|
func _on_account_profile_changed() -> void:
|
||||||
var config := ConfigFile.new()
|
var synced := _synced_question_text_scale()
|
||||||
config.load(TEXT_SCALE_SETTINGS_PATH)
|
if is_equal_approx(synced, question_text_scale):
|
||||||
config.set_value(TEXT_SCALE_SETTINGS_SECTION, TEXT_SCALE_SETTINGS_KEY, value)
|
return
|
||||||
if config.save(TEXT_SCALE_SETTINGS_PATH) != OK:
|
question_text_scale = synced
|
||||||
push_warning("Unable to save question text scale preference.")
|
_refresh_scaled_fonts(self)
|
||||||
|
_render_stem()
|
||||||
|
_apply_saved_choice_state()
|
||||||
|
|
||||||
|
|
||||||
func _set_scaled_label(label: Label, base_size: int, color: Color = Style.TEXT) -> void:
|
func _set_scaled_label(label: Label, base_size: int, color: Color = Style.TEXT) -> void:
|
||||||
|
|||||||
@@ -52,7 +52,10 @@ func _run() -> void:
|
|||||||
|
|
||||||
var clerk_font_size: int = player.search.get_theme_font_size("font_size")
|
var clerk_font_size: int = player.search.get_theme_font_size("font_size")
|
||||||
var chart_font_size: int = player.summary.get_theme_font_size("normal_font_size")
|
var chart_font_size: int = player.summary.get_theme_font_size("normal_font_size")
|
||||||
|
var prior_profile_api_url: String = ProjectSettings.get_setting("step3/profile_api_url", "")
|
||||||
|
ProjectSettings.set_setting("step3/profile_api_url", "http://127.0.0.1:1")
|
||||||
player._zoom(ccs_service.TEXT_SCALE_STEP)
|
player._zoom(ccs_service.TEXT_SCALE_STEP)
|
||||||
|
ProjectSettings.set_setting("step3/profile_api_url", prior_profile_api_url)
|
||||||
_assert(player.summary.get_theme_font_size("normal_font_size") > chart_font_size, "text zoom should enlarge clinical chart text")
|
_assert(player.summary.get_theme_font_size("normal_font_size") > chart_font_size, "text zoom should enlarge clinical chart text")
|
||||||
_assert(player.search.get_theme_font_size("font_size") == clerk_font_size, "text zoom should not resize Order Clerk controls")
|
_assert(player.search.get_theme_font_size("font_size") == clerk_font_size, "text zoom should not resize Order Clerk controls")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user