Add dialogs for connection, channel creation, profile editing, and status management

- Implemented ConnectDialog for server connection and authentication.
- Added CreateChannelDialog for creating new channels with name and topic.
- Developed ProfileEditDialog for editing user profiles including display name, bio, and avatar.
- Created ProfileViewDialog for viewing user profiles with options to edit or set status.
- Introduced StatusDialog for setting user status and status message.
- Added UpdateConfirmDialog for confirming updates and UpdateProgressDialog for showing update progress.
- Removed obsolete hue_icon.ico file from the server.
This commit is contained in:
HueByte
2026-02-22 08:07:53 +01:00
parent 03f4685ae6
commit c5d8e25a86
10 changed files with 0 additions and 0 deletions
@@ -0,0 +1,410 @@
using EchoHub.Client.Services;
using Terminal.Gui.App;
using Terminal.Gui.Drawing;
using Terminal.Gui.ViewBase;
using Terminal.Gui.Views;
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI;
public sealed class AudioPlayerDialog
{
// Block characters for wave animation (increasing height)
private static readonly string[] WaveBlocks = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
private const int WaveBarCount = 24;
private const int AnimationIntervalMs = 150;
private static readonly Attribute WaveActiveAttr = new(new Color(180, 100, 255), Color.None);
private static readonly Attribute WaveIdleAttr = new(new Color(80, 50, 120), Color.None);
private static readonly Attribute FileNameAttr = new(new Color(180, 100, 255), Color.None);
private static readonly Attribute StatusPlayingAttr = new(new Color(0, 200, 0), Color.None);
private static readonly Attribute StatusPausedAttr = new(new Color(220, 180, 0), Color.None);
private static readonly Attribute StatusStoppedAttr = new(new Color(160, 160, 160), Color.None);
public static void Show(IApplication app, AudioPlaybackService audioService, string filePath, string fileName)
{
var dialog = new Dialog { Title = "Audio Player", Width = 52, Height = 14 };
// ── File name ──
var fileLabel = new Label
{
Text = $"\u266a {TruncateFileName(fileName, 44)}",
X = 2,
Y = 1,
Width = Dim.Fill(2)
};
// ── Wave visualization ──
var waveLabel = new Label
{
X = 2,
Y = 3,
Width = Dim.Fill(2),
Height = 1
};
// ── Status label ──
var statusLabel = new Label
{
Text = "Stopped",
X = 2,
Y = 5,
Width = 20
};
// ── Volume controls ──
var volumeHeaderLabel = new Label
{
Text = "Volume:",
X = 2,
Y = 7
};
byte currentVolume = 50;
var volumeBar = new ProgressBar
{
X = 14,
Y = 7,
Width = 20,
Height = 1,
Fraction = currentVolume / 100f,
ProgressBarStyle = ProgressBarStyle.Continuous
};
var volumePercentLabel = new Label
{
Text = $"{currentVolume}%",
X = 35,
Y = 7,
Width = 5
};
var volDownButton = new Button
{
Text = "-",
X = 10,
Y = 7,
Width = 3
};
var volUpButton = new Button
{
Text = "+",
X = 41,
Y = 7,
Width = 3
};
// ── Playback controls ──
var playButton = new Button
{
Text = "\u25b6 Play",
X = 2,
Y = 10,
IsDefault = true
};
var stopButton = new Button
{
Text = "\u25a0 Stop",
X = Pos.Right(playButton) + 2,
Y = 10
};
var closeButton = new Button
{
Text = "Close",
X = Pos.Right(stopButton) + 2,
Y = 10
};
// ── Animation state ──
var animationOffset = 0;
var random = new Random();
// Pre-generate a repeating wave pattern
var wavePattern = new int[WaveBarCount + 8];
for (int i = 0; i < wavePattern.Length; i++)
wavePattern[i] = random.Next(0, WaveBlocks.Length);
Timer? animationTimer = null;
var isDisposed = false;
// ── Helper functions ──
void UpdateWave(bool isActive)
{
if (isDisposed) return;
var bars = new string[WaveBarCount];
for (int i = 0; i < WaveBarCount; i++)
{
if (isActive)
{
var idx = wavePattern[(i + animationOffset) % wavePattern.Length];
bars[i] = WaveBlocks[idx];
}
else
{
bars[i] = WaveBlocks[1]; // low idle bars
}
}
waveLabel.Text = string.Join(" ", bars);
}
void UpdateStatus()
{
if (isDisposed) return;
if (audioService.IsPlaying && !audioService.IsPaused)
{
statusLabel.Text = "Playing";
playButton.Text = "\u23f8 Pause";
}
else if (audioService.IsPaused)
{
statusLabel.Text = "Paused";
playButton.Text = "\u25b6 Resume";
}
else
{
statusLabel.Text = "Stopped";
playButton.Text = "\u25b6 Play";
}
}
void StartAnimation()
{
animationTimer?.Dispose();
animationTimer = new Timer(_ =>
{
if (isDisposed) return;
animationOffset++;
// Shuffle a few bars each tick for organic movement
var idx = random.Next(0, wavePattern.Length);
wavePattern[idx] = random.Next(0, WaveBlocks.Length);
app.Invoke(() =>
{
if (isDisposed) return;
UpdateWave(true);
});
}, null, 0, AnimationIntervalMs);
}
void StopAnimation()
{
animationTimer?.Dispose();
animationTimer = null;
if (!isDisposed)
UpdateWave(false);
}
async Task UpdateVolume(byte newVolume)
{
currentVolume = Math.Clamp(newVolume, (byte)0, (byte)100);
await audioService.SetVolumeAsync(currentVolume);
if (!isDisposed)
{
volumeBar.Fraction = currentVolume / 100f;
volumePercentLabel.Text = $"{currentVolume}%";
}
}
// ── Custom drawing for colored elements ──
fileLabel.DrawingContent += (s, e) =>
{
var normalAttr = fileLabel.GetAttributeForRole(VisualRole.Normal);
var resolvedAttr = FileNameAttr.Background == Color.None
? FileNameAttr with { Background = normalAttr.Background }
: FileNameAttr;
fileLabel.SetAttribute(resolvedAttr);
fileLabel.Move(0, 0);
var text = fileLabel.Text ?? "";
foreach (var g in Terminal.Gui.Drawing.GraphemeHelper.GetGraphemes(text))
fileLabel.AddStr(g);
// Fill remaining width
var width = fileLabel.Viewport.Width;
var textCols = Terminal.Gui.Text.StringExtensions.GetColumns(text);
for (int i = textCols; i < width; i++)
fileLabel.AddStr(" ");
e.Cancel = true;
};
waveLabel.DrawingContent += (s, e) =>
{
var normalAttr = waveLabel.GetAttributeForRole(VisualRole.Normal);
var attr = (audioService.IsPlaying && !audioService.IsPaused) ? WaveActiveAttr : WaveIdleAttr;
var resolvedAttr = attr.Background == Color.None
? attr with { Background = normalAttr.Background }
: attr;
waveLabel.SetAttribute(resolvedAttr);
waveLabel.Move(0, 0);
var text = waveLabel.Text ?? "";
foreach (var g in Terminal.Gui.Drawing.GraphemeHelper.GetGraphemes(text))
waveLabel.AddStr(g);
var width = waveLabel.Viewport.Width;
var textCols = Terminal.Gui.Text.StringExtensions.GetColumns(text);
for (int i = textCols; i < width; i++)
waveLabel.AddStr(" ");
e.Cancel = true;
};
statusLabel.DrawingContent += (s, e) =>
{
var normalAttr = statusLabel.GetAttributeForRole(VisualRole.Normal);
Attribute attr;
if (audioService.IsPlaying && !audioService.IsPaused)
attr = StatusPlayingAttr;
else if (audioService.IsPaused)
attr = StatusPausedAttr;
else
attr = StatusStoppedAttr;
var resolvedAttr = attr.Background == Color.None
? attr with { Background = normalAttr.Background }
: attr;
statusLabel.SetAttribute(resolvedAttr);
statusLabel.Move(0, 0);
var text = statusLabel.Text ?? "";
foreach (var g in Terminal.Gui.Drawing.GraphemeHelper.GetGraphemes(text))
statusLabel.AddStr(g);
var width = statusLabel.Viewport.Width;
var textCols = Terminal.Gui.Text.StringExtensions.GetColumns(text);
for (int i = textCols; i < width; i++)
statusLabel.AddStr(" ");
e.Cancel = true;
};
// ── Event handlers ──
playButton.Accepting += (s, e) =>
{
e.Handled = true;
Task.Run(async () =>
{
if (audioService.IsPaused)
{
await audioService.ResumeAsync();
app.Invoke(() =>
{
UpdateStatus();
StartAnimation();
});
}
else if (audioService.IsPlaying)
{
await audioService.PauseAsync();
app.Invoke(() =>
{
UpdateStatus();
StopAnimation();
});
}
else
{
await audioService.SetVolumeAsync(currentVolume);
await audioService.PlayAsync(filePath);
app.Invoke(() =>
{
UpdateStatus();
StartAnimation();
});
}
});
};
stopButton.Accepting += (s, e) =>
{
e.Handled = true;
Task.Run(async () =>
{
await audioService.StopAsync();
app.Invoke(() =>
{
UpdateStatus();
StopAnimation();
});
});
};
closeButton.Accepting += (s, e) =>
{
e.Handled = true;
isDisposed = true;
animationTimer?.Dispose();
_ = audioService.StopAsync(); // fire-and-forget
app.RequestStop();
};
volDownButton.Accepting += (s, e) =>
{
e.Handled = true;
var newVol = (byte)Math.Max(0, currentVolume - 10);
Task.Run(async () =>
{
await UpdateVolume(newVol);
app.Invoke(() =>
{
volumeBar.SetNeedsDraw();
volumePercentLabel.SetNeedsDraw();
});
});
};
volUpButton.Accepting += (s, e) =>
{
e.Handled = true;
var newVol = (byte)Math.Min(100, currentVolume + 10);
Task.Run(async () =>
{
await UpdateVolume(newVol);
app.Invoke(() =>
{
volumeBar.SetNeedsDraw();
volumePercentLabel.SetNeedsDraw();
});
});
};
// Handle playback finishing naturally
EventHandler? finishedHandler = null;
finishedHandler = (s, e) =>
{
app.Invoke(() =>
{
if (isDisposed) return;
UpdateStatus();
StopAnimation();
});
};
audioService.PlaybackFinished += finishedHandler;
// ── Initial state ──
UpdateWave(false);
UpdateStatus();
dialog.Add(fileLabel, waveLabel, statusLabel,
volumeHeaderLabel, volDownButton, volumeBar, volumePercentLabel, volUpButton,
playButton, stopButton, closeButton);
playButton.SetFocus();
app.Run(dialog);
// Cleanup
isDisposed = true;
animationTimer?.Dispose();
audioService.PlaybackFinished -= finishedHandler;
}
private static string TruncateFileName(string name, int maxLen)
{
if (name.Length <= maxLen)
return name;
var ext = Path.GetExtension(name);
var stem = Path.GetFileNameWithoutExtension(name);
var available = maxLen - ext.Length - 3; // 3 for "..."
if (available < 1)
return name[..maxLen];
return stem[..available] + "..." + ext;
}
}
@@ -0,0 +1,290 @@
using System.Collections.ObjectModel;
using EchoHub.Client.Config;
using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
namespace EchoHub.Client.UI;
/// <summary>
/// Result returned from the connect dialog.
/// </summary>
public record ConnectDialogResult(
string ServerUrl, string Username, string Password,
bool IsRegister, bool RememberMe, string? SavedRefreshToken);
/// <summary>
/// A Terminal.Gui dialog for entering server connection and authentication details.
/// Includes a saved servers selector when saved servers are available.
/// </summary>
public sealed class ConnectDialog
{
/// <summary>
/// Shows the connect dialog with an optional list of saved servers.
/// Returns the result, or null if cancelled.
/// </summary>
public static ConnectDialogResult? Show(IApplication app, List<SavedServer>? savedServers = null)
{
ConnectDialogResult? result = null;
savedServers ??= [];
var hasSavedServers = savedServers.Count > 0;
var dialogHeight = hasSavedServers ? 22 : 18;
var dialog = new Dialog { Title = "Connect to Server", Width = 60, Height = dialogHeight };
int yOffset = 0;
SavedServer? selectedSavedServer = null;
// -- Saved Servers section (if any) -----------------------------------
ListView? savedServerList = null;
if (hasSavedServers)
{
var savedLabel = new Label
{
Text = "Saved Servers:",
X = 1,
Y = 1
};
dialog.Add(savedLabel);
var serverDisplayNames = savedServers
.Select(s =>
{
var session = !string.IsNullOrEmpty(s.RefreshToken) ? " [session]" : "";
return $"{s.Name} ({s.Username ?? "?"}){session}";
})
.ToList();
savedServerList = new ListView
{
Source = new ListWrapper<string>(new ObservableCollection<string>(serverDisplayNames)),
X = 1,
Y = 2,
Width = Dim.Fill(2),
Height = 3
};
dialog.Add(savedServerList);
// Visual separator
var separator = new Label
{
Text = new string('-', 56),
X = 1,
Y = 5
};
dialog.Add(separator);
yOffset = 5;
}
// -- Manual entry fields ----------------------------------------------
var urlLabel = new Label
{
Text = "Server URL:",
X = 1,
Y = yOffset + 1
};
var urlField = new TextField
{
Text = "http://localhost:5000",
X = 15,
Y = yOffset + 1,
Width = Dim.Fill(2)
};
var userLabel = new Label
{
Text = "Username:",
X = 1,
Y = yOffset + 3
};
var userField = new TextField
{
Text = "",
X = 15,
Y = yOffset + 3,
Width = Dim.Fill(2)
};
var passLabel = new Label
{
Text = "Password:",
X = 1,
Y = yOffset + 5
};
var passField = new TextField
{
Text = "",
X = 15,
Y = yOffset + 5,
Width = Dim.Fill(2),
Secret = true
};
var tokenHintLabel = new Label
{
Text = "Session saved \u2014 password optional",
X = 15,
Y = yOffset + 6,
Width = Dim.Fill(2),
Visible = false
};
var rememberMeCheckbox = new CheckBox
{
Text = "Remember me",
X = 15,
Y = yOffset + 7,
Value = CheckState.UnChecked
};
var displayLabel = new Label
{
Text = "Display Name:",
X = 1,
Y = yOffset + 9
};
var displayField = new TextField
{
Text = "",
X = 15,
Y = yOffset + 9,
Width = Dim.Fill(2)
};
var loginButton = new Button
{
Text = "Login",
IsDefault = true,
X = Pos.Center() - 20,
Y = yOffset + 11
};
var registerButton = new Button
{
Text = "Register",
X = Pos.Center() - 5,
Y = yOffset + 11
};
var cancelButton = new Button
{
Text = "Cancel",
X = Pos.Center() + 10,
Y = yOffset + 11
};
// Wire saved server selection to auto-fill fields
if (savedServerList is not null && savedServers.Count > 0)
{
savedServerList.ValueChanged += (sender, e) =>
{
var index = e.NewValue;
if (index.HasValue && index.Value >= 0 && index.Value < savedServers.Count)
{
selectedSavedServer = savedServers[index.Value];
urlField.Text = selectedSavedServer.Url;
userField.Text = selectedSavedServer.Username ?? "";
rememberMeCheckbox.Value = selectedSavedServer.RememberMe
? CheckState.Checked : CheckState.UnChecked;
if (!string.IsNullOrEmpty(selectedSavedServer.RefreshToken))
{
passField.Text = "";
tokenHintLabel.Visible = true;
}
else
{
tokenHintLabel.Visible = false;
}
}
};
// Pre-fill with the first saved server
selectedSavedServer = savedServers[0];
urlField.Text = savedServers[0].Url;
userField.Text = savedServers[0].Username ?? "";
rememberMeCheckbox.Value = savedServers[0].RememberMe
? CheckState.Checked : CheckState.UnChecked;
if (!string.IsNullOrEmpty(savedServers[0].RefreshToken))
tokenHintLabel.Visible = true;
}
loginButton.Accepting += (s, e) =>
{
var url = urlField.Text?.Trim() ?? string.Empty;
var user = userField.Text?.Trim() ?? string.Empty;
var pass = passField.Text ?? string.Empty;
var rememberMe = rememberMeCheckbox.Value == CheckState.Checked;
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user))
{
MessageBox.ErrorQuery(app, "Validation", "Server URL and username are required.", "OK");
e.Handled = true;
return;
}
// Determine if we can use a saved token
string? savedRefreshToken = null;
if (string.IsNullOrEmpty(pass)
&& selectedSavedServer is not null
&& !string.IsNullOrEmpty(selectedSavedServer.RefreshToken)
&& string.Equals(selectedSavedServer.Url, url, StringComparison.OrdinalIgnoreCase)
&& string.Equals(selectedSavedServer.Username, user, StringComparison.OrdinalIgnoreCase))
{
savedRefreshToken = selectedSavedServer.RefreshToken;
}
if (string.IsNullOrEmpty(pass) && savedRefreshToken is null)
{
MessageBox.ErrorQuery(app, "Validation", "Password is required.", "OK");
e.Handled = true;
return;
}
result = new ConnectDialogResult(url, user, pass, IsRegister: false, rememberMe, savedRefreshToken);
e.Handled = true;
app.RequestStop();
};
registerButton.Accepting += (s, e) =>
{
var url = urlField.Text?.Trim() ?? string.Empty;
var user = userField.Text?.Trim() ?? string.Empty;
var pass = passField.Text ?? string.Empty;
var rememberMe = rememberMeCheckbox.Value == CheckState.Checked;
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass))
{
MessageBox.ErrorQuery(app, "Validation", "Server URL, username, and password are required.", "OK");
e.Handled = true;
return;
}
result = new ConnectDialogResult(url, user, pass, IsRegister: true, rememberMe, SavedRefreshToken: null);
e.Handled = true;
app.RequestStop();
};
cancelButton.Accepting += (s, e) =>
{
result = null;
e.Handled = true;
app.RequestStop();
};
dialog.Add(urlLabel, urlField, userLabel, userField, passLabel, passField,
tokenHintLabel, rememberMeCheckbox, displayLabel, displayField,
loginButton, registerButton, cancelButton);
if (hasSavedServers && savedServerList is not null)
savedServerList.SetFocus();
else
urlField.SetFocus();
app.Run(dialog);
return result;
}
}
@@ -0,0 +1,86 @@
using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
namespace EchoHub.Client.UI;
public record CreateChannelResult(string Name, string? Topic, bool IsPublic);
public sealed class CreateChannelDialog
{
public static CreateChannelResult? Show(IApplication app)
{
CreateChannelResult? result = null;
var dialog = new Dialog { Title = "Create Channel", Width = 50, Height = 14 };
var nameLabel = new Label { Text = "Name:", X = 1, Y = 1 };
var nameField = new TextField { X = 10, Y = 1, Width = Dim.Fill(2) };
var topicLabel = new Label { Text = "Topic:", X = 1, Y = 3 };
var topicField = new TextField { X = 10, Y = 3, Width = Dim.Fill(2) };
var publicCheckbox = new CheckBox
{
Text = "Public (visible to all users)",
X = 1,
Y = 5,
Value = CheckState.Checked
};
var hintLabel = new Label
{
Text = "Lowercase letters, digits, hyphens, underscores (2-100 chars)",
X = 1,
Y = 7,
};
var createButton = new Button
{
Text = "Create",
IsDefault = true,
X = Pos.Center() - 10,
Y = 9
};
var cancelButton = new Button
{
Text = "Cancel",
X = Pos.Center() + 5,
Y = 9
};
createButton.Accepting += (s, e) =>
{
var name = nameField.Text?.Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(name))
{
MessageBox.ErrorQuery(app, "Error", "Channel name is required.", "OK");
return;
}
var topic = topicField.Text?.Trim();
if (string.IsNullOrWhiteSpace(topic))
topic = null;
var isPublic = publicCheckbox.Value == CheckState.Checked;
result = new CreateChannelResult(name, topic, isPublic);
e.Handled = true;
app.RequestStop();
};
cancelButton.Accepting += (s, e) =>
{
result = null;
e.Handled = true;
app.RequestStop();
};
dialog.Add(nameLabel, nameField, topicLabel, topicField, publicCheckbox, hintLabel, createButton, cancelButton);
nameField.SetFocus();
app.Run(dialog);
return result;
}
}
@@ -0,0 +1,274 @@
using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
using Terminal.Gui.Drawing;
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI;
/// <summary>
/// Result returned from the profile edit dialog.
/// </summary>
public record ProfileEditResult(string? DisplayName, string? Bio, string? NicknameColor, string? AvatarPath, bool? NotificationSoundEnabled, byte? NotificationVolume);
/// <summary>
/// A Terminal.Gui dialog for editing the user's profile (display name, bio, nickname color).
/// </summary>
public sealed class ProfileEditDialog
{
/// <summary>
/// Shows the profile edit dialog and returns the result, or null if cancelled.
/// </summary>
public static ProfileEditResult? Show(IApplication app, string? currentDisplayName, string? currentBio, string? currentColor, bool notificationSoundEnabled = false, byte notificationVolume = 30)
{
ProfileEditResult? result = null;
var dialog = new Dialog { Title = "Edit Profile", Width = 60, Height = 26 };
// Display Name
var nameLabel = new Label
{
Text = "Display Name:",
X = 1,
Y = 1
};
var nameField = new TextField
{
Text = currentDisplayName ?? "",
X = 17,
Y = 1,
Width = Dim.Fill(2)
};
// Bio
var bioLabel = new Label
{
Text = "Bio:",
X = 1,
Y = 3
};
var bioField = new TextField
{
Text = currentBio ?? "",
X = 17,
Y = 3,
Width = Dim.Fill(2)
};
// Nickname Color
var colorLabel = new Label
{
Text = "Nickname Color:",
X = 1,
Y = 5
};
var colorField = new TextField
{
Text = currentColor ?? "",
X = 17,
Y = 5,
Width = Dim.Fill(2)
};
var colorHintLabel = new Label
{
Text = "(hex e.g. #FF5733)",
X = 17,
Y = 6
};
colorHintLabel.SetScheme(new Scheme
{
Normal = new Attribute(Color.DarkGray, Color.Blue)
});
// Color Preview
var previewLabel = new Label
{
Text = "Preview:",
X = 1,
Y = 8
};
var colorPreview = new Label
{
Text = "\u2588\u2588\u2588\u2588\u2588\u2588",
X = 17,
Y = 8
};
UpdateColorPreview(colorPreview, colorField.Text);
colorField.TextChanged += (sender, e) =>
{
UpdateColorPreview(colorPreview, colorField.Text);
};
// Avatar
var avatarLabel = new Label
{
Text = "Avatar:",
X = 1,
Y = 10
};
var avatarField = new TextField
{
Text = "",
X = 17,
Y = 10,
Width = Dim.Fill(12)
};
var browseButton = new Button
{
Text = "Browse",
X = Pos.AnchorEnd(10),
Y = 10
};
var avatarHintLabel = new Label
{
Text = "(file path or URL)",
X = 17,
Y = 11
};
avatarHintLabel.SetScheme(new Scheme
{
Normal = new Attribute(Color.DarkGray, Color.Blue)
});
browseButton.Accepting += (s, e) =>
{
e.Handled = true;
var openDialog = new OpenDialog
{
Title = "Select Avatar Image",
OpenMode = OpenMode.File,
};
app.Run(openDialog);
if (openDialog.FilePaths.Count > 0)
{
avatarField.Text = openDialog.FilePaths[0];
}
};
// Notification Sound
var notifCheckbox = new CheckBox
{
Text = "Notification sound on @mention",
X = 1,
Y = 13,
Value = notificationSoundEnabled ? CheckState.Checked : CheckState.UnChecked
};
var volumeLabel = new Label
{
Text = "Volume:",
X = 1,
Y = 15
};
var volumeField = new TextField
{
Text = notificationVolume.ToString(),
X = 17,
Y = 15,
Width = 6
};
var volumeHintLabel = new Label
{
Text = "(0-100)",
X = 24,
Y = 15
};
volumeHintLabel.SetScheme(new Scheme
{
Normal = new Attribute(Color.DarkGray, Color.Blue)
});
// Buttons
var saveButton = new Button
{
Text = "Save",
IsDefault = true,
X = Pos.Center() - 10,
Y = 18
};
var cancelButton = new Button
{
Text = "Cancel",
X = Pos.Center() + 5,
Y = 18
};
saveButton.Accepting += (s, e) =>
{
var displayName = NullIfEmpty(nameField.Text?.Trim());
var bio = NullIfEmpty(bioField.Text?.Trim());
var nicknameColor = NullIfEmpty(colorField.Text?.Trim());
var avatarPath = NullIfEmpty(avatarField.Text?.Trim());
byte? volume = byte.TryParse(volumeField.Text, out var v) ? Math.Min(v, (byte)100) : null;
result = new ProfileEditResult(displayName, bio, nicknameColor, avatarPath, notifCheckbox.Value == CheckState.Checked, volume);
e.Handled = true;
app.RequestStop();
};
cancelButton.Accepting += (s, e) =>
{
result = null;
e.Handled = true;
app.RequestStop();
};
dialog.Add(nameLabel, nameField, bioLabel, bioField, colorLabel, colorField,
colorHintLabel, previewLabel, colorPreview,
avatarLabel, avatarField, browseButton, avatarHintLabel,
notifCheckbox, volumeLabel, volumeField, volumeHintLabel,
saveButton, cancelButton);
nameField.SetFocus();
app.Run(dialog);
return result;
}
/// <summary>
/// Attempts to parse a hex color string and update the preview label color.
/// </summary>
private static void UpdateColorPreview(Label preview, string? hexColor)
{
if (string.IsNullOrWhiteSpace(hexColor))
{
preview.SetScheme(new Scheme
{
Normal = new Attribute(Color.White, Color.Blue)
});
return;
}
var color = ParseHexToTrueColor(hexColor.Trim());
preview.SetScheme(new Scheme
{
Normal = new Attribute(color, Color.Blue)
});
}
/// <summary>
/// Parses a hex color string to a Terminal.Gui TrueColor Color.
/// V2 supports TrueColor via new Color(r, g, b).
/// </summary>
private static Color ParseHexToTrueColor(string hex)
{
if (hex.StartsWith('#'))
hex = hex[1..];
if (hex.Length != 6 || !int.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out var rgb))
return Color.White;
int r = (rgb >> 16) & 0xFF;
int g = (rgb >> 8) & 0xFF;
int b = rgb & 0xFF;
return new Color(r, g, b);
}
private static string? NullIfEmpty(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value;
}
@@ -0,0 +1,267 @@
using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
using Terminal.Gui.Drawing;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI;
/// <summary>
/// Action selected by the user in their own profile dialog.
/// </summary>
public enum ProfileAction
{
Close,
EditProfile,
SetStatus
}
/// <summary>
/// Dialog for viewing a user's server profile.
/// Shows Edit Profile / Set Status buttons when viewing own profile.
/// </summary>
public sealed class ProfileViewDialog
{
/// <summary>
/// Show a read-only profile view for another user.
/// </summary>
public static void Show(IApplication app, UserProfileDto? profile)
{
ShowInternal(app, profile, isOwnProfile: false);
}
/// <summary>
/// Show the profile view for the current user with action buttons.
/// Returns the action the user selected.
/// </summary>
public static ProfileAction ShowOwn(
IApplication app,
UserProfileDto? profile,
UserStatus currentStatus,
string? currentStatusMessage)
{
return ShowInternal(app, profile, isOwnProfile: true, currentStatus, currentStatusMessage);
}
private static ProfileAction ShowInternal(
IApplication app,
UserProfileDto? profile,
bool isOwnProfile,
UserStatus? currentStatus = null,
string? currentStatusMessage = null)
{
if (profile is null)
{
MessageBox.ErrorQuery(app, "Profile", "User not found.", "OK");
return ProfileAction.Close;
}
var action = ProfileAction.Close;
var dialog = new Dialog
{
Title = isOwnProfile ? "My Profile" : $"Profile \u2014 {profile.Username}",
Width = 50,
Height = 20
};
int row = 0;
// Username
var usernameLabel = new Label { Text = "Username:", X = 1, Y = row };
var usernameValue = new Label { Text = profile.Username, X = 14, Y = row };
usernameValue.SetScheme(new Scheme
{
Normal = new Attribute(Color.BrightYellow, Color.Blue)
});
dialog.Add(usernameLabel, usernameValue);
row++;
// Display Name
var nameLabel = new Label { Text = "Name:", X = 1, Y = row };
var nameValue = new Label { Text = profile.DisplayName ?? "-", X = 14, Y = row };
dialog.Add(nameLabel, nameValue);
row++;
// Status — use live status for own profile, stored status for others
var displayStatus = isOwnProfile && currentStatus.HasValue ? currentStatus.Value : profile.Status;
var displayStatusMsg = isOwnProfile ? currentStatusMessage : profile.StatusMessage;
var statusLabel = new Label { Text = "Status:", X = 1, Y = row };
var statusText = FormatStatus(displayStatus);
var statusValue = new Label { Text = statusText, X = 14, Y = row };
statusValue.SetScheme(new Scheme
{
Normal = new Attribute(GetStatusColor(displayStatus), Color.Blue)
});
dialog.Add(statusLabel, statusValue);
row++;
// Status Message
if (!string.IsNullOrWhiteSpace(displayStatusMsg))
{
var msgLabel = new Label { Text = "Message:", X = 1, Y = row };
var msgValue = new Label { Text = displayStatusMsg, X = 14, Y = row, Width = Dim.Fill(2) };
dialog.Add(msgLabel, msgValue);
row++;
}
// Color
var colorLabel = new Label { Text = "Color:", X = 1, Y = row };
var colorValue = new Label { Text = profile.NicknameColor ?? "-", X = 14, Y = row };
if (ColorHelper.ParseHexColor(profile.NicknameColor) is { } colorAttr)
colorValue.SetScheme(new Scheme { Normal = colorAttr });
dialog.Add(colorLabel, colorValue);
row++;
// Bio
row++;
var bioLabel = new Label { Text = "Bio:", X = 1, Y = row };
dialog.Add(bioLabel);
row++;
var bioView = new TextView
{
X = 1,
Y = row,
Width = Dim.Fill(2),
Height = 3,
Text = profile.Bio ?? "-",
ReadOnly = true,
WordWrap = true
};
bioView.SetScheme(new Scheme
{
Normal = new Attribute(Color.White, Color.DarkGray),
Focus = new Attribute(Color.White, Color.DarkGray)
});
dialog.Add(bioView);
row += 3;
// ASCII Avatar — render with color tags
if (!string.IsNullOrWhiteSpace(profile.AvatarAscii))
{
row++;
var rawLines = profile.AvatarAscii.Split('\n');
var avatarSource = new ChatListSource();
foreach (var line in rawLines)
{
avatarSource.Add(ChatLine.HasColorTags(line)
? ChatLine.FromColoredText(line)
: new ChatLine(line));
}
var avatarHeight = Math.Min(rawLines.Length + 2, 24);
var avatarFrame = new FrameView
{
Title = "Avatar",
X = 1,
Y = row,
Width = Dim.Fill(2),
Height = avatarHeight
};
var avatarList = new ListView
{
X = 0,
Y = 0,
Width = Dim.Fill(),
Height = Dim.Fill(),
Source = avatarSource
};
avatarFrame.Add(avatarList);
dialog.Add(avatarFrame);
// Grow dialog to fit avatar + widen for art
var artWidth = rawLines.Max(l => ChatLine.HasColorTags(l)
? ChatLine.FromColoredText(l).TextLength
: l.Length);
dialog.Width = Math.Max(50, artWidth + 6);
dialog.Height = row + avatarHeight + 4;
}
// Buttons
if (isOwnProfile)
{
var editButton = new Button
{
Text = "Edit Profile",
X = Pos.Center() - 20,
Y = Pos.AnchorEnd(2)
};
editButton.Accepting += (s, e) =>
{
action = ProfileAction.EditProfile;
e.Handled = true;
app.RequestStop();
};
var statusButton = new Button
{
Text = "Set Status",
X = Pos.Center() - 4,
Y = Pos.AnchorEnd(2)
};
statusButton.Accepting += (s, e) =>
{
action = ProfileAction.SetStatus;
e.Handled = true;
app.RequestStop();
};
var closeButton = new Button
{
Text = "Close",
IsDefault = true,
X = Pos.Center() + 13,
Y = Pos.AnchorEnd(2)
};
closeButton.Accepting += (s, e) =>
{
action = ProfileAction.Close;
e.Handled = true;
app.RequestStop();
};
dialog.Add(editButton, statusButton, closeButton);
}
else
{
var closeButton = new Button
{
Text = "Close",
IsDefault = true,
X = Pos.Center(),
Y = Pos.AnchorEnd(2)
};
closeButton.Accepting += (s, e) =>
{
e.Handled = true;
app.RequestStop();
};
dialog.Add(closeButton);
}
app.Run(dialog);
return action;
}
private static string FormatStatus(UserStatus status) => status switch
{
UserStatus.Online => "\u25cf Online",
UserStatus.Away => "\u25cf Away",
UserStatus.DoNotDisturb => "\u25cf Do Not Disturb",
UserStatus.Invisible => "\u25cb Invisible",
_ => "\u25cf Unknown"
};
private static Color GetStatusColor(UserStatus status) => status switch
{
UserStatus.Online => Color.BrightGreen,
UserStatus.Away => Color.BrightYellow,
UserStatus.DoNotDisturb => Color.BrightRed,
UserStatus.Invisible => Color.Gray,
_ => Color.White
};
}
@@ -0,0 +1,96 @@
using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
using EchoHub.Core.Models;
namespace EchoHub.Client.UI;
/// <summary>
/// Result returned from the status dialog.
/// </summary>
public record StatusDialogResult(UserStatus Status, string? StatusMessage);
/// <summary>
/// A Terminal.Gui dialog for setting the user's status and status message.
/// </summary>
public sealed class StatusDialog
{
/// <summary>
/// Shows the status dialog and returns the result, or null if cancelled.
/// </summary>
public static StatusDialogResult? Show(IApplication app, UserStatus currentStatus, string? currentMessage)
{
StatusDialogResult? result = null;
var dialog = new Dialog { Title = "Set Status", Width = 50, Height = 12 };
var statusLabel = new Label
{
Text = "Status:",
X = 1,
Y = 1
};
var optionSelector = new OptionSelector<UserStatus>
{
X = 12,
Y = 1
};
optionSelector.Value = currentStatus;
var messageLabel = new Label
{
Text = "Message:",
X = 1,
Y = 6
};
var messageField = new TextField
{
Text = currentMessage ?? "",
X = 12,
Y = 6,
Width = Dim.Fill(2)
};
var saveButton = new Button
{
Text = "Save",
IsDefault = true,
X = Pos.Center() - 10,
Y = 8
};
var cancelButton = new Button
{
Text = "Cancel",
X = Pos.Center() + 5,
Y = 8
};
saveButton.Accepting += (s, e) =>
{
var status = optionSelector.Value ?? UserStatus.Online;
var message = messageField.Text?.Trim();
if (string.IsNullOrWhiteSpace(message))
message = null;
result = new StatusDialogResult(status, message);
e.Handled = true;
app.RequestStop();
};
cancelButton.Accepting += (s, e) =>
{
result = null;
e.Handled = true;
app.RequestStop();
};
dialog.Add(statusLabel, optionSelector, messageLabel, messageField, saveButton, cancelButton);
optionSelector.SetFocus();
app.Run(dialog);
return result;
}
}
@@ -0,0 +1,58 @@
using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
namespace EchoHub.Client.UI;
public sealed class UpdateConfirmDialog
{
public static bool Show(IApplication app, string currentVersion, string newVersion)
{
var confirmed = false;
var dialog = new Dialog { Title = "Update Available", Width = 50, Height = 10 };
var messageLabel = new Label
{
Text = $"A new version of EchoHub is available.\n\n Current: {currentVersion}\n Latest: {newVersion}",
X = 1,
Y = 1,
Width = Dim.Fill(2),
Height = 4
};
var updateButton = new Button
{
Text = "Update",
IsDefault = true,
X = Pos.Center() - 10,
Y = 6
};
var cancelButton = new Button
{
Text = "Cancel",
X = Pos.Center() + 5,
Y = 6
};
updateButton.Accepting += (s, e) =>
{
confirmed = true;
e.Handled = true;
app.RequestStop();
};
cancelButton.Accepting += (s, e) =>
{
confirmed = false;
e.Handled = true;
app.RequestStop();
};
dialog.Add(messageLabel, updateButton, cancelButton);
app.Run(dialog);
return confirmed;
}
}
@@ -0,0 +1,61 @@
using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
namespace EchoHub.Client.UI;
public sealed class UpdateProgressDialog
{
private readonly Dialog _dialog;
private readonly ProgressBar _progressBar;
private readonly Label _infoLabel;
private readonly IApplication _app;
public UpdateProgressDialog(IApplication app, string newVersion)
{
_app = app;
_dialog = new Dialog { Title = $"Updating to {newVersion}", Width = 50, Height = 10 };
_infoLabel = new Label
{
Text = "Preparing update...",
X = 1,
Y = 1,
Width = Dim.Fill(2)
};
_progressBar = new ProgressBar
{
X = 1,
Y = 3,
Width = Dim.Fill(2),
Fraction = 0f
};
var cancelButton = new Button
{
Text = "Cancel",
X = Pos.Center(),
Y = 6
};
_dialog.Add(_infoLabel, _progressBar);
}
public void UpdateProgress(float fraction, string statusText)
{
_progressBar.Fraction = fraction;
_infoLabel.Text = statusText;
}
public void Show()
{
_app.Run(_dialog);
}
public void Close()
{
_app.RequestStop();
}
}