dragon-iso/src/TeamsISO.App/PresetsDialog.xaml.cs

426 lines
16 KiB
C#

using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Controls;
using TeamsISO.App.Services;
using TeamsISO.App.ViewModels;
using TeamsISO.Engine.Controller;
namespace TeamsISO.App;
/// <summary>
/// Modal dialog for saving and loading operator presets. Owned by
/// <see cref="MainWindow"/>; given a snapshot of the current
/// <see cref="ParticipantViewModel"/> list and the
/// <see cref="IIsoController"/> so it can re-apply assignments
/// (which requires calling EnableIsoAsync/DisableIsoAsync on the engine).
/// </summary>
public partial class PresetsDialog : Window
{
private readonly IIsoController _controller;
private readonly IReadOnlyList<ParticipantViewModel> _participants;
private readonly ToastViewModel? _toast;
/// <summary>
/// Display-side wrapper for an <see cref="OperatorPresetStore.Preset"/>.
/// Adds derived presentation-only properties so the ListBox template can
/// render without inline converters or value-conversion logic.
/// </summary>
public sealed class PresetRow
{
public OperatorPresetStore.Preset Preset { get; }
public string Name => Preset.Name;
public string SavedAtDisplay => Preset.SavedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm");
public int AssignmentCount => Preset.Assignments.Count(a => a.Enabled);
public PresetRow(OperatorPresetStore.Preset preset) => Preset = preset;
}
public ObservableCollection<PresetRow> Rows { get; } = new();
public PresetsDialog(
IIsoController controller,
IReadOnlyList<ParticipantViewModel> participants,
ToastViewModel? toast = null)
{
InitializeComponent();
_controller = controller;
_participants = participants;
_toast = toast;
PresetsList.ItemsSource = Rows;
ReloadPresets();
}
/// <summary>Refresh the ListBox from disk and reflect emptiness in the empty-state TextBlock.</summary>
private void ReloadPresets()
{
Rows.Clear();
foreach (var p in OperatorPresetStore.LoadAll().OrderByDescending(p => p.SavedAt))
Rows.Add(new PresetRow(p));
EmptyState.Visibility = Rows.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
UpdateButtonStates();
}
private void UpdateButtonStates()
{
var hasSelection = PresetsList.SelectedItem is PresetRow;
ApplyButton.IsEnabled = hasSelection;
DeleteButton.IsEnabled = hasSelection;
DuplicateButton.IsEnabled = hasSelection;
}
private void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (PresetsList.SelectedItem is PresetRow row)
{
// Mirror the selected name into the textbox so a re-save overwrites
// by default; operator can still type a new name to fork.
NameBox.Text = row.Name;
}
UpdateButtonStates();
}
private void OnSave(object sender, RoutedEventArgs e)
{
var name = NameBox.Text?.Trim();
if (string.IsNullOrWhiteSpace(name))
{
_toast?.Warn("Enter a name for the preset");
NameBox.Focus();
return;
}
var assignments = _participants
.Select(p => new OperatorPresetStore.Assignment(
DisplayName: p.DisplayName,
CustomOutputName: string.IsNullOrWhiteSpace(p.CustomName) ? null : p.CustomName,
Enabled: p.IsEnabled))
.ToList();
var existing = OperatorPresetStore.Find(name);
if (existing is not null)
{
var confirm = MessageBox.Show(
this,
$"A preset named \"{name}\" already exists. Overwrite it?",
"TeamsISO — Overwrite preset",
MessageBoxButton.YesNo,
MessageBoxImage.Question,
MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return;
}
try
{
OperatorPresetStore.Save(new OperatorPresetStore.Preset(
Name: name,
SavedAt: DateTimeOffset.Now,
Assignments: assignments));
_toast?.Show($"Saved preset \"{name}\"");
ReloadPresets();
}
catch (Exception ex)
{
MessageBox.Show(
this,
$"Could not save preset.\n\n{ex.Message}",
"TeamsISO — Save preset",
MessageBoxButton.OK,
MessageBoxImage.Warning);
}
}
/// <summary>
/// Apply the selected preset: walks the current participants list, matching
/// by display name (the only stable join key across meetings — Ids are
/// regenerated each meeting). For each match, set the custom output name and
/// reconcile its enabled state with the preset by calling EnableIsoAsync /
/// DisableIsoAsync as needed. Participants in the preset who aren't in the
/// current meeting are silently skipped (and reported in the toast).
/// </summary>
private async void OnApply(object sender, RoutedEventArgs e)
{
if (PresetsList.SelectedItem is not PresetRow row) return;
ApplyButton.IsEnabled = false;
try
{
// PresetApplier owns the apply loop — same code path the REST control
// surface and auto-apply-on-launch use. Dialog passes null dispatcher
// since OnApply already runs on the UI thread.
var result = await PresetApplier.ApplyAsync(
row.Preset, _participants, _controller, dispatcher: null);
var summary = result.Skipped > 0
? $"Applied \"{row.Name}\" — {result.Changed} change(s); {result.Skipped} not in meeting"
: $"Applied \"{row.Name}\" — {result.Changed} change(s)";
_toast?.Show(summary);
DialogResult = true;
Close();
}
finally
{
ApplyButton.IsEnabled = true;
}
}
/// <summary>
/// Duplicate the selected preset under a new name. We auto-suggest
/// "&lt;original&gt; (copy)" but pop a tiny input dialog so the operator
/// can pick something meaningful. WPF doesn't ship an InputBox; we
/// use a quick custom prompt below.
/// </summary>
private void OnDuplicate(object sender, RoutedEventArgs e)
{
if (PresetsList.SelectedItem is not PresetRow row) return;
var defaultName = SuggestCopyName(row.Name);
var newName = PromptForName("Duplicate preset", "New name:", defaultName);
if (string.IsNullOrWhiteSpace(newName)) return;
try
{
var existing = OperatorPresetStore.Find(newName);
if (existing is not null)
{
var confirm = MessageBox.Show(
this,
$"A preset named \"{newName}\" already exists. Overwrite it?",
"TeamsISO — Duplicate preset",
MessageBoxButton.YesNo,
MessageBoxImage.Question,
MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return;
}
// Re-using Save() with a fresh SavedAt timestamp — Save's overwrite
// semantics handle the name-collision case cleanly.
OperatorPresetStore.Save(new OperatorPresetStore.Preset(
Name: newName,
SavedAt: DateTimeOffset.Now,
Assignments: row.Preset.Assignments));
_toast?.Show($"Duplicated to \"{newName}\"");
ReloadPresets();
}
catch (Exception ex)
{
MessageBox.Show(this,
$"Could not duplicate preset.\n\n{ex.Message}",
"TeamsISO — Duplicate preset",
MessageBoxButton.OK,
MessageBoxImage.Warning);
}
}
/// <summary>
/// Suggest a name for a copy: "Foo" → "Foo (copy)", "Foo (copy)" → "Foo (copy 2)".
/// Bumps the digit if the operator iterates from a copy.
/// </summary>
private static string SuggestCopyName(string original)
{
if (!original.EndsWith(")", StringComparison.Ordinal))
return original + " (copy)";
var match = System.Text.RegularExpressions.Regex.Match(original, @" \(copy(?: (\d+))?\)$");
if (!match.Success) return original + " (copy)";
var n = match.Groups[1].Success && int.TryParse(match.Groups[1].Value, out var parsed) ? parsed + 1 : 2;
return original[..(original.Length - match.Length)] + $" (copy {n})";
}
/// <summary>
/// Quick input dialog for a single string. WPF doesn't ship one, so we
/// build a minimal modal here. Keeps the dialog dependency-free.
/// </summary>
private string? PromptForName(string title, string prompt, string defaultValue)
{
var dlg = new System.Windows.Window
{
Title = title,
Owner = this,
Width = 400,
Height = 170,
WindowStartupLocation = System.Windows.WindowStartupLocation.CenterOwner,
ResizeMode = System.Windows.ResizeMode.NoResize,
Background = (System.Windows.Media.Brush)FindResource("Wd.Canvas"),
};
var stack = new System.Windows.Controls.StackPanel { Margin = new System.Windows.Thickness(20) };
stack.Children.Add(new System.Windows.Controls.TextBlock
{
Text = prompt,
Margin = new System.Windows.Thickness(0, 0, 0, 8),
Foreground = (System.Windows.Media.Brush)FindResource("Wd.Text.Primary"),
});
var tb = new System.Windows.Controls.TextBox { Text = defaultValue, Padding = new System.Windows.Thickness(8, 6, 8, 6) };
stack.Children.Add(tb);
var buttons = new System.Windows.Controls.StackPanel
{
Orientation = System.Windows.Controls.Orientation.Horizontal,
HorizontalAlignment = System.Windows.HorizontalAlignment.Right,
Margin = new System.Windows.Thickness(0, 16, 0, 0),
};
var ok = new System.Windows.Controls.Button { Content = "OK", IsDefault = true, Padding = new System.Windows.Thickness(20, 6, 20, 6), Style = (System.Windows.Style)FindResource("Wd.Button.Primary") };
var cancel = new System.Windows.Controls.Button { Content = "Cancel", IsCancel = true, Padding = new System.Windows.Thickness(14, 6, 14, 6), Margin = new System.Windows.Thickness(0, 0, 8, 0), Style = (System.Windows.Style)FindResource("Wd.Button.Ghost") };
ok.Click += (_, _) => { dlg.DialogResult = true; dlg.Close(); };
buttons.Children.Add(cancel);
buttons.Children.Add(ok);
stack.Children.Add(buttons);
dlg.Content = stack;
tb.Focus();
tb.SelectAll();
var result = dlg.ShowDialog();
return result == true ? tb.Text.Trim() : null;
}
private void OnDelete(object sender, RoutedEventArgs e)
{
if (PresetsList.SelectedItem is not PresetRow row) return;
var confirm = MessageBox.Show(
this,
$"Delete preset \"{row.Name}\"? This cannot be undone.",
"TeamsISO — Delete preset",
MessageBoxButton.YesNo,
MessageBoxImage.Warning,
MessageBoxResult.No);
if (confirm != MessageBoxResult.Yes) return;
try
{
OperatorPresetStore.Delete(row.Name);
_toast?.Show($"Deleted preset \"{row.Name}\"");
ReloadPresets();
}
catch (Exception ex)
{
MessageBox.Show(
this,
$"Could not delete preset.\n\n{ex.Message}",
"TeamsISO — Delete preset",
MessageBoxButton.OK,
MessageBoxImage.Warning);
}
}
private void OnCancel(object sender, RoutedEventArgs e)
{
DialogResult = false;
Close();
}
/// <summary>
/// Save every preset as a single .json bundle to a path the user picks via
/// SaveFileDialog. We use Microsoft.Win32.SaveFileDialog because it doesn't
/// drag in WinForms; the WPF host doesn't ship a built-in alternative.
/// </summary>
private void OnExport(object sender, RoutedEventArgs e)
{
var dlg = new Microsoft.Win32.SaveFileDialog
{
Title = "Export TeamsISO presets",
FileName = $"teamsiso-presets-{DateTimeOffset.Now:yyyy-MM-dd}.json",
Filter = "TeamsISO preset bundle (*.json)|*.json",
DefaultExt = "json",
};
if (dlg.ShowDialog(this) != true) return;
try
{
var json = OperatorPresetStore.ExportAllAsJson();
System.IO.File.WriteAllText(dlg.FileName, json);
_toast?.Show($"Exported {Rows.Count} preset(s)");
}
catch (Exception ex)
{
MessageBox.Show(this,
$"Could not export presets.\n\n{ex.Message}",
"TeamsISO — Export presets",
MessageBoxButton.OK,
MessageBoxImage.Warning);
}
}
/// <summary>
/// Load a bundle from a path the user picks. On name collision we ask once
/// (covering all collisions) whether to overwrite — a per-preset prompt would
/// be exhausting for a 20-preset bundle. The Y/N here drives the overwrite
/// flag passed to <see cref="OperatorPresetStore.ImportBundle"/>.
/// </summary>
private void OnImport(object sender, RoutedEventArgs e)
{
var dlg = new Microsoft.Win32.OpenFileDialog
{
Title = "Import TeamsISO presets",
Filter = "TeamsISO preset bundle (*.json)|*.json|All files (*.*)|*.*",
};
if (dlg.ShowDialog(this) != true) return;
string json;
try { json = System.IO.File.ReadAllText(dlg.FileName); }
catch (Exception ex)
{
MessageBox.Show(this,
$"Could not read the file.\n\n{ex.Message}",
"TeamsISO — Import presets",
MessageBoxButton.OK,
MessageBoxImage.Warning);
return;
}
// Quick parse to sniff for collisions before asking the operator anything.
OperatorPresetStore.Bundle? bundle;
try { bundle = System.Text.Json.JsonSerializer.Deserialize<OperatorPresetStore.Bundle>(json); }
catch
{
MessageBox.Show(this,
"That file isn't a valid TeamsISO preset bundle.",
"TeamsISO — Import presets",
MessageBoxButton.OK,
MessageBoxImage.Warning);
return;
}
if (bundle is null || bundle.Presets is null || bundle.Presets.Count == 0)
{
MessageBox.Show(this,
"The bundle is empty.",
"TeamsISO — Import presets",
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
}
var existingNames = OperatorPresetStore.LoadAll()
.Select(p => p.Name)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var collisions = bundle.Presets.Count(p => existingNames.Contains(p.Name));
var overwrite = false;
if (collisions > 0)
{
var choice = MessageBox.Show(
this,
$"{collisions} preset name(s) in this bundle already exist on this machine.\n\n" +
"Yes = overwrite local copies with the bundle's versions.\n" +
"No = keep local copies; only import new presets.",
"TeamsISO — Import presets",
MessageBoxButton.YesNoCancel,
MessageBoxImage.Question,
MessageBoxResult.No);
if (choice == MessageBoxResult.Cancel) return;
overwrite = choice == MessageBoxResult.Yes;
}
var result = OperatorPresetStore.ImportBundle(json, overwrite);
if (result.Error is not null)
{
MessageBox.Show(this,
$"Import failed.\n\n{result.Error}",
"TeamsISO — Import presets",
MessageBoxButton.OK,
MessageBoxImage.Warning);
return;
}
var summary = $"Imported — {result.Added} new";
if (result.Overwritten > 0) summary += $", {result.Overwritten} overwritten";
if (result.Skipped > 0) summary += $", {result.Skipped} skipped";
_toast?.Show(summary);
ReloadPresets();
}
}