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; /// /// Modal dialog for saving and loading operator presets. Owned by /// ; given a snapshot of the current /// list and the /// so it can re-apply assignments /// (which requires calling EnableIsoAsync/DisableIsoAsync on the engine). /// public partial class PresetsDialog : Window { private readonly IIsoController _controller; private readonly IReadOnlyList _participants; private readonly ToastViewModel? _toast; /// /// Display-side wrapper for an . /// Adds derived presentation-only properties so the ListBox template can /// render without inline converters or value-conversion logic. /// 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 Rows { get; } = new(); public PresetsDialog( IIsoController controller, IReadOnlyList participants, ToastViewModel? toast = null) { InitializeComponent(); _controller = controller; _participants = participants; _toast = toast; PresetsList.ItemsSource = Rows; ReloadPresets(); } /// Refresh the ListBox from disk and reflect emptiness in the empty-state TextBlock. 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); } } /// /// 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). /// 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; } } /// /// Duplicate the selected preset under a new name. We auto-suggest /// "<original> (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. /// 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); } } /// /// Suggest a name for a copy: "Foo" → "Foo (copy)", "Foo (copy)" → "Foo (copy 2)". /// Bumps the digit if the operator iterates from a copy. /// 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})"; } /// /// Quick input dialog for a single string. WPF doesn't ship one, so we /// build a minimal modal here. Keeps the dialog dependency-free. /// 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(); } /// /// 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. /// 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); } } /// /// 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 . /// 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(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(); } }