Files
LlamaCasty/ViewModels/SocialsDialogViewModel.cs
T
gramps c82636e1a9 feat(socials): bar appearance — text colour, background plate, opacity, text shadow
In the Socials drawer, under the show/hide switch: text colour, an optional background plate
with its own colour and opacity, and a text drop shadow. The bar had been hard-wired
white-on-transparent since it shipped.

- `SocialsConfig` gains five additive properties (BarTextColor, BarBackgroundEnabled,
  BarBackgroundColor, BarBackgroundOpacity, BarTextShadow).
- Persistence: five new Socials columns, added by the per-column idempotent migration pattern
  (a half-migrated DB heals instead of failing), written in Save and read back in Load with
  defaults for pre-existing rows.
- `SocialBarRenderer.Render(entries, SocialBarStyle?)` — the look is a separate value type so the
  renderer stays a pure "entries + look" function and the preview can never read a half-applied
  config mid-edit. A malformed hex falls back to white/black instead of throwing inside a render
  tick.
- The plate hugs the entries (rounded 20px, padding) so it reads as a caption bar, not a band
  across the frame. Off by default, because transparent is what creators have today.
- The colour pickers reuse `BorderColorPalette` — one catalogue of the app's colours, per the
  TASK 60/63 rule.

Guard: SocialBarAppearanceTests paints the real strip and inspects pixels — default is white with
no plate, a red text colour reaches the glyphs, a 55% blue plate yields half-transparent blue
pixels, and the shadow adds dark pixels that were not there before.
2026-10-30 08:47:50 -07:00

389 lines
13 KiB
C#

using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Threading;
using System.Windows.Input;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.Services;
namespace ytLive.ViewModels;
/// <summary>
/// View model for the Social Media Site Promotion dialog. Owns the working copy
/// of the social bar config: the six fixed slots, the sign-in gate, per-row
/// validation, the freemium lock, and the YouTube sign-out. Deliberately WPF-free
/// (no MessageBox, no Window) so the whole flow is unit-testable with fakes.
/// The window surfaces the confirmations (delete, sign-in gate) as dialogs and
/// calls back into the VM.
/// </summary>
public sealed class SocialsDialogViewModel : ViewModelBase
{
public const int MaxSlots = 6;
private readonly ISocialValidator _validator;
private readonly Func<Task<YouTubeChannel?>> _signInProvider;
private readonly Func<Task> _signOutAction;
private readonly List<DialogEntry> _working = new();
private readonly CancellationTokenSource _cts = new();
private YouTubeChannel? _account;
private bool _isSignedIn;
private bool _isBusy;
private bool _barEnabled = true;
public SocialsDialogViewModel(
ISocialValidator validator,
Func<Task<YouTubeChannel?>> signInProvider,
Func<Task> signOutAction,
YouTubeChannel? account,
SocialsConfig? current = null)
{
_validator = validator;
_signInProvider = signInProvider;
_signOutAction = signOutAction;
_barEnabled = current?.BarEnabled ?? true;
_barTextColor = current?.BarTextColor ?? "#FFFFFF";
_barBackgroundEnabled = current?.BarBackgroundEnabled ?? false;
_barBackgroundColor = current?.BarBackgroundColor ?? "#000000";
_barBackgroundOpacity = current?.BarBackgroundOpacity ?? 0.55;
_barTextShadow = current?.BarTextShadow ?? false;
_account = account;
_isSignedIn = account != null;
foreach (var entry in current?.Entries ?? [])
if (entry.Service != SocialService.YouTube)
_working.Add(new DialogEntry(entry.Service, entry.Handle, entry.ProfileUrl));
SignInCommand = new RelayCommand(_ => _ = SignInAsync(), _ => CanSignIn);
SaveCommand = new RelayCommand(_ => Save(), _ => CanSave);
CancelCommand = new RelayCommand(_ => Cancel());
RebuildSlots();
}
public ObservableCollection<SocialSlotViewModel> Slots { get; } = new();
public bool BarEnabled
{
get => _barEnabled;
set => SetProperty(ref _barEnabled, value);
}
// ─── Bar appearance (creator request 2026-10-30) ───────────────────────────
private string _barTextColor = "#FFFFFF";
public string BarTextColor
{
get => _barTextColor;
set => SetProperty(ref _barTextColor, value);
}
private bool _barBackgroundEnabled;
public bool BarBackgroundEnabled
{
get => _barBackgroundEnabled;
set => SetProperty(ref _barBackgroundEnabled, value);
}
private string _barBackgroundColor = "#000000";
public string BarBackgroundColor
{
get => _barBackgroundColor;
set => SetProperty(ref _barBackgroundColor, value);
}
private double _barBackgroundOpacity = 0.55;
public double BarBackgroundOpacity
{
get => _barBackgroundOpacity;
set => SetProperty(ref _barBackgroundOpacity, value);
}
private bool _barTextShadow;
public bool BarTextShadow
{
get => _barTextShadow;
set => SetProperty(ref _barTextShadow, value);
}
/// <summary>The colours offered for the text and the plate — the same catalogue the border
/// picker uses, so there is one list of "the app's colours" (TASK 60/63).</summary>
public IReadOnlyList<NamedColor> ColorOptions => BorderColorPalette.All;
public bool IsSignedIn
{
get => _isSignedIn;
private set
{
if (SetProperty(ref _isSignedIn, value))
OnPropertyChanged(nameof(ShowSignInBanner));
}
}
public bool IsBusy
{
get => _isBusy;
private set
{
if (SetProperty(ref _isBusy, value))
{
OnPropertyChanged(nameof(ShowSignInBanner));
CommandManager.InvalidateRequerySuggested();
}
}
}
public bool ShowSignInBanner => !IsSignedIn && !IsBusy;
public bool CanSignIn => !IsBusy;
/// <summary>
/// Save is blocked while any slot has un-validated input, is mid-validation,
/// or failed validation — an empty slot or a confirmed entry never blocks.
/// </summary>
public bool CanSave =>
!IsBusy &&
Slots.All(s => !s.IsEditing || string.IsNullOrWhiteSpace(s.EditText)) &&
Slots.All(s => !s.IsValidating && !s.HasError);
public ICommand SignInCommand { get; }
public ICommand SaveCommand { get; }
public ICommand CancelCommand { get; }
public event Action? SaveRequested;
public event Action? CancelRequested;
/// <summary>Entries to persist after a successful save (YouTube first when signed in).</summary>
public IReadOnlyList<SocialEntry>? CommittedEntries { get; private set; }
/// <summary>
/// Dismissal is a hard stop: abort every in-flight validation and get out.
/// The canceled continuations never touch slot state.
/// </summary>
public void Cancel()
{
_cts.Cancel();
CancelRequested?.Invoke();
}
/// <summary>Aborts in-flight work without raising <see cref="CancelRequested"/>
/// (used by the window's Closing handler so a Save close isn't clobbered).</summary>
public void AbortPending() => _cts.Cancel();
private async Task SignInAsync()
{
if (IsBusy) return;
IsBusy = true;
try
{
var channel = await _signInProvider();
if (channel != null)
{
_account = channel;
IsSignedIn = true;
RebuildSlots();
}
}
finally
{
IsBusy = false;
}
}
/// <summary>Starts editing the slot's input box (add or edit).</summary>
public void StartEdit(int slotIndex)
{
if (slotIndex <= 0 || slotIndex >= Slots.Count) return;
var slot = Slots[slotIndex];
if (slot.IsLocked || slot.IsYoutube || slot.IsValidating) return;
slot.EditText = slot.IsFilled ? slot.Handle : string.Empty;
slot.Error = null;
slot.IsEditing = true;
}
/// <summary>Commits the slot's input — empty cancels, otherwise validates.
/// Re-submitting text that was already handled is a no-op, so a focus shift
/// (clicking Cancel, tabbing away) never re-fires a lookup.</summary>
public void ConfirmEdit(int slotIndex)
{
if (slotIndex <= 0 || slotIndex >= Slots.Count) return;
var slot = Slots[slotIndex];
if (!slot.IsEditing || slot.IsValidating) return;
if (string.IsNullOrWhiteSpace(slot.EditText))
{
slot.IsEditing = false;
return;
}
var text = slot.EditText.Trim();
if (slot.IsFilled && text == slot.Handle)
{
slot.IsEditing = false;
return;
}
if (slot.LastValidatedInput == text)
{
if (slot.IsFilled) slot.IsEditing = false;
return;
}
slot.LastValidatedInput = text;
_ = ValidateAsync(slot);
}
/// <summary>Runs the lookup; on success snaps the row back to its logo + handle.
/// A canceled lookup (dismissal) leaves the slot untouched.</summary>
private async Task ValidateAsync(SocialSlotViewModel slot)
{
var ct = _cts.Token;
slot.IsValidating = true;
slot.Error = null;
try
{
var text = slot.EditText.Trim();
var (service, handle, _) = SocialServiceIcons.DetectService(text);
var result = await _validator.LookupAsync(service, handle, ct);
if (ct.IsCancellationRequested || result.Canceled) return;
if (result.Success)
ApplyValidated(slot, new DialogEntry(service, result.Handle, result.ProfileUrl, result.FediverseSoftware));
else
slot.Error = result.Error;
}
finally
{
slot.IsValidating = false;
}
}
private void ApplyValidated(SocialSlotViewModel slot, DialogEntry entry)
{
var index = slot.Index - 1;
if (slot.IsFilled && index >= 0 && index < _working.Count)
_working[index] = entry;
else
_working.Add(entry);
slot.Service = entry.Service;
slot.Handle = entry.Handle;
slot.ProfileUrl = entry.ProfileUrl;
slot.FediverseSoftware = entry.FediverseSoftware;
slot.IsFilled = true;
slot.IsEditing = false;
RebuildSlots();
}
/// <summary>The window confirms the delete, then calls this. Slot 0 signs out of YouTube.</summary>
public async Task DeleteSlotAsync(int slotIndex)
{
if (slotIndex == 0)
{
if (!IsSignedIn) return;
await _signOutAction();
_account = null;
IsSignedIn = false;
RebuildSlots();
return;
}
var entryIndex = slotIndex - 1;
if (entryIndex < 0 || entryIndex >= _working.Count) return;
_working.RemoveAt(entryIndex);
RebuildSlots();
}
private void Save()
{
if (!CanSave) return;
var entries = new List<SocialEntry>();
if (IsSignedIn && _account != null)
{
var handle = _account.DisplayName.Trim().TrimStart('@');
entries.Add(new SocialEntry
{
Service = SocialService.YouTube,
Handle = handle,
ProfileUrl = string.IsNullOrWhiteSpace(_account.ChannelId)
? SocialServiceIcons.CanonicalUrlFor(SocialService.YouTube, handle)
: $"https://www.youtube.com/channel/{_account.ChannelId}",
});
}
foreach (var entry in _working)
entries.Add(new SocialEntry
{
Service = entry.Service,
Handle = entry.Handle,
ProfileUrl = entry.ProfileUrl,
FediverseSoftware = entry.FediverseSoftware,
});
CommittedEntries = entries;
SaveRequested?.Invoke();
}
/// <summary>Builds the config the main window commits on save, or null when empty.</summary>
public SocialsConfig? BuildSocialsConfig()
{
var entries = CommittedEntries ?? throw new InvalidOperationException("Save was not completed.");
if (entries.Count == 0) return null;
var config = new SocialsConfig
{
BarEnabled = BarEnabled,
BarTextColor = BarTextColor,
BarBackgroundEnabled = BarBackgroundEnabled,
BarBackgroundColor = BarBackgroundColor,
BarBackgroundOpacity = BarBackgroundOpacity,
BarTextShadow = BarTextShadow,
};
foreach (var entry in entries)
config.Entries.Add(entry);
return config;
}
private void RebuildSlots()
{
foreach (var slot in Slots)
slot.PropertyChanged -= OnSlotPropertyChanged;
Slots.Clear();
for (var i = 0; i < MaxSlots; i++)
{
var slot = new SocialSlotViewModel(i);
if (i == 0)
{
slot.Service = SocialService.YouTube;
slot.IsSignIn = !IsSignedIn;
slot.IsFilled = IsSignedIn;
if (IsSignedIn && _account != null)
slot.Handle = _account.DisplayName;
}
else
{
var entryIndex = i - 1;
if (entryIndex < _working.Count)
{
var entry = _working[entryIndex];
slot.Service = entry.Service;
slot.IsFilled = true;
slot.Handle = entry.Handle;
slot.ProfileUrl = entry.ProfileUrl;
slot.FediverseSoftware = entry.FediverseSoftware;
}
else if (i >= 2)
{
// Slot is empty — ready for user to add
}
}
slot.PropertyChanged += OnSlotPropertyChanged;
Slots.Add(slot);
}
}
private void OnSlotPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(SocialSlotViewModel.IsEditing)
or nameof(SocialSlotViewModel.IsValidating)
or nameof(SocialSlotViewModel.HasError)
or nameof(SocialSlotViewModel.EditText))
{
OnPropertyChanged(nameof(CanSave));
CommandManager.InvalidateRequerySuggested();
}
}
}