diff --git a/.gitignore b/.gitignore index bb21f03..7fe0a3a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Godot 4+ specific ignores .godot/ /android/ +/build/ # .NET build output **/bin/ diff --git a/api/QuestionsApi.Tests/UserNotepadTests.cs b/api/QuestionsApi.Tests/UserNotepadTests.cs new file mode 100644 index 0000000..4cb9a15 --- /dev/null +++ b/api/QuestionsApi.Tests/UserNotepadTests.cs @@ -0,0 +1,49 @@ +using QuestionsApi.Models; +using Xunit; + +namespace QuestionsApi.Tests; + +public sealed class UserNotepadTests +{ + [Fact] + public void ValidatorAcceptsPlainTextAtMaximumLength() + { + var request = new UpdateUserNotepadRequest + { + Content = new string('x', UserNotepadValidator.MaxContentLength), + ExpectedRevision = 0 + }; + + Assert.Null(UserNotepadValidator.Validate(request)); + } + + [Fact] + public void ValidatorRejectsOversizedContentAndNegativeRevision() + { + var oversized = new UpdateUserNotepadRequest + { + Content = new string('x', UserNotepadValidator.MaxContentLength + 1), + ExpectedRevision = 0 + }; + Assert.Contains("characters or fewer", UserNotepadValidator.Validate(oversized)); + + var invalidRevision = new UpdateUserNotepadRequest { ExpectedRevision = -1 }; + Assert.Contains("cannot be negative", UserNotepadValidator.Validate(invalidRevision)); + } + + [Fact] + public void ResponseDoesNotExposeUserOrMongoIdentifiers() + { + var response = UserNotepadResponse.FromModel(new UserNotepad + { + UserId = "private-user-id", + Content = "My notes", + Revision = 7 + }); + + Assert.Equal("My notes", response.Content); + Assert.Equal(7, response.Revision); + Assert.Null(typeof(UserNotepadResponse).GetProperty(nameof(UserNotepad.UserId))); + Assert.Null(typeof(UserNotepadResponse).GetProperty(nameof(UserNotepad.Id))); + } +} diff --git a/api/QuestionsApi/Controllers/NotesController.cs b/api/QuestionsApi/Controllers/NotesController.cs new file mode 100644 index 0000000..ba9a567 --- /dev/null +++ b/api/QuestionsApi/Controllers/NotesController.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using QuestionsApi.Models; +using QuestionsApi.Services; +using System.Security.Claims; + +namespace QuestionsApi.Controllers; + +[ApiController, Route("api/notes"), Authorize(Roles = "USER,SUPER")] +public sealed class NotesController(UserNotepadStore notes) : ControllerBase +{ + [HttpGet] + public async Task> Get() + { + var note = await notes.GetAsync(UserId()); + return Ok(note is null ? UserNotepadResponse.Empty() : UserNotepadResponse.FromModel(note)); + } + + [HttpPut] + public async Task> Update(UpdateUserNotepadRequest request) + { + if (UserNotepadValidator.Validate(request) is { } error) return BadRequest(error); + var result = await notes.UpdateAsync(UserId(), request.Content ?? "", request.ExpectedRevision); + if (result.Succeeded) return Ok(UserNotepadResponse.FromModel(result.Note!)); + var current = result.Current is null + ? UserNotepadResponse.Empty() + : UserNotepadResponse.FromModel(result.Current); + return Conflict(current); + } + + private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue(ClaimTypes.Name) + ?? User.Identity?.Name + ?? ""; +} diff --git a/api/QuestionsApi/Models/UserNotepad.cs b/api/QuestionsApi/Models/UserNotepad.cs new file mode 100644 index 0000000..92ed649 --- /dev/null +++ b/api/QuestionsApi/Models/UserNotepad.cs @@ -0,0 +1,49 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace QuestionsApi.Models; + +public sealed class UserNotepad +{ + [BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; } + public string UserId { get; set; } = ""; + public string Content { get; set; } = ""; + public long Revision { get; set; } + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; + public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; +} + +public sealed class UpdateUserNotepadRequest +{ + public string Content { get; set; } = ""; + public long ExpectedRevision { get; set; } +} + +public sealed class UserNotepadResponse +{ + public string Content { get; set; } = ""; + public long Revision { get; set; } + public DateTime? UpdatedUtc { get; set; } + + public static UserNotepadResponse Empty() => new(); + + public static UserNotepadResponse FromModel(UserNotepad note) => new() + { + Content = note.Content, + Revision = note.Revision, + UpdatedUtc = note.UpdatedUtc + }; +} + +public static class UserNotepadValidator +{ + public const int MaxContentLength = 50_000; + + public static string? Validate(UpdateUserNotepadRequest request) + { + if (request.ExpectedRevision < 0) return "expectedRevision cannot be negative"; + if ((request.Content ?? "").Length > MaxContentLength) + return $"content must be {MaxContentLength} characters or fewer"; + return null; + } +} diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index d579b7c..fe806a1 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -21,6 +21,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); diff --git a/api/QuestionsApi/Services/UserNotepadStore.cs b/api/QuestionsApi/Services/UserNotepadStore.cs new file mode 100644 index 0000000..16684a7 --- /dev/null +++ b/api/QuestionsApi/Services/UserNotepadStore.cs @@ -0,0 +1,66 @@ +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public sealed record UserNotepadUpdateResult(UserNotepad? Note, UserNotepad? Current) +{ + public bool Succeeded => Note is not null; +} + +public sealed class UserNotepadStore +{ + private readonly IMongoCollection _notes; + + public UserNotepadStore(MongoDatabaseProvider mongo) + { + _notes = mongo.Database.GetCollection("UserNotepads"); + _notes.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(note => note.UserId), + new CreateIndexOptions { Unique = true, Name = "user_notepad_unique_user" })); + } + + public async Task GetAsync(string userId) => + await _notes.Find(note => note.UserId == userId).FirstOrDefaultAsync(); + + public async Task UpdateAsync( + string userId, + string content, + long expectedRevision) + { + var now = DateTime.UtcNow; + if (expectedRevision == 0) + { + var created = new UserNotepad + { + UserId = userId, + Content = content, + Revision = 1, + CreatedUtc = now, + UpdatedUtc = now + }; + try + { + await _notes.InsertOneAsync(created); + return new UserNotepadUpdateResult(created, null); + } + catch (MongoWriteException exception) when ( + exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + return new UserNotepadUpdateResult(null, await GetAsync(userId)); + } + } + + var updated = await _notes.FindOneAndUpdateAsync( + note => note.UserId == userId && note.Revision == expectedRevision, + Builders.Update + .Set(note => note.Content, content) + .Set(note => note.UpdatedUtc, now) + .Inc(note => note.Revision, 1), + new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); + + return updated is not null + ? new UserNotepadUpdateResult(updated, null) + : new UserNotepadUpdateResult(null, await GetAsync(userId)); + } +} diff --git a/project.godot b/project.godot index 5b1a485..decb894 100644 --- a/project.godot +++ b/project.godot @@ -28,6 +28,7 @@ BlockSessionService="*res://scripts/autoload/block_session_service.gd" ExamSimulationService="*res://scripts/autoload/exam_simulation_service.gd" CCSService="*res://scripts/autoload/ccs_service.gd" FeedbackService="*res://scripts/autoload/feedback_service.gd" +NotesService="*res://scripts/autoload/notes_service.gd" [display] @@ -52,4 +53,5 @@ textures/vram_compression/import_etc2_astc=true questions_api_url="https://s3q.ranaze.com/api/questions/search" question_reports_api_url="https://s3q.ranaze.com/api/question-reports" app_feedback_api_url="https://s3q.ranaze.com/api/app-feedback" +notes_api_url="https://s3q.ranaze.com/api/notes" build_revision="development" diff --git a/scripts/autoload/notes_service.gd b/scripts/autoload/notes_service.gd new file mode 100644 index 0000000..d091064 --- /dev/null +++ b/scripts/autoload/notes_service.gd @@ -0,0 +1,353 @@ +extends Node + +const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") +const DEFAULT_API_URL := "https://s3q.ranaze.com/api/notes" +const REQUEST_TIMEOUT_SECONDS := 30.0 +const AUTOSAVE_DELAY_SECONDS := 1.5 +const RETRY_DELAY_SECONDS := 5.0 +const MAX_CONTENT_LENGTH := 50000 + +signal content_changed(content: String) +signal state_changed(state: String) +signal conflict_detected(server_content: String) + +var api_url := DEFAULT_API_URL +var content := "" +var revision := 0 +var updated_utc := "" +var state := "idle" +var dirty := false +var conflict_server_content := "" +var conflict_server_revision := 0 +var conflict_active := false + +var request: HTTPRequest +var autosave_timer: Timer +var retry_timer: Timer +var request_action := "" +var request_access_token := "" +var request_account_key := "" +var sent_content := "" +var active_account_key := "" +var remote_loaded := false +var auth_state: Node + + +func _ready() -> void: + auth_state = get_node("/root/AuthState") + api_url = EndpointConfig.resolve( + str(ProjectSettings.get_setting("step3/notes_api_url", DEFAULT_API_URL)), + "/api/notes") + request = HTTPRequest.new() + request.timeout = REQUEST_TIMEOUT_SECONDS + request.request_completed.connect(_on_request_completed) + add_child(request) + autosave_timer = Timer.new() + autosave_timer.one_shot = true + autosave_timer.wait_time = AUTOSAVE_DELAY_SECONDS + autosave_timer.timeout.connect(_save_remote) + add_child(autosave_timer) + retry_timer = Timer.new() + retry_timer.one_shot = true + retry_timer.wait_time = RETRY_DELAY_SECONDS + retry_timer.timeout.connect(_retry_sync) + add_child(retry_timer) + auth_state.auth_changed.connect(_on_auth_changed) + auth_state.session_refreshed.connect(_on_session_refreshed) + _on_auth_changed() + + +func ensure_loaded() -> void: + if not auth_state.is_logged_in: + return + var account_key := _account_key(auth_state.username) + if account_key != active_account_key: + _activate_account(account_key) + if not remote_loaded and request_action.is_empty(): + _load_remote() + + +func set_content(value: String) -> void: + if value.length() > MAX_CONTENT_LENGTH: + value = value.left(MAX_CONTENT_LENGTH) + if value == content: + return + content = value + dirty = true + _clear_conflict() + _write_local_draft() + _set_state("dirty") + content_changed.emit(content) + autosave_timer.start() + + +func flush() -> void: + if dirty and request_action.is_empty() and not conflict_active: + autosave_timer.stop() + _save_remote() + + +func resolve_conflict_keep_draft() -> void: + if not conflict_active: + return + revision = conflict_server_revision + _clear_conflict() + dirty = true + _write_local_draft() + _set_state("dirty") + autosave_timer.start() + + +func resolve_conflict_use_synced() -> void: + if not conflict_active: + return + content = conflict_server_content + revision = conflict_server_revision + _clear_conflict() + dirty = false + _write_local_draft() + _set_state("saved") + content_changed.emit(content) + + +func status_text() -> String: + match state: + "loading": return "Loading..." + "dirty": return "Unsaved changes" + "saving": return "Saving..." + "saved": return "Saved" + "offline": return "Saved locally - waiting for connection" + "conflict": return "Changes found on another device" + _: return "" + + +func _on_auth_changed() -> void: + if not auth_state.is_logged_in: + _deactivate_account() + return + var account_key := _account_key(auth_state.username) + if account_key != active_account_key: + _activate_account(account_key) + elif dirty and request_action.is_empty() and not conflict_active: + retry_timer.start() + + +func _on_session_refreshed() -> void: + if dirty and request_action.is_empty() and not conflict_active: + _save_remote() + + +func _activate_account(account_key: String) -> void: + _cancel_request() + autosave_timer.stop() + retry_timer.stop() + active_account_key = account_key + content = "" + revision = 0 + updated_utc = "" + dirty = false + remote_loaded = false + _clear_conflict() + _read_local_draft() + content_changed.emit(content) + _load_remote() + + +func _deactivate_account() -> void: + _cancel_request() + if dirty: + _write_local_draft() + autosave_timer.stop() + retry_timer.stop() + active_account_key = "" + content = "" + revision = 0 + updated_utc = "" + dirty = false + remote_loaded = false + _clear_conflict() + _set_state("idle") + content_changed.emit(content) + + +func _load_remote() -> void: + if active_account_key.is_empty() or not auth_state.can_send_authenticated_request(): + return + _start_request("load", HTTPClient.METHOD_GET, "") + if not dirty: + _set_state("loading") + + +func _save_remote() -> void: + if not dirty or not request_action.is_empty() or conflict_active: + return + if not auth_state.can_send_authenticated_request(): + _set_state("offline") + return + sent_content = content + var payload := JSON.stringify({"content": sent_content, "expectedRevision": revision}) + _start_request("save", HTTPClient.METHOD_PUT, payload) + _set_state("saving") + + +func _start_request(action: String, method: HTTPClient.Method, body: String) -> void: + request_action = action + request_access_token = auth_state.access_token + request_account_key = active_account_key + var headers := PackedStringArray([ + "Content-Type: application/json", + "Authorization: Bearer %s" % request_access_token + ]) + var error := request.request(api_url, headers, method, body) + if error != OK: + request_action = "" + _set_state("offline") + retry_timer.start() + + +func _on_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + var completed_action := request_action + var completed_account := request_account_key + request_action = "" + request_account_key = "" + if completed_account != active_account_key or active_account_key.is_empty(): + return + if response_code == 401: + auth_state.handle_unauthorized(request_access_token) + if result != HTTPRequest.RESULT_SUCCESS or response_code == 0 or response_code >= 500: + _set_state("offline") + if dirty: + retry_timer.start() + return + var parsed_variant: Variant = JSON.parse_string(body.get_string_from_utf8()) + if typeof(parsed_variant) != TYPE_DICTIONARY: + _set_state("offline") + if dirty: + retry_timer.start() + return + var parsed := parsed_variant as Dictionary + if response_code == 409 and completed_action == "save": + _set_conflict(parsed) + return + if response_code < 200 or response_code >= 300: + _set_state("offline") + if dirty: + retry_timer.start() + return + remote_loaded = true + if completed_action == "load": + _apply_remote_load(parsed) + elif completed_action == "save": + _apply_remote_save(parsed) + + +func _apply_remote_load(server: Dictionary) -> void: + var server_content := str(server.get("content", "")) + var server_revision := maxi(0, int(server.get("revision", 0))) + updated_utc = str(server.get("updatedUtc", "")) + if not dirty: + content = server_content + revision = server_revision + _write_local_draft() + _set_state("saved") + content_changed.emit(content) + return + if content == server_content: + revision = server_revision + dirty = false + _write_local_draft() + _set_state("saved") + return + if revision == server_revision: + _set_state("dirty") + autosave_timer.start() + return + _set_conflict(server) + + +func _apply_remote_save(server: Dictionary) -> void: + revision = maxi(0, int(server.get("revision", revision + 1))) + updated_utc = str(server.get("updatedUtc", "")) + if content == sent_content: + dirty = false + _set_state("saved") + else: + dirty = true + _set_state("dirty") + autosave_timer.start() + _write_local_draft() + + +func _set_conflict(server: Dictionary) -> void: + conflict_server_content = str(server.get("content", "")) + conflict_server_revision = maxi(0, int(server.get("revision", 0))) + conflict_active = true + _set_state("conflict") + conflict_detected.emit(conflict_server_content) + + +func _retry_sync() -> void: + if not auth_state.is_logged_in: + return + if not remote_loaded: + _load_remote() + elif dirty: + _save_remote() + + +func _set_state(value: String) -> void: + if state == value: + return + state = value + state_changed.emit(state) + + +func _clear_conflict() -> void: + conflict_server_content = "" + conflict_server_revision = 0 + conflict_active = false + + +func _account_key(username: String) -> String: + return username.strip_edges().to_lower().sha256_text() + + +func _draft_path() -> String: + return "user://notepad_%s.json" % active_account_key + + +func _read_local_draft() -> void: + if active_account_key.is_empty() or not FileAccess.file_exists(_draft_path()): + return + var file := FileAccess.open(_draft_path(), FileAccess.READ) + if file == null: + return + var parsed_variant: Variant = JSON.parse_string(file.get_as_text()) + if typeof(parsed_variant) != TYPE_DICTIONARY: + return + var parsed := parsed_variant as Dictionary + content = str(parsed.get("content", "")).left(MAX_CONTENT_LENGTH) + revision = maxi(0, int(parsed.get("revision", 0))) + dirty = bool(parsed.get("dirty", false)) + _set_state("dirty" if dirty else "saved") + + +func _write_local_draft() -> void: + if active_account_key.is_empty(): + return + var file := FileAccess.open(_draft_path(), FileAccess.WRITE) + if file == null: + return + file.store_string(JSON.stringify({ + "content": content, + "revision": revision, + "dirty": dirty, + "updatedUtc": "%sZ" % Time.get_datetime_string_from_system(true) + })) + + +func _cancel_request() -> void: + if request != null and request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: + request.cancel_request() + request_action = "" + request_account_key = "" diff --git a/scripts/autoload/notes_service.gd.uid b/scripts/autoload/notes_service.gd.uid new file mode 100644 index 0000000..847ab24 --- /dev/null +++ b/scripts/autoload/notes_service.gd.uid @@ -0,0 +1 @@ +uid://b8juke0d675k5 diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index d04454e..842132f 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -12,6 +12,7 @@ var sound_toggle: CheckButton var theme_button: Button var feedback_button: Button var feedback_dialog: Control +var notes_dialog: Control var session_expiry_dialog: Control var theme_service var current_screen_name := "" @@ -283,9 +284,18 @@ func _build_ui() -> void: screens_container.add_child(settings) screens["settings"] = settings + var notes := preload("res://scripts/ui/notes_screen.gd").new() + notes.name = "NotesScreen" + notes.main = self + screens_container.add_child(notes) + screens["notes"] = notes + feedback_dialog = preload("res://scripts/ui/app_feedback_dialog.gd").new() feedback_dialog.name = "AppFeedbackDialog" add_child(feedback_dialog) + notes_dialog = preload("res://scripts/ui/notes_dialog.gd").new() + notes_dialog.name = "NotesDialog" + add_child(notes_dialog) session_expiry_dialog = preload("res://scripts/ui/session_expiry_dialog.gd").new() session_expiry_dialog.name = "SessionExpiryDialog" session_expiry_dialog.logout_requested.connect(_logout) @@ -348,7 +358,7 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void _add_nav_button(container, "flashcards", "Flashcards", "copy", false) _add_nav_button(container, "study_plan", "Study Plan", "calendar-days", true) _add_nav_button(container, "bookmarks", "Bookmarks", "bookmark", false) - _add_nav_button(container, "notes", "Notes", "notebook-pen", false) + _add_nav_button(container, "notes", "Notes", "notebook-pen", true) var spacer := Control.new() spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL @@ -391,6 +401,7 @@ func _on_navigation_pressed(key: String) -> void: "performance": show_screen("profile") "study_plan": show_screen("map") "settings": show_screen("settings") + "notes": show_screen("notes") "logout": _logout() @@ -648,6 +659,14 @@ func open_feedback_dialog() -> void: feedback_dialog.show_dialog(FeedbackService.build_context(current_screen_name, get_viewport_rect().size)) +func open_notes_dialog() -> void: + if not AuthState.is_logged_in or notes_dialog == null: + return + if current_screen_name != "player" or BlockSessionService.session_kind == "simulation": + return + notes_dialog.show_dialog() + + func _on_theme_changed(is_dark_mode: bool) -> void: Style.refresh_tree(self, not is_dark_mode) _refresh_theme_button() diff --git a/scripts/ui/navigation_routes.gd b/scripts/ui/navigation_routes.gd index b9db6cf..ce58cff 100644 --- a/scripts/ui/navigation_routes.gd +++ b/scripts/ui/navigation_routes.gd @@ -17,4 +17,6 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri key = "performance" elif screen_name == "settings": key = "settings" + elif screen_name == "notes": + key = "notes" return key diff --git a/scripts/ui/notes_dialog.gd b/scripts/ui/notes_dialog.gd new file mode 100644 index 0000000..aba8b98 --- /dev/null +++ b/scripts/ui/notes_dialog.gd @@ -0,0 +1,108 @@ +extends Control + +const Brand := preload("res://scripts/ui/medmind_style.gd") +const NotesEditor := preload("res://scripts/ui/notes_editor.gd") + +signal closed + +var panel: PanelContainer +var notes_editor: VBoxContainer +var theme_service: Node +var block_session_service: Node + + +func _ready() -> void: + theme_service = get_node("/root/UIThemeService") + block_session_service = get_node("/root/BlockSessionService") + visible = false + top_level = true + mouse_filter = Control.MOUSE_FILTER_STOP + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + var dim := ColorRect.new() + dim.color = Color(0, 0, 0, 0.72) + dim.set_anchors_preset(Control.PRESET_FULL_RECT) + dim.gui_input.connect(_on_dim_input) + add_child(dim) + var center := CenterContainer.new() + center.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(center) + panel = PanelContainer.new() + panel.custom_minimum_size = Vector2(760, 650) + center.add_child(panel) + var content := VBoxContainer.new() + content.add_theme_constant_override("separation", 12) + panel.add_child(content) + var header := HBoxContainer.new() + content.add_child(header) + var title := Label.new() + title.text = "Notes" + title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + title.add_theme_font_override("font", Brand.font) + title.add_theme_font_size_override("font_size", 26) + header.add_child(title) + var close_button := Button.new() + close_button.icon = preload("res://assets/icons/lucide/x.svg") + close_button.tooltip_text = "Close notes" + close_button.custom_minimum_size = Vector2(42, 42) + close_button.add_theme_constant_override("icon_max_width", 20) + close_button.pressed.connect(hide_dialog) + header.add_child(close_button) + notes_editor = NotesEditor.new() + content.add_child(notes_editor) + theme_service.theme_changed.connect(_on_theme_changed) + resized.connect(_fit_panel) + _apply_theme() + _fit_panel() + + +func show_dialog() -> void: + notes_editor.activate() + visible = true + move_to_front() + block_session_service.pause_current_question_timer() + call_deferred("_focus_editor") + + +func hide_dialog() -> void: + if not visible: + return + visible = false + get_node("/root/NotesService").flush() + block_session_service.resume_current_question_timer() + closed.emit() + + +func _focus_editor() -> void: + notes_editor.focus_editor() + + +func _input(event: InputEvent) -> void: + if visible and event.is_action_pressed("ui_cancel"): + hide_dialog() + get_viewport().set_input_as_handled() + + +func _on_dim_input(event: InputEvent) -> void: + var mouse_event := event as InputEventMouseButton + if mouse_event != null and mouse_event.pressed and mouse_event.button_index == MOUSE_BUTTON_LEFT: + hide_dialog() + get_viewport().set_input_as_handled() + + +func _on_theme_changed(_dark: bool) -> void: + _apply_theme() + + +func _apply_theme() -> void: + panel.add_theme_stylebox_override("panel", Brand.panel_box(theme_service.is_dark_mode())) + + +func _fit_panel() -> void: + if panel == null: + return + var available := get_viewport_rect().size - Vector2(32, 32) + var target_size := Vector2( + minf(760.0, maxf(300.0, available.x)), + minf(650.0, maxf(420.0, available.y))) + if panel.custom_minimum_size != target_size: + panel.custom_minimum_size = target_size diff --git a/scripts/ui/notes_dialog.gd.uid b/scripts/ui/notes_dialog.gd.uid new file mode 100644 index 0000000..d3d89c1 --- /dev/null +++ b/scripts/ui/notes_dialog.gd.uid @@ -0,0 +1 @@ +uid://c20n7rxtocujy diff --git a/scripts/ui/notes_editor.gd b/scripts/ui/notes_editor.gd new file mode 100644 index 0000000..25826c9 --- /dev/null +++ b/scripts/ui/notes_editor.gd @@ -0,0 +1,142 @@ +extends VBoxContainer + +const Brand := preload("res://scripts/ui/medmind_style.gd") + +var editor: TextEdit +var status_label: Label +var count_label: Label +var conflict_panel: PanelContainer +var applying_content := false +var notes_service: Node +var theme_service: Node + + +func _ready() -> void: + notes_service = get_node("/root/NotesService") + theme_service = get_node("/root/UIThemeService") + size_flags_horizontal = Control.SIZE_EXPAND_FILL + size_flags_vertical = Control.SIZE_EXPAND_FILL + add_theme_constant_override("separation", 10) + + var metadata_row := HBoxContainer.new() + metadata_row.add_theme_constant_override("separation", 12) + add_child(metadata_row) + status_label = Label.new() + status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + Brand.apply_font(status_label) + metadata_row.add_child(status_label) + count_label = Label.new() + Brand.apply_font(count_label) + metadata_row.add_child(count_label) + + editor = TextEdit.new() + editor.placeholder_text = "Write notes..." + editor.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY + editor.size_flags_horizontal = Control.SIZE_EXPAND_FILL + editor.size_flags_vertical = Control.SIZE_EXPAND_FILL + editor.custom_minimum_size = Vector2(0, 260) + editor.add_theme_font_override("font", Brand.font) + editor.add_theme_font_size_override("font_size", 17) + editor.text_changed.connect(_on_text_changed) + add_child(editor) + + conflict_panel = PanelContainer.new() + conflict_panel.visible = false + add_child(conflict_panel) + var conflict_content := VBoxContainer.new() + conflict_content.add_theme_constant_override("separation", 8) + conflict_panel.add_child(conflict_content) + var conflict_message := Label.new() + conflict_message.text = "This note changed on another device. Choose which version to keep." + conflict_message.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Brand.apply_font(conflict_message) + conflict_content.add_child(conflict_message) + var conflict_actions := HBoxContainer.new() + conflict_actions.add_theme_constant_override("separation", 8) + conflict_content.add_child(conflict_actions) + var keep_draft := Button.new() + keep_draft.text = "Keep my draft" + keep_draft.pressed.connect(notes_service.resolve_conflict_keep_draft) + conflict_actions.add_child(keep_draft) + var use_synced := Button.new() + use_synced.text = "Use synced version" + use_synced.pressed.connect(notes_service.resolve_conflict_use_synced) + conflict_actions.add_child(use_synced) + + notes_service.content_changed.connect(_on_service_content_changed) + notes_service.state_changed.connect(_on_service_state_changed) + theme_service.theme_changed.connect(_on_theme_changed) + _refresh_from_service() + _apply_theme() + + +func activate() -> void: + notes_service.ensure_loaded() + _refresh_from_service() + + +func focus_editor() -> void: + editor.grab_focus() + + +func _on_text_changed() -> void: + if applying_content: + return + if editor.text.length() > notes_service.MAX_CONTENT_LENGTH: + var caret_line := editor.get_caret_line() + var caret_column := editor.get_caret_column() + applying_content = true + editor.text = editor.text.left(NotesService.MAX_CONTENT_LENGTH) + editor.set_caret_line(mini(caret_line, editor.get_line_count() - 1)) + editor.set_caret_column(caret_column) + applying_content = false + notes_service.set_content(editor.text) + _refresh_metadata() + + +func _on_service_content_changed(value: String) -> void: + if editor.text != value: + applying_content = true + editor.text = value + applying_content = false + _refresh_metadata() + + +func _on_service_state_changed(_new_state: String) -> void: + _refresh_metadata() + + +func _refresh_from_service() -> void: + _on_service_content_changed(notes_service.content) + + +func _refresh_metadata() -> void: + status_label.text = notes_service.status_text() + count_label.text = "%s / %s" % [ + _format_count(editor.text.length()), + _format_count(notes_service.MAX_CONTENT_LENGTH) + ] + conflict_panel.visible = notes_service.conflict_active + status_label.add_theme_color_override( + "font_color", + Brand.AMBER if notes_service.state in ["dirty", "offline", "conflict"] else Brand.muted_color(theme_service.is_dark_mode())) + + +func _format_count(value: int) -> String: + return "%d,%03d" % [int(floor(float(value) / 1000.0)), value % 1000] if value >= 1000 else str(value) + + +func _on_theme_changed(_dark: bool) -> void: + _apply_theme() + + +func _apply_theme() -> void: + var dark: bool = theme_service.is_dark_mode() + editor.add_theme_color_override("font_color", Brand.text_color(dark)) + editor.add_theme_color_override("font_placeholder_color", Brand.muted_color(dark)) + editor.add_theme_color_override("caret_color", Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE) + editor.add_theme_color_override("selection_color", Color(Brand.ROYAL_BLUE, 0.32)) + editor.add_theme_stylebox_override("normal", Brand.field_box(dark)) + editor.add_theme_stylebox_override("focus", Brand.field_box(dark, true)) + conflict_panel.add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.AMBER)) + _refresh_metadata() diff --git a/scripts/ui/notes_editor.gd.uid b/scripts/ui/notes_editor.gd.uid new file mode 100644 index 0000000..968c80d --- /dev/null +++ b/scripts/ui/notes_editor.gd.uid @@ -0,0 +1 @@ +uid://du8xhjfdkdakr diff --git a/scripts/ui/notes_screen.gd b/scripts/ui/notes_screen.gd new file mode 100644 index 0000000..4f28335 --- /dev/null +++ b/scripts/ui/notes_screen.gd @@ -0,0 +1,34 @@ +extends VBoxContainer + +const Brand := preload("res://scripts/ui/medmind_style.gd") +const NotesEditor := preload("res://scripts/ui/notes_editor.gd") + +var main +var notes_editor: VBoxContainer + + +func _ready() -> void: + size_flags_horizontal = Control.SIZE_EXPAND_FILL + size_flags_vertical = Control.SIZE_EXPAND_FILL + 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() + add_child(notes_editor) + + +func on_show(_payload: Variant = null) -> void: + notes_editor.activate() + + +func on_hide() -> void: + get_node("/root/NotesService").flush() diff --git a/scripts/ui/notes_screen.gd.uid b/scripts/ui/notes_screen.gd.uid new file mode 100644 index 0000000..c5839f3 --- /dev/null +++ b/scripts/ui/notes_screen.gd.uid @@ -0,0 +1 @@ +uid://e7os1ufiuo37 diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index f6229df..e5f4789 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -83,6 +83,7 @@ var labs_button: Button var clear_highlight_button: Button var flag_button: Button var report_button: Button +var notes_button: Button var app_feedback_button: Button var theme_button: Button var question_toolbar: HFlowContainer @@ -273,6 +274,18 @@ func _ready() -> void: report_button.pressed.connect(_toggle_report_current_question) question_toolbar.add_child(report_button) + notes_button = Button.new() + notes_button.tooltip_text = "Open notes" + notes_button.icon = preload("res://assets/icons/lucide/notebook-pen.svg") + notes_button.custom_minimum_size = Vector2(44, 44) + notes_button.add_theme_constant_override("icon_max_width", 22) + notes_button.alignment = HORIZONTAL_ALIGNMENT_CENTER + notes_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER + notes_button.focus_mode = Control.FOCUS_NONE + UISoundService.set_button_sound(notes_button, "open") + notes_button.pressed.connect(func(): main.open_notes_dialog()) + question_toolbar.add_child(notes_button) + app_feedback_button = Button.new() app_feedback_button.text = "Feedback" app_feedback_button.tooltip_text = "Send general feedback or report a bug" @@ -827,7 +840,7 @@ func _apply_question_theme() -> void: var navigator_heading := navigator_popup.find_child("NavigatorHeading", true, false) as Label if navigator_heading != null: navigator_heading.add_theme_color_override("font_color", Brand.text_color(dark)) - for control in [text_smaller_button, text_larger_button, flag_button, app_feedback_button]: + for control in [text_smaller_button, text_larger_button, flag_button, notes_button, app_feedback_button]: if control == null: continue var active: bool = control == flag_button and flagged @@ -1864,6 +1877,8 @@ func _refresh_toolbar_state() -> void: if report_button != null: var question_id := str(BlockSessionService.get_current_question().get("id", "")) report_button.tooltip_text = "Report submitted; report another issue" if reported_question_ids.has(question_id) else "Report question" + if notes_button != null: + notes_button.visible = BlockSessionService.session_kind != "simulation" _apply_question_theme() diff --git a/tests/notes_ui_test.gd b/tests/notes_ui_test.gd new file mode 100644 index 0000000..7b6349f --- /dev/null +++ b/tests/notes_ui_test.gd @@ -0,0 +1,37 @@ +extends SceneTree + +const NotesEditor := preload("res://scripts/ui/notes_editor.gd") +const NotesDialog := preload("res://scripts/ui/notes_dialog.gd") +const NavigationRoutes := preload("res://scripts/ui/navigation_routes.gd") + +var failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var editor := NotesEditor.new() + root.add_child(editor) + await process_frame + _assert(editor.editor != null, "notes editor should create a text editor") + _assert(editor.editor.wrap_mode == TextEdit.LINE_WRAPPING_BOUNDARY, "notes should wrap plain text") + _assert(NavigationRoutes.key_for_screen("notes") == "notes", "notes should have a navigation route") + + var dialog := NotesDialog.new() + root.add_child(dialog) + await process_frame + _assert(not dialog.visible, "notes dialog should start hidden") + _assert(dialog.notes_editor != null, "notes dialog should contain the shared editor") + + editor.queue_free() + dialog.queue_free() + quit(1 if failures > 0 else 0) + + +func _assert(condition: bool, message: String) -> void: + if condition: + return + failures += 1 + push_error(message) diff --git a/tests/notes_ui_test.gd.uid b/tests/notes_ui_test.gd.uid new file mode 100644 index 0000000..4922ccd --- /dev/null +++ b/tests/notes_ui_test.gd.uid @@ -0,0 +1 @@ +uid://lo6a7h02ld0l diff --git a/tests/post_answer_review_test.gd b/tests/post_answer_review_test.gd index 9bebc03..5b02a2a 100644 --- a/tests/post_answer_review_test.gd +++ b/tests/post_answer_review_test.gd @@ -348,6 +348,7 @@ func _test_player_runtime_construction() -> void: _assert(player.clear_highlight_button.icon != null, "clear highlights should use the supplied toolbar artwork") _assert(player.report_button.icon != null, "report question should use the supplied toolbar artwork") _assert(player.labs_button.icon != null, "lab results should use the supplied toolbar artwork") + _assert(player.notes_button != null and player.notes_button.icon != null, "practice questions should expose the notes tool") for icon_button in [player.clear_highlight_button, player.report_button, player.labs_button]: _assert(icon_button.alignment == HORIZONTAL_ALIGNMENT_CENTER and icon_button.icon_alignment == HORIZONTAL_ALIGNMENT_CENTER, "question toolbar artwork should be horizontally centered") _assert(icon_button.vertical_icon_alignment == VERTICAL_ALIGNMENT_CENTER, "question toolbar artwork should be vertically centered") @@ -362,6 +363,14 @@ func _test_player_runtime_construction() -> void: _assert(player.theme_button.icon in [Brand.SUN_ICON, Brand.MOON_ICON], "question theme toggle should use the shared sun or moon icon") _assert(player.choice_badges.size() == 5, "question player should construct an answer badge for every visible choice slot") _assert(player.choice_followup_panels[2].get_parent() == player.choice_rows[2], "distractor remediation should remain nested inside its answer choice") + var prior_session_kind: String = root.get_node("BlockSessionService").session_kind + root.get_node("BlockSessionService").session_kind = "simulation" + player._refresh_toolbar_state() + _assert(not player.notes_button.visible, "timed simulations should hide the notes tool") + root.get_node("BlockSessionService").session_kind = "core" + player._refresh_toolbar_state() + _assert(player.notes_button.visible, "practice question runs should show the notes tool") + root.get_node("BlockSessionService").session_kind = prior_session_kind player.choice_followup_panels[2].visible = true player.locked_wrong_choice_ids = ["C"] player.presented_choices = [{"id": "A", "text": "First"}, {"id": "B", "text": "Second"}, {"id": "C", "text": "Seek a court order"}] diff --git a/tests/shell_navigation_test.gd b/tests/shell_navigation_test.gd index 90a4940..32adc69 100644 --- a/tests/shell_navigation_test.gd +++ b/tests/shell_navigation_test.gd @@ -16,6 +16,7 @@ func _initialize() -> void: _assert(NavigationRoutes.key_for_screen("profile") == "performance", "profile should activate Performance") _assert(NavigationRoutes.key_for_screen("map") == "study_plan", "study map should activate Study Plan") _assert(NavigationRoutes.key_for_screen("settings") == "settings", "settings should activate Settings") + _assert(NavigationRoutes.key_for_screen("notes") == "notes", "notes should activate Notes") quit(1 if failures > 0 else 0)