egui_charts/ui/profile/mod.rs
1//! Profile details and follow controls for a host's [`ProfileDirectory`].
2
3use crate::localization as l10n;
4use crate::services::{FollowStatus, ProfileDirectory, ProfileId};
5use crate::theme::ThemeExt;
6use crate::ui_kit::EmptyState;
7use crate::ui_kit::buttons::Button;
8use crate::ui_kit::status_message::{MessageKind, status_message};
9use egui::{RichText, Ui};
10
11/// A profile card that embeds in any layout and sends follow requests to the directory's
12/// [`Profiles`](crate::services::Profiles) service. Details and follow state stay host-owned.
13/// Nothing is cached in the card, so replacing the directory's service clears what it shows.
14///
15/// Handle [`NotificationsAction::OpenProfile`] by selecting its profile in the host's layout, then
16/// call [`show`](Self::show) for it. Keep polling the directory every frame, including while
17/// the card is hidden. Install an egui image loader to display host-provided avatar URIs.
18///
19/// ```
20/// use egui_charts::services::{ProfileDirectory, ProfileId};
21/// use egui_charts::ui::profile::ProfileCard;
22///
23/// let mut directory = ProfileDirectory::new(); // Attach the host with set_service.
24/// let selected = ProfileId("ada".into());
25/// # egui::__run_test_ui(|ui| {
26/// directory.poll(ui.ctx());
27/// ProfileCard::new(&selected).show(ui, &mut directory);
28/// # });
29/// ```
30///
31/// [`NotificationsAction::OpenProfile`]: crate::ui::widget_bar::panels::notifications::NotificationsAction::OpenProfile
32pub struct ProfileCard<'a> {
33 id: &'a ProfileId,
34}
35
36impl<'a> ProfileCard<'a> {
37 /// Shows the profile with this host identifier, or an unavailable state until it is pushed.
38 pub fn new(id: &'a ProfileId) -> Self {
39 Self { id }
40 }
41
42 /// Draws the latest details, follow control and any pending request or host failure.
43 /// A failed request keeps the reported follow state and may be retried or dismissed.
44 pub fn show(self, ui: &mut Ui, directory: &mut ProfileDirectory) {
45 let Some(profile) = directory.profile(self.id) else {
46 EmptyState::new("Profile unavailable").show(ui);
47 return;
48 };
49 let theme = ui.active_theme();
50 let operation = directory.operation(self.id);
51 let pending = operation.is_some_and(|op| op.status == FollowStatus::Pending);
52 let mut follow = None;
53 let mut dismiss = false;
54 ui.push_id(("profile", self.id), |ui| {
55 ui.vertical(|ui| {
56 ui.horizontal_wrapped(|ui| {
57 if let Some(avatar) = &profile.avatar {
58 ui.add(
59 egui::Image::new(avatar)
60 .fit_to_exact_size(egui::vec2(48.0, 48.0))
61 .alt_text(&profile.name)
62 .corner_radius(24),
63 );
64 }
65 ui.label(
66 RichText::new(&profile.name)
67 .size(theme.text.heading)
68 .strong(),
69 );
70 });
71 if !profile.bio.is_empty() {
72 ui.label(&profile.bio);
73 }
74 if let Some(followers) = profile.followers {
75 ui.label(
76 RichText::new(l10n::format_text(
77 ui.ctx(),
78 "Followers: {count}",
79 &[("count", followers.to_string())],
80 ))
81 .color(theme.ui.text_secondary),
82 );
83 }
84 ui.add_space(theme.spacing.sm);
85 ui.horizontal_wrapped(|ui| {
86 let label = if pending {
87 if operation.is_some_and(|op| op.follow) {
88 "Following…"
89 } else {
90 "Unfollowing…"
91 }
92 } else if profile.followed {
93 "Unfollow"
94 } else {
95 "Follow"
96 };
97 if Button::new(label)
98 .primary()
99 .enabled(!pending)
100 .show(ui)
101 .clicked()
102 {
103 follow = Some(!profile.followed);
104 }
105 if pending {
106 ui.spinner();
107 }
108 });
109 if let Some(operation) = operation
110 && let FollowStatus::Failed(message) = &operation.status
111 {
112 status_message(ui, MessageKind::Error, message);
113 dismiss = Button::new("Dismiss").small().show(ui).clicked();
114 }
115 });
116 });
117 if let Some(follow) = follow {
118 directory.follow(self.id, follow);
119 } else if dismiss {
120 directory.dismiss(self.id);
121 }
122 }
123}
124
125#[cfg(test)]
126mod tests;