diff --git a/scenes/profile_ui.tscn b/scenes/profile_ui.tscn new file mode 100644 index 0000000..ac3e6b3 --- /dev/null +++ b/scenes/profile_ui.tscn @@ -0,0 +1,215 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/profile_ui.gd" id="1_pu"] + +[node name="ProfileUI" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_pu") + +[node name="VBoxContainer" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 32.0 +offset_top = 32.0 +offset_right = -32.0 +offset_bottom = -32.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/separation = 12 + +[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"] +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"] +custom_minimum_size = Vector2(100, 36) +layout_mode = 2 +text = "< Back" + +[node name="TitleLabel" type="Label" parent="VBoxContainer/HeaderHBox"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 26 +text = "Profile" +horizontal_alignment = 1 + +[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"] +custom_minimum_size = Vector2(100, 36) +layout_mode = 2 +text = "Refresh" + +[node name="LookupHBox" type="HBoxContainer" parent="VBoxContainer"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="LookupPrefix" type="Label" parent="VBoxContainer/LookupHBox"] +layout_mode = 2 +text = "Lookup User ID:" + +[node name="UserIdEdit" type="LineEdit" parent="VBoxContainer/LookupHBox"] +custom_minimum_size = Vector2(120, 32) +layout_mode = 2 +placeholder_text = "e.g. 2" + +[node name="ViewUserButton" type="Button" parent="VBoxContainer/LookupHBox"] +custom_minimum_size = Vector2(100, 32) +layout_mode = 2 +text = "View" + +[node name="Spacer" type="Control" parent="VBoxContainer/LookupHBox"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="ViewMyProfileButton" type="Button" parent="VBoxContainer/LookupHBox"] +custom_minimum_size = Vector2(140, 32) +layout_mode = 2 +text = "My Own Profile" + +[node name="StatusMessageLabel" type="Label" parent="VBoxContainer"] +layout_mode = 2 +horizontal_alignment = 1 + +[node name="Scroll" type="ScrollContainer" parent="VBoxContainer"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="ContentVBox" type="VBoxContainer" parent="VBoxContainer/Scroll"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 16 + +[node name="CardPanel" type="PanelContainer" parent="VBoxContainer/Scroll/ContentVBox"] +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="VBoxContainer/Scroll/ContentVBox/CardPanel"] +layout_mode = 2 +theme_override_constants/margin_left = 16 +theme_override_constants/margin_top = 16 +theme_override_constants/margin_right = 16 +theme_override_constants/margin_bottom = 16 + +[node name="HBox" type="HBoxContainer" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin"] +layout_mode = 2 +theme_override_constants/separation = 20 + +[node name="AvatarBox" type="VBoxContainer" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox"] +custom_minimum_size = Vector2(120, 120) +layout_mode = 2 +alignment = 1 + +[node name="AvatarLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/AvatarBox"] +layout_mode = 2 +text = "Avatar" +horizontal_alignment = 1 + +[node name="DetailsVBox" type="VBoxContainer" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 6 + +[node name="UsernameLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox"] +layout_mode = 2 +theme_override_font_sizes/font_size = 22 +text = "Username" + +[node name="UserIdLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox"] +layout_mode = 2 +text = "User ID: --" + +[node name="BioPrompt" type="Label" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox"] +layout_mode = 2 +text = "Bio:" + +[node name="BioEdit" type="TextEdit" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox"] +custom_minimum_size = Vector2(0, 80) +layout_mode = 2 +placeholder_text = "Tell the world about yourself..." + +[node name="SaveBioButton" type="Button" parent="VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox"] +custom_minimum_size = Vector2(100, 32) +layout_mode = 2 +size_flags_horizontal = 0 +text = "Save Bio" + +[node name="PrivacyVBox" type="VBoxContainer" parent="VBoxContainer/Scroll/ContentVBox"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="PrivacyHeader" type="Label" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox"] +layout_mode = 2 +theme_override_font_sizes/font_size = 18 +text = "Privacy Settings" + +[node name="Grid" type="GridContainer" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox"] +layout_mode = 2 +columns = 2 +theme_override_constants/h_separation = 16 +theme_override_constants/v_separation = 8 + +[node name="ProfileVisLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid"] +layout_mode = 2 +text = "Profile Visibility:" + +[node name="ProfileVisOption" type="OptionButton" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid"] +layout_mode = 2 + +[node name="FriendsVisLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid"] +layout_mode = 2 +text = "Friends List Visibility:" + +[node name="FriendsVisOption" type="OptionButton" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid"] +layout_mode = 2 + +[node name="FavoritesVisLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid"] +layout_mode = 2 +text = "Favorites Visibility:" + +[node name="FavoritesVisOption" type="OptionButton" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid"] +layout_mode = 2 + +[node name="SavePrivacyButton" type="Button" parent="VBoxContainer/Scroll/ContentVBox/PrivacyVBox"] +custom_minimum_size = Vector2(140, 32) +layout_mode = 2 +size_flags_horizontal = 0 +text = "Save Privacy" + +[node name="FavoritesVBox" type="VBoxContainer" parent="VBoxContainer/Scroll/ContentVBox"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="FavHeader" type="Label" parent="VBoxContainer/Scroll/ContentVBox/FavoritesVBox"] +layout_mode = 2 +theme_override_font_sizes/font_size = 18 +text = "Favorite Experiences" + +[node name="FavoritesStatusLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/FavoritesVBox"] +layout_mode = 2 +text = "No favorite experiences yet." + +[node name="FavoritesList" type="VBoxContainer" parent="VBoxContainer/Scroll/ContentVBox/FavoritesVBox"] +layout_mode = 2 +theme_override_constants/separation = 4 + +[node name="FriendsVBox" type="VBoxContainer" parent="VBoxContainer/Scroll/ContentVBox"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="FriendsHeader" type="Label" parent="VBoxContainer/Scroll/ContentVBox/FriendsVBox"] +layout_mode = 2 +theme_override_font_sizes/font_size = 18 +text = "Friends" + +[node name="FriendsStatusLabel" type="Label" parent="VBoxContainer/Scroll/ContentVBox/FriendsVBox"] +layout_mode = 2 +text = "No friends listed." + +[node name="FriendsList" type="VBoxContainer" parent="VBoxContainer/Scroll/ContentVBox/FriendsVBox"] +layout_mode = 2 +theme_override_constants/separation = 4 diff --git a/scripts/profile_ui.gd b/scripts/profile_ui.gd new file mode 100644 index 0000000..dd55454 --- /dev/null +++ b/scripts/profile_ui.gd @@ -0,0 +1,312 @@ +extends Control +class_name ProfileUI +## Profile Page UI. +## +## Displays: +## - User avatar representation, username, and bio +## - Favorite experiences (worlds) list +## - Public/mutual friends list +## - Respects server-enforced privacy controls and blocked-user restricted view +## - Allows authenticated user to edit their own bio and privacy settings + +signal back_requested + +@onready var back_button: Button = $VBoxContainer/HeaderHBox/BackButton +@onready var title_label: Label = $VBoxContainer/HeaderHBox/TitleLabel +@onready var refresh_button: Button = $VBoxContainer/HeaderHBox/RefreshButton +@onready var status_label: Label = $VBoxContainer/StatusMessageLabel + +# Lookup controls +@onready var user_id_edit: LineEdit = $VBoxContainer/LookupHBox/UserIdEdit +@onready var view_user_btn: Button = $VBoxContainer/LookupHBox/ViewUserButton +@onready var view_my_profile_btn: Button = $VBoxContainer/LookupHBox/ViewMyProfileButton + +# Profile Card details +@onready var username_label: Label = $VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox/UsernameLabel +@onready var user_id_label: Label = $VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox/UserIdLabel +@onready var avatar_preview_label: Label = $VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/AvatarBox/AvatarLabel +@onready var bio_edit: TextEdit = $VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox/BioEdit +@onready var save_bio_btn: Button = $VBoxContainer/Scroll/ContentVBox/CardPanel/Margin/HBox/DetailsVBox/SaveBioButton + +# Privacy Controls (visible only for own profile) +@onready var privacy_vbox: VBoxContainer = $VBoxContainer/Scroll/ContentVBox/PrivacyVBox +@onready var profile_vis_opt: OptionButton = $VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid/ProfileVisOption +@onready var friends_vis_opt: OptionButton = $VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid/FriendsVisOption +@onready var favorites_vis_opt: OptionButton = $VBoxContainer/Scroll/ContentVBox/PrivacyVBox/Grid/FavoritesVisOption +@onready var save_privacy_btn: Button = $VBoxContainer/Scroll/ContentVBox/PrivacyVBox/SavePrivacyButton + +# Favorites list +@onready var favorites_container: VBoxContainer = $VBoxContainer/Scroll/ContentVBox/FavoritesVBox/FavoritesList +@onready var favorites_status_label: Label = $VBoxContainer/Scroll/ContentVBox/FavoritesVBox/FavoritesStatusLabel + +# Friends list +@onready var friends_container: VBoxContainer = $VBoxContainer/Scroll/ContentVBox/FriendsVBox/FriendsList +@onready var friends_status_label: Label = $VBoxContainer/Scroll/ContentVBox/FriendsVBox/FriendsStatusLabel + +var _api_client: Node = null +var _viewing_user_id: int = 0 +var _my_user_id: int = 0 +var _is_own_profile: bool = false + + +func _ready() -> void: + _api_client = _get_api() + _setup_privacy_options() + _wire_signals() + + if _api_client != null and _api_client.has_token(): + _init_my_profile() + + +func _get_api() -> Node: + if has_node("/root/ApiClient"): + return get_node("/root/ApiClient") + return null + + +func _setup_privacy_options() -> void: + var visibilities := ["public", "friends_only", "private"] + for opt in [profile_vis_opt, friends_vis_opt, favorites_vis_opt]: + if opt: + opt.clear() + for v in visibilities: + opt.add_item(v.capitalize()) + + +func _wire_signals() -> void: + if back_button: + back_button.pressed.connect(_on_back_pressed) + if refresh_button: + refresh_button.pressed.connect(_refresh_current) + if view_user_btn: + view_user_btn.pressed.connect(_on_view_user_pressed) + if view_my_profile_btn: + view_my_profile_btn.pressed.connect(_init_my_profile) + if save_bio_btn: + save_bio_btn.pressed.connect(_on_save_bio_pressed) + if save_privacy_btn: + save_privacy_btn.pressed.connect(_on_save_privacy_pressed) + + +func _on_back_pressed() -> void: + back_requested.emit() + if get_tree().current_scene == self: + get_tree().change_scene_to_file("res://scenes/main_menu.tscn") + + +func show_message(text: String, is_error: bool = false) -> void: + if status_label: + status_label.text = text + status_label.modulate = Color(1.0, 0.4, 0.4) if is_error else Color(0.4, 1.0, 0.4) + + +func _init_my_profile() -> void: + if _api_client == null or not _api_client.has_token(): + show_message("Log in to view profile.", true) + return + + var me_res: Dictionary = await _api_client.get_me() + if me_res.ok and me_res.data is Dictionary: + _my_user_id = int(me_res.data.get("id", 0)) + load_profile(_my_user_id, true) + + +func _on_view_user_pressed() -> void: + var uid_str := user_id_edit.text.strip_edges() + if not uid_str.is_valid_int(): + show_message("Enter a valid integer user ID.", true) + return + var uid := int(uid_str) + load_profile(uid, uid == _my_user_id) + + +func _refresh_current() -> void: + if _viewing_user_id > 0: + load_profile(_viewing_user_id, _is_own_profile) + + +## Load profile for user_id. +func load_profile(user_id: int, is_own: bool = false) -> void: + _viewing_user_id = user_id + _is_own_profile = is_own + + if _is_own_profile: + await _load_own_profile() + else: + await _load_public_profile(user_id) + + +func _load_own_profile() -> void: + title_label.text = "My Profile" + if privacy_vbox: + privacy_vbox.visible = true + if save_bio_btn: + save_bio_btn.visible = true + if bio_edit: + bio_edit.editable = true + + var res: Dictionary = await _api_client.get_my_profile() + if not res.ok: + show_message("Failed to load own profile: " + res.detail, true) + return + + var data: Dictionary = res.data if res.data is Dictionary else {} + _populate_card(_my_user_id, "You", str(data.get("bio", "")), data.get("avatar_asset_id")) + + # Privacy + if data.has("privacy_json") and data["privacy_json"] is Dictionary: + var priv: Dictionary = data["privacy_json"] + _set_option_val(profile_vis_opt, priv.get("profile_visibility", "public")) + _set_option_val(friends_vis_opt, priv.get("friends_list_visibility", "public")) + _set_option_val(favorites_vis_opt, priv.get("favorites_visibility", "public")) + + # Load own favorites + var fav_res: Dictionary = await _api_client.get_my_favorites() + _populate_favorites(fav_res.data.get("world_ids", []) if fav_res.ok and fav_res.data is Dictionary else []) + + # Load own friends + var fr_res: Dictionary = await _api_client.get_friends() + _populate_friends(fr_res.data.get("friends", []) if fr_res.ok and fr_res.data is Dictionary else []) + + +func _load_public_profile(user_id: int) -> void: + title_label.text = "User Profile" + if privacy_vbox: + privacy_vbox.visible = false + if save_bio_btn: + save_bio_btn.visible = false + if bio_edit: + bio_edit.editable = false + + var res: Dictionary = await _api_client.get_public_profile(user_id) + if not res.ok: + show_message("Failed to load profile: " + res.detail, true) + return + + var data: Dictionary = res.data if res.data is Dictionary else {} + var uname: String = str(data.get("username", "User #%d" % user_id)) + var bio: String = str(data.get("bio", "")) if data.get("bio") != null else "[Bio hidden or empty]" + + _populate_card(user_id, uname, bio, data.get("avatar_asset_id")) + + # Favorites + if data.has("favorites") and data["favorites"] != null: + _populate_favorites(data["favorites"]) + else: + _clear_children(favorites_container) + if favorites_status_label: + favorites_status_label.text = "Favorites are hidden by privacy settings." + favorites_status_label.visible = true + + # Friends + if data.has("friends") and data["friends"] != null: + _populate_friends(data["friends"]) + else: + _clear_children(friends_container) + if friends_status_label: + friends_status_label.text = "Friends list is hidden by privacy settings." + friends_status_label.visible = true + + +func _populate_card(uid: int, uname: String, bio: String, avatar_asset_id: Variant) -> void: + if username_label: + username_label.text = uname + if user_id_label: + user_id_label.text = "User ID: %d" % uid + if bio_edit: + bio_edit.text = bio + if avatar_preview_label: + avatar_preview_label.text = "Avatar #%s" % str(avatar_asset_id) if avatar_asset_id != null else "Default Avatar" + + +func _populate_favorites(favorites: Array) -> void: + _clear_children(favorites_container) + if favorites_status_label: + favorites_status_label.visible = favorites.is_empty() + if favorites.is_empty(): + favorites_status_label.text = "No favorite experiences yet." + + for item in favorites: + var hbox := HBoxContainer.new() + hbox.add_theme_constant_override("separation", 8) + var lbl := Label.new() + if item is Dictionary: + lbl.text = "★ %s (World #%s)" % [str(item.get("name", "World")), str(item.get("id", ""))] + else: + lbl.text = "★ World #%s" % str(item) + hbox.add_child(lbl) + favorites_container.add_child(hbox) + + +func _populate_friends(friends: Array) -> void: + _clear_children(friends_container) + if friends_status_label: + friends_status_label.visible = friends.is_empty() + if friends.is_empty(): + friends_status_label.text = "No friends listed." + + for friend in friends: + var hbox := HBoxContainer.new() + hbox.add_theme_constant_override("separation", 8) + var lbl := Label.new() + if friend is Dictionary: + var fid: int = int(friend.get("id", 0)) + var fname: String = str(friend.get("username", "User")) + lbl.text = "👤 %s (ID: %d)" % [fname, fid] + hbox.add_child(lbl) + var view_btn := Button.new() + view_btn.text = "View" + view_btn.pressed.connect(func(): load_profile(fid, fid == _my_user_id)) + hbox.add_child(view_btn) + else: + lbl.text = "👤 User #%s" % str(friend) + hbox.add_child(lbl) + friends_container.add_child(hbox) + + +func _on_save_bio_pressed() -> void: + if _api_client == null or not _is_own_profile: + return + var new_bio := bio_edit.text.strip_edges() if bio_edit else "" + var res: Dictionary = await _api_client.update_my_profile(new_bio) + if res.ok: + show_message("Bio updated successfully!") + else: + show_message("Failed to update bio: " + res.detail, true) + + +func _on_save_privacy_pressed() -> void: + if _api_client == null or not _is_own_profile: + return + var privacy := { + "profile_visibility": _get_option_val(profile_vis_opt), + "friends_list_visibility": _get_option_val(friends_vis_opt), + "favorites_visibility": _get_option_val(favorites_vis_opt), + } + var res: Dictionary = await _api_client.update_my_profile(null, null, privacy) + if res.ok: + show_message("Privacy settings updated!") + else: + show_message("Failed to update privacy: " + res.detail, true) + + +func _set_option_val(opt: OptionButton, val: String) -> void: + if opt == null: + return + for i in range(opt.item_count): + if opt.get_item_text(i).to_lower() == val.to_lower(): + opt.selected = i + return + + +func _get_option_val(opt: OptionButton) -> String: + if opt == null or opt.selected < 0: + return "public" + return opt.get_item_text(opt.selected).to_lower().replace(" ", "_") + + +func _clear_children(node: Node) -> void: + if node == null: + return + for c in node.get_children(): + c.queue_free() diff --git a/scripts/smoke_test_profile_ui.gd b/scripts/smoke_test_profile_ui.gd new file mode 100644 index 0000000..c18016f --- /dev/null +++ b/scripts/smoke_test_profile_ui.gd @@ -0,0 +1,80 @@ +extends SceneTree +## Headless smoke test for ProfileUI scene and script. + +func _initialize() -> void: + _run() + +func _run() -> void: + await process_frame + + print("SMOKE: Starting Profile UI verification...") + var scene_res := load("res://scenes/profile_ui.tscn") + if scene_res == null: + printerr("FAIL: Could not load res://scenes/profile_ui.tscn") + quit(1) + return + + var ui: Control = scene_res.instantiate() + if ui == null: + printerr("FAIL: Could not instantiate ProfileUI") + quit(1) + return + + root.add_child(ui) + await process_frame + + # Verify essential nodes + if ui.username_label == null or ui.bio_edit == null: + printerr("FAIL: username_label or bio_edit is null") + quit(1) + return + + if ui.privacy_vbox == null or ui.favorites_container == null or ui.friends_container == null: + printerr("FAIL: container nodes are null") + quit(1) + return + + # Verify signal declaration + if not ui.has_signal("back_requested"): + printerr("FAIL: back_requested signal missing") + quit(1) + return + + # Test card population + ui._populate_card(99, "TestExplorer", "Just another world builder", 12) + if ui.username_label.text != "TestExplorer" or ui.bio_edit.text != "Just another world builder": + printerr("FAIL: Card population failed") + quit(1) + return + + print("PASS: Profile card population verified.") + + # Test favorites population + var mock_favs := [ + {"id": 1, "name": "Sunset Plains"}, + {"id": 2, "name": "Neon City Rooftops"} + ] + ui._populate_favorites(mock_favs) + if ui.favorites_container.get_child_count() != 2: + printerr("FAIL: Favorites count mismatch") + quit(1) + return + + print("PASS: Favorites list population verified.") + + # Test friends population + var mock_friends := [ + {"id": 10, "username": "Alice"}, + {"id": 20, "username": "Bob"} + ] + ui._populate_friends(mock_friends) + if ui.friends_container.get_child_count() != 2: + printerr("FAIL: Friends list count mismatch") + quit(1) + return + + print("PASS: Friends list population verified.") + + ui.queue_free() + print("SMOKE RESULT: PASS") + quit(0)