diff --git a/assets/branding/brand-guide.png b/assets/branding/brand-guide.png new file mode 100644 index 0000000..53e0415 Binary files /dev/null and b/assets/branding/brand-guide.png differ diff --git a/assets/branding/brand-guide.png.import b/assets/branding/brand-guide.png.import new file mode 100644 index 0000000..f2e0586 --- /dev/null +++ b/assets/branding/brand-guide.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cggvp10nl7dkw" +path="res://.godot/imported/brand-guide.png-f5bf305eb515449b2b41095c31a95f1b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/branding/brand-guide.png" +dest_files=["res://.godot/imported/brand-guide.png-f5bf305eb515449b2b41095c31a95f1b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/branding/login-reference.png b/assets/branding/login-reference.png new file mode 100644 index 0000000..40d14a6 Binary files /dev/null and b/assets/branding/login-reference.png differ diff --git a/assets/branding/login-reference.png.import b/assets/branding/login-reference.png.import new file mode 100644 index 0000000..a3dc5d9 --- /dev/null +++ b/assets/branding/login-reference.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dh5mmxgokpxsi" +path="res://.godot/imported/login-reference.png-ffc946c8cb9eb0b3ef4572dae6a411fc.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/branding/login-reference.png" +dest_files=["res://.godot/imported/login-reference.png-ffc946c8cb9eb0b3ef4572dae6a411fc.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/branding/medmind-icon.png b/assets/branding/medmind-icon.png new file mode 100644 index 0000000..f2ffc13 Binary files /dev/null and b/assets/branding/medmind-icon.png differ diff --git a/assets/branding/medmind-icon.png.import b/assets/branding/medmind-icon.png.import new file mode 100644 index 0000000..91bb276 --- /dev/null +++ b/assets/branding/medmind-icon.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://7xw8fhj28e7r" +path="res://.godot/imported/medmind-icon.png-01bef25038ace5255da752667f77403f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/branding/medmind-icon.png" +dest_files=["res://.godot/imported/medmind-icon.png-01bef25038ace5255da752667f77403f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/fonts/Inter-Variable.ttf b/assets/fonts/Inter-Variable.ttf new file mode 100644 index 0000000..047c92f Binary files /dev/null and b/assets/fonts/Inter-Variable.ttf differ diff --git a/assets/fonts/Inter-Variable.ttf.import b/assets/fonts/Inter-Variable.ttf.import new file mode 100644 index 0000000..7ac009b --- /dev/null +++ b/assets/fonts/Inter-Variable.ttf.import @@ -0,0 +1,36 @@ +[remap] + +importer="font_data_dynamic" +type="FontFile" +uid="uid://ck2hsnyyktfpr" +path="res://.godot/imported/Inter-Variable.ttf-e500b369badb17500bd3445af6919693.fontdata" + +[deps] + +source_file="res://assets/fonts/Inter-Variable.ttf" +dest_files=["res://.godot/imported/Inter-Variable.ttf-e500b369badb17500bd3445af6919693.fontdata"] + +[params] + +Rendering=null +antialiasing=1 +generate_mipmaps=false +disable_embedded_bitmaps=true +multichannel_signed_distance_field=false +msdf_pixel_range=8 +msdf_size=48 +allow_system_fallback=true +force_autohinter=false +modulate_color_glyphs=false +hinting=1 +subpixel_positioning=4 +keep_rounding_remainders=true +oversampling=0.0 +Fallbacks=null +fallbacks=[] +Compress=null +compress=true +preload=[] +language_support={} +script_support={} +opentype_features={} diff --git a/assets/fonts/OFL.txt b/assets/fonts/OFL.txt new file mode 100644 index 0000000..21f6aff --- /dev/null +++ b/assets/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/export_presets.cfg b/export_presets.cfg index 564488d..2d65e89 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -22,7 +22,7 @@ vram_texture_compression/for_desktop=true vram_texture_compression/for_mobile=true html/export_icon=true html/custom_html_shell="" -html/head_include="" +html/head_include="" html/canvas_resize_policy=2 html/focus_canvas_on_start=true html/experimental_virtual_keyboard=false diff --git a/project.godot b/project.godot index 77bd935..5b1a485 100644 --- a/project.godot +++ b/project.godot @@ -10,11 +10,11 @@ config_version=5 [application] -config/name="step3" +config/name="MedMind" config/version="pre-alpha" run/main_scene="res://scenes/main.tscn" config/features=PackedStringArray("4.6", "GL Compatibility") -config/icon="res://icon.svg" +config/icon="res://assets/branding/medmind-icon.png" [autoload] diff --git a/scripts/ui/login_screen.gd b/scripts/ui/login_screen.gd index 195c812..9277316 100644 --- a/scripts/ui/login_screen.gd +++ b/scripts/ui/login_screen.gd @@ -1,348 +1,632 @@ -extends VBoxContainer +extends Control -const Style := preload("res://scripts/ui/ui_style.gd") +const Brand := preload("res://scripts/ui/medmind_style.gd") const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") -const DEFAULT_AUTH_LOGIN_URL := "https://pauth.ranaze.com/api/Auth/login" -const DEFAULT_AUTH_REGISTER_URL := "https://pauth.ranaze.com/api/Auth/register" +const AUTH_BASE := "https://pauth.ranaze.com/api/Auth" var main: Control +var auth_base_url := AUTH_BASE +var background: ColorRect +var left_brand: VBoxContainer var auth_panel: PanelContainer -var auth_prompt_label: Label -var switch_mode_button: Button -var login_fields: VBoxContainer -var register_fields: VBoxContainer -var username_input: LineEdit +var panel_content: VBoxContainer +var heading: Label +var subheading: Label +var message_label: Label +var theme_button: Button +var login_view: VBoxContainer +var register_view: VBoxContainer +var reset_view: VBoxContainer +var steam_view: VBoxContainer +var login_input: LineEdit var password_input: LineEdit var registration_username_input: LineEdit var registration_email_input: LineEdit var registration_password_input: LineEdit var registration_confirm_input: LineEdit -var login_button: Button -var register_button: Button -var error_label: Label -var success_label: Label -var login_request: HTTPRequest -var register_request: HTTPRequest -var auth_mode := "login" +var reset_login_input: LineEdit +var reset_code_input: LineEdit +var reset_password_input: LineEdit +var reset_confirm_input: LineEdit +var reset_confirm_group: VBoxContainer +var steam_action: OptionButton +var steam_username_input: LineEdit +var steam_email_input: LineEdit +var steam_password_input: LineEdit +var submit_buttons: Array[Button] = [] +var fields: Array[LineEdit] = [] +var requests: Dictionary = {} +var poll_timer: Timer +var mode := "login" +var reset_requested := false +var steam_transaction_id := "" +var steam_poll_attempts := 0 var automatic_login_pending := false var login_attempt_username := "" -var auth_login_url := DEFAULT_AUTH_LOGIN_URL -var auth_register_url := DEFAULT_AUTH_REGISTER_URL +var compact_layout := false +var layout_updating := false func _ready() -> void: - auth_login_url = EndpointConfig.resolve(DEFAULT_AUTH_LOGIN_URL, "/api/Auth/login") - auth_register_url = EndpointConfig.resolve(DEFAULT_AUTH_REGISTER_URL, "/api/Auth/register") - set_anchors_preset(Control.PRESET_FULL_RECT) - add_theme_constant_override("separation", 18) - - var spacer_top := Control.new() - spacer_top.size_flags_vertical = Control.SIZE_EXPAND_FILL - add_child(spacer_top) - - var center := CenterContainer.new() - center.size_flags_horizontal = Control.SIZE_EXPAND_FILL - add_child(center) - - auth_panel = PanelContainer.new() - auth_panel.custom_minimum_size = Vector2(540, 470) - auth_panel.add_theme_stylebox_override("panel", Style.panel_box(Color("172337"), Style.CYAN)) - center.add_child(auth_panel) - - var content := VBoxContainer.new() - content.add_theme_constant_override("separation", 14) - auth_panel.add_child(content) - - var title := Label.new() - title.text = "RanaZe's Doctor Maker" - title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - Style.set_label(title, 34, Style.TEXT) - content.add_child(title) - - auth_prompt_label = Label.new() - auth_prompt_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - auth_prompt_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - Style.set_label(auth_prompt_label, 16, Style.MUTED) - content.add_child(auth_prompt_label) - - login_fields = VBoxContainer.new() - login_fields.add_theme_constant_override("separation", 10) - content.add_child(login_fields) - username_input = _add_text_field(login_fields, "Username", "enter username") - username_input.text_submitted.connect(_on_login_input_submitted) - password_input = _add_text_field(login_fields, "Password", "enter password", true) - password_input.text_submitted.connect(_on_login_input_submitted) - - login_button = Button.new() - login_button.text = "Log In" - login_button.custom_minimum_size = Vector2(0, 50) - Style.style_primary_button(login_button) - login_button.pressed.connect(_on_login_pressed) - login_fields.add_child(login_button) - - register_fields = VBoxContainer.new() - register_fields.add_theme_constant_override("separation", 10) - content.add_child(register_fields) - registration_username_input = _add_text_field(register_fields, "Username", "3-24 letters, numbers, or underscores") - registration_email_input = _add_text_field(register_fields, "Email", "name@example.com") - registration_password_input = _add_text_field(register_fields, "Password", "at least 8 characters", true) - registration_confirm_input = _add_text_field(register_fields, "Confirm password", "re-enter password", true) - registration_confirm_input.text_submitted.connect(_on_register_input_submitted) - - register_button = Button.new() - register_button.text = "Create Account" - register_button.custom_minimum_size = Vector2(0, 50) - Style.style_primary_button(register_button) - register_button.pressed.connect(_on_register_pressed) - register_fields.add_child(register_button) - - success_label = Label.new() - success_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - success_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - Style.set_label(success_label, 15, Style.LIME) - content.add_child(success_label) - - error_label = Label.new() - error_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - error_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - Style.set_label(error_label, 15, Style.RED) - content.add_child(error_label) - - var switch_spacer := Control.new() - switch_spacer.custom_minimum_size = Vector2(0, 8) - content.add_child(switch_spacer) - - switch_mode_button = Button.new() - switch_mode_button.custom_minimum_size = Vector2(0, 46) - Style.style_chip(switch_mode_button, Style.CYAN) - switch_mode_button.pressed.connect(_toggle_auth_mode) - content.add_child(switch_mode_button) - - var spacer_bottom := Control.new() - spacer_bottom.size_flags_vertical = Control.SIZE_EXPAND_FILL - add_child(spacer_bottom) - - login_request = HTTPRequest.new() - login_request.name = "LoginRequest" - login_request.request_completed.connect(_on_login_request_completed) - add_child(login_request) - - register_request = HTTPRequest.new() - register_request.name = "RegisterRequest" - register_request.request_completed.connect(_on_register_request_completed) - add_child(register_request) - _set_auth_mode("login") + auth_base_url = EndpointConfig.resolve(AUTH_BASE, "/api/Auth") + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _build_ui() + _create_request("login", _on_login_completed) + _create_request("register", _on_register_completed) + _create_request("reset_request", _on_reset_requested) + _create_request("reset_confirm", _on_reset_confirmed) + _create_request("steam_start", _on_steam_started) + _create_request("steam_status", _on_steam_status) + _create_request("steam_complete", _on_steam_completed) + poll_timer = Timer.new() + poll_timer.wait_time = 2.0 + poll_timer.timeout.connect(_poll_steam) + add_child(poll_timer) + get_viewport().size_changed.connect(_update_responsive_layout) + UIThemeService.theme_changed.connect(_on_theme_changed) + _update_responsive_layout() + _set_mode("login") + _apply_theme() func on_show(_payload: Variant = null) -> void: _set_busy(false) - _set_auth_mode("login") - _clear_messages() - username_input.grab_focus() + _set_mode("login") + _clear_message() + login_input.grab_focus() -func _add_text_field(parent: VBoxContainer, field_name: String, placeholder: String, secret: bool = false) -> LineEdit: +func on_hide() -> void: + poll_timer.stop() + + +func _build_ui() -> void: + background = ColorRect.new() + background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + background.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(background) + + _add_dot_grid(Vector2(22, 24)) + + theme_button = Button.new() + theme_button.tooltip_text = "Switch color theme" + theme_button.custom_minimum_size = Vector2(44, 44) + theme_button.set_anchors_preset(Control.PRESET_TOP_RIGHT) + theme_button.position = Vector2(-58, 14) + theme_button.pressed.connect(UIThemeService.toggle_theme) + add_child(theme_button) + + var page_margin := MarginContainer.new() + page_margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + page_margin.add_theme_constant_override("margin_left", 42) + page_margin.add_theme_constant_override("margin_right", 42) + page_margin.add_theme_constant_override("margin_top", 38) + page_margin.add_theme_constant_override("margin_bottom", 38) + add_child(page_margin) + + var columns := HBoxContainer.new() + columns.add_theme_constant_override("separation", 54) + page_margin.add_child(columns) + + left_brand = VBoxContainer.new() + left_brand.size_flags_horizontal = Control.SIZE_EXPAND_FILL + left_brand.alignment = BoxContainer.ALIGNMENT_CENTER + left_brand.add_theme_constant_override("separation", 24) + columns.add_child(left_brand) + var brand_row := HBoxContainer.new() + brand_row.alignment = BoxContainer.ALIGNMENT_CENTER + brand_row.add_theme_constant_override("separation", 24) + left_brand.add_child(brand_row) + var large_icon := TextureRect.new() + large_icon.texture = preload("res://assets/branding/medmind-icon.png") + large_icon.custom_minimum_size = Vector2(172, 172) + large_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + large_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + brand_row.add_child(large_icon) + var wordmark := Label.new() + wordmark.text = "MedMind" + wordmark.add_theme_font_size_override("font_size", 76) + wordmark.add_theme_font_override("font", Brand.font) + brand_row.add_child(wordmark) + var tagline := Label.new() + tagline.text = "Think like a physician." + tagline.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + tagline.add_theme_font_size_override("font_size", 22) + tagline.add_theme_font_override("font", Brand.font) + left_brand.add_child(tagline) + + var panel_center := CenterContainer.new() + panel_center.size_flags_horizontal = Control.SIZE_EXPAND_FILL + panel_center.size_flags_vertical = Control.SIZE_EXPAND_FILL + columns.add_child(panel_center) + auth_panel = PanelContainer.new() + auth_panel.custom_minimum_size = Vector2(610, 760) + panel_center.add_child(auth_panel) + var scroll := ScrollContainer.new() + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + auth_panel.add_child(scroll) + panel_content = VBoxContainer.new() + panel_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL + panel_content.add_theme_constant_override("separation", 16) + scroll.add_child(panel_content) + + var panel_header := HBoxContainer.new() + panel_header.add_theme_constant_override("separation", 18) + panel_content.add_child(panel_header) + var small_icon := TextureRect.new() + small_icon.texture = preload("res://assets/branding/medmind-icon.png") + small_icon.custom_minimum_size = Vector2(82, 82) + small_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + small_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + panel_header.add_child(small_icon) + var titles := VBoxContainer.new() + titles.size_flags_horizontal = Control.SIZE_EXPAND_FILL + panel_header.add_child(titles) + heading = Label.new() + heading.add_theme_font_size_override("font_size", 34) + heading.add_theme_font_override("font", Brand.font) + titles.add_child(heading) + subheading = Label.new() + subheading.add_theme_font_size_override("font_size", 18) + subheading.add_theme_font_override("font", Brand.font) + titles.add_child(subheading) + + login_view = _build_login_view() + register_view = _build_register_view() + reset_view = _build_reset_view() + steam_view = _build_steam_view() + for view in [login_view, register_view, reset_view, steam_view]: + panel_content.add_child(view) + + message_label = Label.new() + message_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + message_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + message_label.add_theme_font_size_override("font_size", 14) + message_label.add_theme_font_override("font", Brand.font) + panel_content.add_child(message_label) + + +func _build_login_view() -> VBoxContainer: + var view := _new_form() + login_input = _add_field(view, "Email or Username", "Enter your email or username") + password_input = _add_field(view, "Password", "Enter your password", true) + _add_show_password(view, [password_input]) + var forgot := Button.new() + forgot.text = "Forgot password?" + forgot.flat = true + forgot.alignment = HORIZONTAL_ALIGNMENT_RIGHT + forgot.pressed.connect(func(): _set_mode("reset")) + view.add_child(forgot) + var sign_in := _add_primary_button(view, "Sign In", _on_login_pressed) + password_input.text_submitted.connect(func(_text: String): _on_login_pressed()) + var divider := Label.new() + divider.text = "or" + divider.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + view.add_child(divider) + var steam := Button.new() + steam.text = "Sign in with Steam" + steam.custom_minimum_size = Vector2(0, 58) + steam.pressed.connect(_start_steam) + view.add_child(steam) + submit_buttons.append(steam) + var create := Button.new() + create.text = "New to MedMind? Create an account" + create.flat = true + create.pressed.connect(func(): _set_mode("register")) + view.add_child(create) + submit_buttons.append(sign_in) + return view + + +func _build_register_view() -> VBoxContainer: + var view := _new_form() + registration_username_input = _add_field(view, "Username", "3-24 letters, numbers, or underscores") + registration_email_input = _add_field(view, "Email", "name@example.com") + registration_password_input = _add_field(view, "Password", "At least 8 characters", true) + registration_confirm_input = _add_field(view, "Confirm password", "Re-enter your password", true) + _add_show_password(view, [registration_password_input, registration_confirm_input]) + _add_primary_button(view, "Create Account", _on_register_pressed) + _add_back_button(view, "Already have an account? Sign in", "login") + registration_confirm_input.text_submitted.connect(func(_text: String): _on_register_pressed()) + return view + + +func _build_reset_view() -> VBoxContainer: + var view := _new_form() + reset_login_input = _add_field(view, "Email or Username", "Enter your account email or username") + _add_primary_button(view, "Send Verification Code", _request_reset) + reset_confirm_group = _new_form() + view.add_child(reset_confirm_group) + reset_code_input = _add_field(reset_confirm_group, "Verification code", "6-digit code") + reset_code_input.max_length = 6 + reset_password_input = _add_field(reset_confirm_group, "New password", "At least 8 characters", true) + reset_confirm_input = _add_field(reset_confirm_group, "Confirm new password", "Re-enter your new password", true) + _add_show_password(reset_confirm_group, [reset_password_input, reset_confirm_input]) + _add_primary_button(reset_confirm_group, "Update Password", _confirm_reset) + _add_back_button(view, "Back to sign in", "login") + reset_confirm_group.visible = false + return view + + +func _build_steam_view() -> VBoxContainer: + var view := _new_form() + var help := Label.new() + help.text = "Steam was verified. Create a MedMind account or link an existing one." + help.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + view.add_child(help) + steam_action = OptionButton.new() + steam_action.add_item("Create a new MedMind account") + steam_action.add_item("Link an existing MedMind account") + steam_action.item_selected.connect(_on_steam_action_changed) + view.add_child(steam_action) + steam_username_input = _add_field(view, "Username or email", "Choose a username") + steam_email_input = _add_field(view, "Email", "name@example.com") + steam_password_input = _add_field(view, "Password", "At least 8 characters", true) + _add_show_password(view, [steam_password_input]) + _add_primary_button(view, "Continue", _complete_steam) + _add_back_button(view, "Cancel Steam sign in", "login") + return view + + +func _new_form() -> VBoxContainer: + var form := VBoxContainer.new() + form.add_theme_constant_override("separation", 12) + return form + + +func _add_field(parent: VBoxContainer, label_text: String, placeholder: String, secret := false) -> LineEdit: var label := Label.new() - label.text = field_name - Style.set_label(label, 15, Style.CYAN) + label.text = label_text + label.add_theme_font_size_override("font_size", 15) + label.add_theme_font_override("font", Brand.font) parent.add_child(label) - var input := LineEdit.new() input.placeholder_text = placeholder input.secret = secret - input.custom_minimum_size = Vector2(0, 42) + input.custom_minimum_size = Vector2(0, 54) + input.add_theme_font_size_override("font_size", 16) + input.add_theme_font_override("font", Brand.font) parent.add_child(input) + fields.append(input) return input -func _set_auth_mode(next_mode: String) -> void: - auth_mode = next_mode - if login_fields == null: - return - login_fields.visible = auth_mode == "login" - register_fields.visible = auth_mode == "register" - auth_panel.custom_minimum_size.y = 535 if auth_mode == "login" else 650 - if auth_mode == "login": - auth_prompt_label.text = "Log in to continue studying." - switch_mode_button.text = "Create new account" - username_input.grab_focus() - else: - auth_prompt_label.text = "Create your account" - switch_mode_button.text = "Already have an account? Log in" - registration_username_input.grab_focus() - _clear_messages() +func _add_show_password(parent: VBoxContainer, targets: Array) -> void: + var toggle := CheckButton.new() + toggle.text = "Show password" + toggle.toggled.connect(func(show: bool): + for target in targets: + target.secret = not show + ) + parent.add_child(toggle) -func _toggle_auth_mode() -> void: - _set_auth_mode("register" if auth_mode == "login" else "login") +func _add_primary_button(parent: VBoxContainer, text_value: String, callback: Callable) -> Button: + var button := Button.new() + button.text = text_value + button.custom_minimum_size = Vector2(0, 58) + button.pressed.connect(callback) + parent.add_child(button) + submit_buttons.append(button) + return button + + +func _add_back_button(parent: VBoxContainer, text_value: String, target_mode: String) -> void: + var button := Button.new() + button.text = text_value + button.flat = true + button.pressed.connect(func(): _set_mode(target_mode)) + parent.add_child(button) + + +func _add_dot_grid(origin: Vector2) -> void: + for row in range(4): + for column in range(5): + var dot := ColorRect.new() + dot.color = Color(Brand.ROYAL_BLUE, 0.45) + dot.position = origin + Vector2(column * 30, row * 30) + dot.size = Vector2(5, 5) + dot.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(dot) + + +func _set_mode(next_mode: String) -> void: + mode = next_mode + if login_view == null: return + login_view.visible = mode == "login" + register_view.visible = mode == "register" + reset_view.visible = mode == "reset" + steam_view.visible = mode == "steam" + match mode: + "register": heading.text = "Create your account"; subheading.text = "Start studying with MedMind" + "reset": heading.text = "Reset your password"; subheading.text = "We will email you a verification code" + "steam": heading.text = "Finish Steam sign in"; subheading.text = "Connect Steam to MedMind" + _: heading.text = "Welcome back"; subheading.text = "Sign in to continue" + _clear_message() + _update_responsive_layout() + + +func _update_responsive_layout() -> void: + if left_brand == null or layout_updating: return + layout_updating = true + var narrow := size.x < 1120.0 + if OS.has_feature("web"): + var browser_width: Variant = JavaScriptBridge.eval("window.innerWidth", true) + narrow = float(browser_width) < 760.0 + left_brand.visible = not narrow + compact_layout = narrow + theme_button.custom_minimum_size = Vector2(160, 160) if narrow else Vector2(44, 44) + theme_button.position = Vector2(-190, 20) if narrow else Vector2(-58, 14) + theme_button.add_theme_font_size_override("font_size", 64 if narrow else 20) + auth_panel.custom_minimum_size = Vector2(1700, 2300) if narrow else Vector2(610, 780) + _apply_compact_scale(panel_content, 3.8 if narrow else 1.0) + _apply_theme() + layout_updating = false + + +func _apply_compact_scale(control: Control, factor: float) -> void: + if not control.has_meta("medmind_base_minimum"): + control.set_meta("medmind_base_minimum", control.custom_minimum_size) + var base_minimum: Vector2 = control.get_meta("medmind_base_minimum") + control.custom_minimum_size = base_minimum * factor + if control is Label or control is Button or control is LineEdit or control is OptionButton: + if not control.has_meta("medmind_base_font_size"): + control.set_meta("medmind_base_font_size", control.get_theme_font_size("font_size")) + control.add_theme_font_size_override("font_size", int(round(float(control.get_meta("medmind_base_font_size")) * factor))) + for child in control.get_children(): + if child is Control: + _apply_compact_scale(child, factor) + + +func _apply_theme() -> void: + var dark := UIThemeService.is_dark_mode() + background.color = Brand.background_color(dark) + auth_panel.add_theme_stylebox_override("panel", Brand.panel_box(dark)) + theme_button.text = "☀" if dark else "☾" + theme_button.tooltip_text = "Use light theme" if dark else "Use dark theme" + theme_button.add_theme_font_override("font", Brand.font) + for node in find_children("*", "Label", true, false): + (node as Label).add_theme_color_override("font_color", Brand.text_color(dark)) + for input in fields: + input.add_theme_color_override("font_color", Brand.text_color(dark)) + input.add_theme_color_override("font_placeholder_color", Brand.muted_color(dark)) + 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): + var typed := button as Button + typed.add_theme_font_override("font", Brand.font) + typed.add_theme_color_override("font_color", Brand.text_color(dark)) + if not typed.flat: + typed.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark)) + typed.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true)) + for button in submit_buttons: + if button.text not in ["Sign in with Steam"]: + button.add_theme_stylebox_override("normal", Brand.gradient_button_box()) + button.add_theme_color_override("font_color", Color.WHITE) + subheading.add_theme_color_override("font_color", Brand.muted_color(dark)) + message_label.add_theme_color_override("font_color", Brand.muted_color(dark)) + + +func _on_theme_changed(_is_dark: bool) -> void: + _apply_theme() func _on_login_pressed() -> void: - _send_login(username_input.text.strip_edges(), password_input.text, false) + _send_login(login_input.text.strip_edges(), password_input.text, false) -func _send_login(username: String, password: String, is_automatic: bool) -> void: - if username.is_empty() or password.is_empty(): - _show_error("Username and password required.") - return - - automatic_login_pending = is_automatic - login_attempt_username = username - _set_busy(true) - login_button.text = "Signing in..." - _clear_messages() - if is_automatic: - success_label.text = "Account created. Signing you in..." - var payload := {"username": username, "password": password} - var headers := PackedStringArray(["Content-Type: application/json"]) - var request_error := login_request.request(auth_login_url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload)) - if request_error != OK: - _set_busy(false) - login_button.text = "Log In" - _show_error("Failed to send login request.") +func _send_login(identifier: String, password: String, automatic: bool) -> void: + if identifier.is_empty() or password.is_empty(): return _show_error("Email or username and password are required.") + automatic_login_pending = automatic + login_attempt_username = identifier + _send_json("login", "/login", {"username": identifier, "password": password}, "Signing in...") -func _on_login_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: +func _on_login_completed(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: _set_busy(false) - login_button.text = "Log In" - - if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300: if automatic_login_pending: - _set_auth_mode("login") - username_input.text = login_attempt_username - password_input.text = "" - _show_error("Account created, but automatic login failed. Please log in with your new account.") - else: - _show_error("Login failed. Check your credentials and try again.") + _set_mode("login"); login_input.text = login_attempt_username + _show_error("Account created, but automatic sign in failed. Please sign in.") + else: _show_error("Sign in failed. Check your credentials and connection.") automatic_login_pending = false return - - var response: Variant = JSON.parse_string(body.get_string_from_utf8()) - if typeof(response) != TYPE_DICTIONARY: - _show_error("Login response was not valid JSON.") - return - - var token := String(response.get("accessToken", "")) - var response_username := String(response.get("username", login_attempt_username)) - if token.is_empty(): - _show_error("Login response did not include an access token.") - return - - AuthState.set_session( - response_username, - token, - String(response.get("refreshToken", "")), - String(response.get("role", "")), - String(response.get("exp", "")) - ) - UISoundService.play_sound("correct") - password_input.text = "" - registration_password_input.text = "" - registration_confirm_input.text = "" - automatic_login_pending = false - main.show_screen("selection", "exam") + _accept_session(body, login_attempt_username) func _on_register_pressed() -> void: var username := registration_username_input.text.strip_edges() var email := registration_email_input.text.strip_edges() var password := registration_password_input.text - var confirmation := registration_confirm_input.text - var validation_error := _validate_registration(username, email, password, confirmation) - if validation_error != "": - _show_error(validation_error) - return - - _set_busy(true) - register_button.text = "Creating account..." - _clear_messages() - var payload := {"username": username, "email": email, "password": password} - var headers := PackedStringArray(["Content-Type: application/json"]) - var request_error := register_request.request(auth_register_url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload)) - if request_error != OK: - _set_busy(false) - register_button.text = "Create Account" - _show_error("Failed to send account creation request.") + var error := _validate_account(username, email, password, registration_confirm_input.text) + if not error.is_empty(): return _show_error(error) + login_attempt_username = username + _send_json("register", "/register", {"username": username, "email": email, "password": password}, "Creating account...") -func _on_register_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: +func _on_register_completed(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: _set_busy(false) - register_button.text = "Create Account" - if result != HTTPRequest.RESULT_SUCCESS: - _clear_registration_passwords() - _show_error("Network error. Please try again.") - return - if response_code < 200 or response_code >= 300: - _clear_registration_passwords() - var server_message := _response_message(body) - _show_error(server_message if server_message != "" else "Account creation failed.") - return - - var username := registration_username_input.text.strip_edges() - var password := registration_password_input.text - username_input.text = username - _send_login(username, password, true) + if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300: + return _show_error(_response_message(body, "Account creation failed.")) + _send_login(login_attempt_username, registration_password_input.text, true) -func _validate_registration(username: String, email: String, password: String, confirmation: String) -> String: - var username_pattern := RegEx.new() - username_pattern.compile("^[A-Za-z0-9_]{3,24}$") - if username_pattern.search(username) == null: - registration_username_input.grab_focus() - return "Username must be 3-24 characters using only letters, numbers, or underscores." - var email_pattern := RegEx.new() - email_pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$") - if email_pattern.search(email) == null: - registration_email_input.grab_focus() - return "Enter a valid email address." - if password.length() < 8: - registration_password_input.grab_focus() - return "Password must be at least 8 characters." - if password != confirmation: - registration_confirm_input.grab_focus() - return "Passwords do not match." +func _request_reset() -> void: + if reset_login_input.text.strip_edges().is_empty(): return _show_error("Enter your email or username.") + _send_json("reset_request", "/password-reset/request", {"login": reset_login_input.text.strip_edges()}, "Sending code...") + + +func _on_reset_requested(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + _set_busy(false) + if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300: + return _show_error(_response_message(body, "Unable to send a verification code.")) + reset_requested = true + reset_confirm_group.visible = true + _show_success("If the account exists, a verification code has been sent.") + reset_code_input.grab_focus() + + +func _confirm_reset() -> void: + if reset_code_input.text.length() != 6: return _show_error("Enter the 6-digit verification code.") + if reset_password_input.text.length() < 8: return _show_error("Password must be at least 8 characters.") + if reset_password_input.text != reset_confirm_input.text: return _show_error("Passwords do not match.") + _send_json("reset_confirm", "/password-reset/confirm", { + "login": reset_login_input.text.strip_edges(), "code": reset_code_input.text, + "newPassword": reset_password_input.text + }, "Updating password...") + + +func _on_reset_confirmed(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + _set_busy(false) + if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300: + return _show_error(_response_message(body, "The verification code is invalid or expired.")) + var identifier := reset_login_input.text + _set_mode("login") + login_input.text = identifier + _show_success("Password updated. You can now sign in.") + + +func _start_steam() -> void: + if OS.has_feature("web"): + JavaScriptBridge.eval("window.medmindSteamWindow = window.open('about:blank', 'medmind_steam');", true) + _send_json("steam_start", "/steam/start", {}, "Opening Steam...") + + +func _on_steam_started(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + _set_busy(false) + if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300: + return _show_error("Unable to start Steam sign in.") + var data := _parse_dictionary(body) + steam_transaction_id = str(data.get("transactionId", "")) + var auth_url := str(data.get("authUrl", "")) + if steam_transaction_id.is_empty() or auth_url.is_empty(): return _show_error("Steam returned an invalid sign-in response.") + if OS.has_feature("web"): + JavaScriptBridge.eval("if (window.medmindSteamWindow) { window.medmindSteamWindow.location.href = %s; } else { window.location.href = %s; }" % [JSON.stringify(auth_url), JSON.stringify(auth_url)], true) + else: + OS.shell_open(auth_url) + steam_poll_attempts = 0 + _show_success("Complete sign in in the Steam window. Waiting for verification...") + poll_timer.start() + + +func _poll_steam() -> void: + var status_request: HTTPRequest = requests["steam_status"] + if steam_transaction_id.is_empty() or status_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: return + steam_poll_attempts += 1 + if steam_poll_attempts > 150: + poll_timer.stop(); return _show_error("Steam sign in timed out. Try again when ready.") + var error: Error = status_request.request("%s/steam/status/%s" % [auth_base_url, steam_transaction_id]) + if error != OK: _show_error("Waiting for a stable connection to Steam...") + + +func _on_steam_status(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + if result != HTTPRequest.RESULT_SUCCESS or code >= 500: return + if code == 404: + poll_timer.stop(); return _show_error("Steam sign in expired. Please try again.") + var data := _parse_dictionary(body) + match str(data.get("status", "pending")): + "requires_account": poll_timer.stop(); _set_mode("steam") + "complete": poll_timer.stop(); _accept_session(body, str(data.get("username", ""))) + + +func _on_steam_action_changed(index: int) -> void: + steam_email_input.visible = index == 0 + steam_username_input.placeholder_text = "Choose a username" if index == 0 else "Existing email or username" + + +func _complete_steam() -> void: + var action := "create" if steam_action.selected == 0 else "link" + if action == "create": + var validation := _validate_account(steam_username_input.text.strip_edges(), steam_email_input.text.strip_edges(), steam_password_input.text, steam_password_input.text) + if not validation.is_empty(): return _show_error(validation) + elif steam_username_input.text.strip_edges().is_empty() or steam_password_input.text.is_empty(): + return _show_error("Enter your existing account credentials.") + _send_json("steam_complete", "/steam/complete", { + "transactionId": steam_transaction_id, "action": action, + "username": steam_username_input.text.strip_edges(), "email": steam_email_input.text.strip_edges(), + "password": steam_password_input.text + }, "Connecting account...") + + +func _on_steam_completed(result: int, code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + _set_busy(false) + if result != HTTPRequest.RESULT_SUCCESS or code < 200 or code >= 300: + return _show_error(_response_message(body, "Unable to connect this Steam account.")) + steam_poll_attempts = 0 + poll_timer.start() + _poll_steam() + + +func _accept_session(body: PackedByteArray, fallback_username: String) -> void: + var response := _parse_dictionary(body) + var token := str(response.get("accessToken", "")) + if token.is_empty(): return _show_error("Sign in response did not include an access token.") + AuthState.set_session(str(response.get("username", fallback_username)), token, + str(response.get("refreshToken", "")), str(response.get("role", "")), str(response.get("exp", ""))) + password_input.text = "" + _set_busy(false) + UISoundService.play_sound("correct") + main.show_screen("selection", "exam") + + +func _create_request(key: String, callback: Callable) -> void: + var request := HTTPRequest.new() + request.timeout = 20.0 + request.request_completed.connect(callback) + add_child(request) + requests[key] = request + + +func _send_json(key: String, path: String, payload: Dictionary, status: String) -> void: + _clear_message() + _set_busy(true) + message_label.text = status + var request: HTTPRequest = requests[key] + var error := request.request(auth_base_url + path, PackedStringArray(["Content-Type: application/json"]), HTTPClient.METHOD_POST, JSON.stringify(payload)) + if error != OK: + _set_busy(false) + _show_error("Unable to send the request. Check your connection and try again.") + + +func _set_busy(busy: bool) -> void: + for button in submit_buttons: button.disabled = busy + for field in fields: field.editable = not busy + + +func _validate_account(username: String, email: String, password: String, confirmation: String) -> String: + var username_regex := RegEx.new(); username_regex.compile("^[A-Za-z0-9_]{3,24}$") + if username_regex.search(username) == null: return "Username must be 3-24 letters, numbers, or underscores." + var email_regex := RegEx.new(); email_regex.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$") + if email_regex.search(email) == null: return "Enter a valid email address." + if password.length() < 8: return "Password must be at least 8 characters." + if password != confirmation: return "Passwords do not match." return "" -func _set_busy(is_busy: bool) -> void: - if login_button == null: - return - login_button.disabled = is_busy - register_button.disabled = is_busy - switch_mode_button.disabled = is_busy - username_input.editable = not is_busy - password_input.editable = not is_busy - registration_username_input.editable = not is_busy - registration_email_input.editable = not is_busy - registration_password_input.editable = not is_busy - registration_confirm_input.editable = not is_busy +func _parse_dictionary(body: PackedByteArray) -> Dictionary: + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + return parsed if typeof(parsed) == TYPE_DICTIONARY else {} -func _clear_registration_passwords() -> void: - registration_password_input.text = "" - registration_confirm_input.text = "" - - -func _response_message(body: PackedByteArray) -> String: +func _response_message(body: PackedByteArray, fallback: String) -> String: var raw := body.get_string_from_utf8().strip_edges() var parsed: Variant = JSON.parse_string(raw) - return String(parsed) if typeof(parsed) == TYPE_STRING else raw + if typeof(parsed) == TYPE_STRING and not str(parsed).is_empty(): return str(parsed) + if typeof(parsed) == TYPE_DICTIONARY and parsed.has("message"): return str(parsed.message) + return fallback -func _on_login_input_submitted(_new_text: String) -> void: - _on_login_pressed() +func _clear_message() -> void: + if message_label != null: message_label.text = "" -func _on_register_input_submitted(_new_text: String) -> void: - _on_register_pressed() - - -func _clear_messages() -> void: - error_label.text = "" - success_label.text = "" - - -func _show_error(message: String) -> void: +func _show_error(value: String) -> void: + _set_busy(false) + message_label.text = value + message_label.add_theme_color_override("font_color", Color("ef4444")) UISoundService.play_sound("error") - success_label.text = "" - error_label.text = message + + +func _show_success(value: String) -> void: + message_label.text = value + message_label.add_theme_color_override("font_color", Brand.SUCCESS) diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index 784f678..d9197e1 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -14,6 +14,7 @@ var feedback_button: Button var feedback_dialog: Control var theme_service var current_screen_name := "" +var screens_margin: MarginContainer func _ready() -> void: theme_service = get_node("/root/UIThemeService") @@ -47,6 +48,12 @@ func show_screen(screen_name: String, payload: Variant = null) -> void: return current_screen_name = screen_name + if screens_margin != null: + var margin := 0 if screen_name == "login" else 24 + screens_margin.add_theme_constant_override("margin_left", margin) + screens_margin.add_theme_constant_override("margin_right", margin) + screens_margin.add_theme_constant_override("margin_top", 0 if screen_name == "login" else 18) + screens_margin.add_theme_constant_override("margin_bottom", 0 if screen_name == "login" else 22) screen.visible = true if header_panel != null: header_panel.visible = screen_name not in ["login", "player", "ccs_player"] @@ -77,7 +84,7 @@ func _build_ui() -> void: header_panel.add_child(header) var title := Label.new() - title.text = "RanaZe's Doctor Maker" + title.text = "MedMind" Style.set_label(title, 31, Style.TEXT) title.size_flags_horizontal = Control.SIZE_EXPAND_FILL header.add_child(title) @@ -115,6 +122,7 @@ func _build_ui() -> void: _refresh_theme_button() var screens_container := MarginContainer.new() + screens_margin = screens_container screens_container.name = "Screens" screens_container.size_flags_vertical = Control.SIZE_EXPAND_FILL screens_container.add_theme_constant_override("margin_left", 24) diff --git a/scripts/ui/medmind_style.gd b/scripts/ui/medmind_style.gd new file mode 100644 index 0000000..83dc1d7 --- /dev/null +++ b/scripts/ui/medmind_style.gd @@ -0,0 +1,79 @@ +extends RefCounted + +const DEEP_NAVY := Color("0b1324") +const ROYAL_BLUE := Color("2563eb") +const SKY_BLUE := Color("38bdf8") +const PURPLE := Color("7c3aed") +const MAGENTA := Color("d946ef") +const TEAL := Color("14b8a6") +const SUCCESS := Color("22c55e") +const AMBER := Color("f59e0b") +const SLATE := Color("94a3b8") +const DARK_TEXT := Color("f8fafc") +const LIGHT_TEXT := Color("0f172a") + +static var font: Font = preload("res://assets/fonts/Inter-Variable.ttf") + + +static func text_color(dark: bool) -> Color: + return DARK_TEXT if dark else LIGHT_TEXT + + +static func muted_color(dark: bool) -> Color: + return Color("a9b5ca") if dark else Color("53627a") + + +static func background_color(dark: bool) -> Color: + return DEEP_NAVY if dark else Color("f5f8ff") + + +static func panel_box(dark: bool) -> StyleBoxFlat: + var box := StyleBoxFlat.new() + box.bg_color = Color("0b1427cc") if dark else Color("ffffffee") + box.border_color = Color("33415d") if dark else Color("d4def0") + box.set_border_width_all(1) + box.set_corner_radius_all(8) + box.content_margin_left = 54 + box.content_margin_right = 54 + box.content_margin_top = 48 + box.content_margin_bottom = 42 + return box + + +static func field_box(dark: bool, focused: bool = false) -> StyleBoxFlat: + var box := StyleBoxFlat.new() + box.bg_color = Color("0d172a") if dark else Color("f8faff") + box.border_color = ROYAL_BLUE if focused else (Color("33415d") if dark else Color("cbd6e8")) + box.set_border_width_all(1) + box.set_corner_radius_all(8) + box.content_margin_left = 18 + box.content_margin_right = 18 + box.content_margin_top = 15 + box.content_margin_bottom = 15 + return box + + +static func secondary_button_box(dark: bool, hover: bool = false) -> StyleBoxFlat: + var box := field_box(dark, hover) + box.bg_color = Color("111b30") if dark else Color("ffffff") + return box + + +static func gradient_button_box() -> StyleBoxTexture: + var gradient := Gradient.new() + gradient.colors = PackedColorArray([Color("1677ff"), Color("8f3ff4")]) + var texture := GradientTexture2D.new() + texture.gradient = gradient + texture.width = 512 + texture.height = 64 + texture.fill_from = Vector2(0, 0.5) + texture.fill_to = Vector2(1, 0.5) + var box := StyleBoxTexture.new() + box.texture = texture + box.content_margin_top = 14 + box.content_margin_bottom = 14 + return box + + +static func apply_font(control: Control) -> void: + control.add_theme_font_override("font", font) diff --git a/scripts/ui/medmind_style.gd.uid b/scripts/ui/medmind_style.gd.uid new file mode 100644 index 0000000..fbebb6f --- /dev/null +++ b/scripts/ui/medmind_style.gd.uid @@ -0,0 +1 @@ +uid://defm4b6tk5n1h