Files
Tornado3_2026Election/Tornado3_2026Election/MainWindow.xaml.cs
2026-05-13 11:21:48 +09:00

554 lines
18 KiB
C#

using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Tornado3_2026Election.Domain;
using Tornado3_2026Election.ViewModels;
using Windows.Graphics;
using Windows.Storage.Pickers;
using WinRT.Interop;
namespace Tornado3_2026Election;
public sealed partial class MainWindow : Window
{
private const uint ImageIcon = 1;
private const uint LoadFromFile = 0x0010;
private const uint SetWindowIconMessage = 0x0080;
private const int LargeIcon = 1;
private const int SmallIcon = 0;
private const int SmallIconWidthMetric = 49;
private const int SmallIconHeightMetric = 50;
private const int LargeIconWidthMetric = 11;
private const int LargeIconHeightMetric = 12;
private bool _suppressBroadcastPhaseToggle;
private bool _suppressOperationModeToggle;
private bool _startupFlowShown;
private bool _windowIconApplied;
private nint _largeWindowIconHandle;
private nint _smallWindowIconHandle;
public MainWindow()
{
ViewModel = new MainViewModel();
InitializeComponent();
Activated += MainWindow_Activated;
ApplyWindowIcon();
CaptureCurrentWindowPlacement();
HookWindowPlacementTracking();
MainNavigationView.SelectedItem = MainNavigationView.MenuItems[0];
MainNavigationView.Header = ViewModel.CurrentPageTitle;
MainNavigationView.Loaded += MainNavigationView_Loaded;
Closed += MainWindow_Closed;
}
public MainViewModel ViewModel { get; }
private void ApplyWindowIcon()
{
if (_windowIconApplied)
{
return;
}
try
{
var iconPath = Path.Combine(AppContext.BaseDirectory, "Assets", "AppIcon.ico");
if (!File.Exists(iconPath))
{
return;
}
var windowHandle = WindowNative.GetWindowHandle(this);
if (windowHandle == IntPtr.Zero)
{
return;
}
GetAppWindow()?.SetIcon(iconPath);
ApplyNativeWindowIcons(windowHandle, iconPath);
_windowIconApplied = true;
}
catch
{
// Ignore icon application failures and keep the window usable.
}
}
private void MainWindow_Activated(object sender, WindowActivatedEventArgs args)
{
ApplyWindowIcon();
}
private async void MainNavigationView_Loaded(object sender, RoutedEventArgs e)
{
if (_startupFlowShown)
{
return;
}
_startupFlowShown = true;
if (MainNavigationView.XamlRoot is null)
{
return;
}
await ApplySavedWindowPlacementAsync();
if (await ViewModel.HasRestorableStateAsync())
{
await ViewModel.RestoreStartupStateAsync();
EnsureNavigationSelection();
return;
}
await ShowStationSelectionDialogAsync();
await ViewModel.SaveStateAsync();
}
private async Task ShowStationSelectionDialogAsync()
{
var stationComboBox = new ComboBox
{
DisplayMemberPath = nameof(StationFilterItemViewModel.Name),
SelectedValuePath = nameof(StationFilterItemViewModel.Id),
ItemsSource = ViewModel.Settings.Stations,
SelectedValue = ViewModel.Settings.SelectedStationId,
MinWidth = 240
};
var stationDialog = new ContentDialog
{
XamlRoot = MainNavigationView.XamlRoot,
Title = "방송사 선택",
PrimaryButtonText = "확인",
DefaultButton = ContentDialogButton.Primary,
Content = new StackPanel
{
Spacing = 10,
Children =
{
new TextBlock
{
Text = "앱 시작 시 사용할 방송사를 선택하세요."
},
stationComboBox
}
}
};
stationDialog.PrimaryButtonClick += (_, _) =>
{
if (stationComboBox.SelectedValue is string stationId && !string.IsNullOrWhiteSpace(stationId))
{
ViewModel.Settings.SelectedStationId = stationId;
MainNavigationView.Header = ViewModel.CurrentPageTitle;
}
};
await ShowDialogAsync(stationDialog);
}
private async void PickImageRootFolderButton_Click(object sender, RoutedEventArgs e)
{
try
{
var windowHandle = WindowNative.GetWindowHandle(this);
if (windowHandle == IntPtr.Zero)
{
return;
}
var picker = new FolderPicker
{
SuggestedStartLocation = PickerLocationId.ComputerFolder,
CommitButtonText = "선택"
};
picker.FileTypeFilter.Add("*");
InitializeWithWindow.Initialize(picker, windowHandle);
var folder = await picker.PickSingleFolderAsync();
if (folder is not null)
{
ViewModel.Settings.ImageRootPath = Services.TornadoPathResolver.GetDefaultT3CutPath();
}
}
catch
{
// Ignore picker failures and keep the settings screen responsive.
}
}
private async void BroadcastPhaseToggleSwitch_Toggled(object sender, RoutedEventArgs e)
{
if (_suppressBroadcastPhaseToggle || sender is not ToggleSwitch toggleSwitch)
{
return;
}
var targetPhase = toggleSwitch.IsOn ? BroadcastPhase.Counting : BroadcastPhase.PreElection;
if (ViewModel.Data.BroadcastPhase == targetPhase)
{
return;
}
var confirmed = await ConfirmBroadcastPhaseChangeAsync(targetPhase);
if (confirmed)
{
ViewModel.ApplyBroadcastPhase(targetPhase);
EnsureNavigationSelection();
return;
}
_suppressBroadcastPhaseToggle = true;
toggleSwitch.IsOn = ViewModel.Data.IsCountingPhase;
_suppressBroadcastPhaseToggle = false;
}
private async void OperationModeToggleSwitch_Toggled(object sender, RoutedEventArgs e)
{
if (_suppressOperationModeToggle || sender is not ToggleSwitch toggleSwitch)
{
return;
}
var targetMode = toggleSwitch.IsOn ? ChannelOperationMode.VideoWall : ChannelOperationMode.General;
if (ViewModel.OperationMode == targetMode)
{
return;
}
var confirmed = await ConfirmOperationModeChangeAsync(targetMode);
if (confirmed)
{
ViewModel.ApplyOperationMode(targetMode);
EnsureNavigationSelection();
return;
}
_suppressOperationModeToggle = true;
toggleSwitch.IsOn = ViewModel.IsVideoWallOperationMode;
_suppressOperationModeToggle = false;
}
private AppWindow? GetAppWindow()
{
try
{
var windowHandle = WindowNative.GetWindowHandle(this);
if (windowHandle == IntPtr.Zero)
{
return null;
}
var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(windowHandle);
return AppWindow.GetFromWindowId(windowId);
}
catch
{
return null;
}
}
private void HookWindowPlacementTracking()
{
var appWindow = GetAppWindow();
if (appWindow is null)
{
return;
}
appWindow.Changed += (_, args) =>
{
if (args.DidPositionChange || args.DidSizeChange || args.DidPresenterChange)
{
CaptureCurrentWindowPlacement();
}
};
}
private async Task ApplySavedWindowPlacementAsync()
{
var savedWindowPlacement = await ViewModel.GetSavedWindowPlacementAsync();
var appWindow = GetAppWindow();
if (savedWindowPlacement is null || appWindow is null)
{
return;
}
var savedRect = new RectInt32(
savedWindowPlacement.Value.X ?? appWindow.Position.X,
savedWindowPlacement.Value.Y ?? appWindow.Position.Y,
savedWindowPlacement.Value.Width,
savedWindowPlacement.Value.Height);
var displayArea = DisplayArea.GetFromRect(savedRect, DisplayAreaFallback.Nearest);
var width = Math.Clamp(savedWindowPlacement.Value.Width, 640, displayArea.WorkArea.Width);
var height = Math.Clamp(savedWindowPlacement.Value.Height, 480, displayArea.WorkArea.Height);
var x = savedWindowPlacement.Value.X ?? appWindow.Position.X;
var y = savedWindowPlacement.Value.Y ?? appWindow.Position.Y;
var maxX = displayArea.WorkArea.X + Math.Max(0, displayArea.WorkArea.Width - width);
var maxY = displayArea.WorkArea.Y + Math.Max(0, displayArea.WorkArea.Height - height);
x = Math.Clamp(x, displayArea.WorkArea.X, maxX);
y = Math.Clamp(y, displayArea.WorkArea.Y, maxY);
appWindow.MoveAndResize(new RectInt32(x, y, width, height));
ViewModel.UpdateWindowPlacement(x, y, width, height, savedWindowPlacement.Value.IsMaximized);
if (savedWindowPlacement.Value.IsMaximized && appWindow.Presenter is OverlappedPresenter presenter)
{
presenter.Maximize();
}
}
private void CaptureCurrentWindowPlacement()
{
var appWindow = GetAppWindow();
if (appWindow is null)
{
return;
}
if (appWindow.Presenter is not OverlappedPresenter presenter)
{
ViewModel.UpdateWindowPlacement(appWindow.Position.X, appWindow.Position.Y, appWindow.Size.Width, appWindow.Size.Height, false);
return;
}
if (presenter.State == OverlappedPresenterState.Minimized)
{
return;
}
var isMaximized = presenter.State == OverlappedPresenterState.Maximized;
ViewModel.UpdateWindowPlacement(appWindow.Position.X, appWindow.Position.Y, appWindow.Size.Width, appWindow.Size.Height, isMaximized);
}
private static Task ShowDialogAsync(ContentDialog dialog)
{
var completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
void OnClosed(ContentDialog sender, ContentDialogClosedEventArgs args)
{
sender.Closed -= OnClosed;
completionSource.TrySetResult();
}
dialog.Closed += OnClosed;
_ = dialog.ShowAsync();
return completionSource.Task;
}
private async Task<bool> ConfirmBroadcastPhaseChangeAsync(BroadcastPhase targetPhase)
{
if (MainNavigationView.XamlRoot is null)
{
return false;
}
var targetLabel = targetPhase == BroadcastPhase.PreElection ? "투표" : "개표";
var description = targetPhase == BroadcastPhase.PreElection
? "투표 단계에서는 투표율과 투표자 수 중심으로 수신합니다."
: "개표 단계에서는 후보 득표수와 당선 판정 중심으로 수신합니다.";
var dialog = new ContentDialog
{
XamlRoot = MainNavigationView.XamlRoot,
Title = "방송 단계 변경",
PrimaryButtonText = "변경",
CloseButtonText = "취소",
DefaultButton = ContentDialogButton.Close,
Content = new StackPanel
{
Spacing = 10,
Children =
{
new TextBlock
{
Text = $"현재 방송 단계를 {targetLabel} 모드로 전환할까요?"
},
new TextBlock
{
Text = description,
TextWrapping = TextWrapping.WrapWholeWords
}
}
}
};
return await dialog.ShowAsync() == ContentDialogResult.Primary;
}
private async Task<bool> ConfirmOperationModeChangeAsync(ChannelOperationMode targetMode)
{
if (MainNavigationView.XamlRoot is null)
{
return false;
}
var targetLabel = targetMode == ChannelOperationMode.General ? "일반" : "VideoWall";
var description = targetMode == ChannelOperationMode.General
? "일반 모드에서는 노멀, 좌상단, 하단만 보이고 VideoWall 메뉴와 패널은 숨깁니다."
: "VideoWall 모드에서는 VideoWall만 보이고 노멀, 좌상단, 하단 메뉴와 패널은 숨깁니다.";
var dialog = new ContentDialog
{
XamlRoot = MainNavigationView.XamlRoot,
Title = "운영 모드 변경",
PrimaryButtonText = "변경",
CloseButtonText = "취소",
DefaultButton = ContentDialogButton.Close,
Content = new StackPanel
{
Spacing = 10,
Children =
{
new TextBlock
{
Text = $"현재 운영 모드를 {targetLabel} 모드로 전환할까요?"
},
new TextBlock
{
Text = description,
TextWrapping = TextWrapping.WrapWholeWords
}
}
}
};
return await dialog.ShowAsync() == ContentDialogResult.Primary;
}
private void MainNavigationView_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args)
{
if (args.SelectedItemContainer?.Tag is string tag)
{
ViewModel.Navigate(tag);
MainNavigationView.Header = ViewModel.CurrentPageTitle;
}
}
private void DistrictOverviewCard_Tapped(object sender, TappedRoutedEventArgs e)
{
if (sender is not FrameworkElement element ||
element.DataContext is not DistrictOverviewCardViewModel card)
{
return;
}
ViewModel.Data.SelectDistrictOverviewCard(card);
}
private void EnsureNavigationSelection()
{
if (!ViewModel.IsPageAvailable(ViewModel.CurrentPage))
{
ViewModel.Navigate(ViewModel.IsVideoWallOperationMode ? "videowall" : "normal");
}
var targetTag = ViewModel.CurrentPage switch
{
AppPage.Normal => "normal",
AppPage.TopLeft => "top-left",
AppPage.Bottom => "bottom",
AppPage.VideoWall => "videowall",
AppPage.PreElectionData => "pre-election-data",
AppPage.TurnoutData => "turnout-data",
AppPage.CountingData => "counting-data",
AppPage.Data => ViewModel.Data.IsPreElectionPhase ? "turnout-data" : "counting-data",
AppPage.CareerPromiseData => "career-promises",
AppPage.CutList => "cut-list",
AppPage.Settings => "settings",
AppPage.Log => "log",
_ => ViewModel.IsVideoWallOperationMode ? "videowall" : "normal"
};
foreach (var item in MainNavigationView.MenuItems)
{
if (item is NavigationViewItem navigationItem && string.Equals(navigationItem.Tag as string, targetTag, StringComparison.Ordinal))
{
MainNavigationView.SelectedItem = navigationItem;
break;
}
}
MainNavigationView.Header = ViewModel.CurrentPageTitle;
}
private async void MainWindow_Closed(object sender, WindowEventArgs args)
{
CaptureCurrentWindowPlacement();
ReleaseNativeWindowIcons();
await ViewModel.ShutdownAsync();
}
private void ApplyNativeWindowIcons(IntPtr windowHandle, string iconPath)
{
_largeWindowIconHandle = LoadNativeWindowIcon(
iconPath,
GetSystemMetrics(LargeIconWidthMetric),
GetSystemMetrics(LargeIconHeightMetric),
_largeWindowIconHandle);
if (_largeWindowIconHandle != IntPtr.Zero)
{
SendMessage(windowHandle, SetWindowIconMessage, (IntPtr)LargeIcon, _largeWindowIconHandle);
}
_smallWindowIconHandle = LoadNativeWindowIcon(
iconPath,
GetSystemMetrics(SmallIconWidthMetric),
GetSystemMetrics(SmallIconHeightMetric),
_smallWindowIconHandle);
if (_smallWindowIconHandle != IntPtr.Zero)
{
SendMessage(windowHandle, SetWindowIconMessage, (IntPtr)SmallIcon, _smallWindowIconHandle);
}
}
private void ReleaseNativeWindowIcons()
{
if (_largeWindowIconHandle != IntPtr.Zero)
{
DestroyIcon(_largeWindowIconHandle);
_largeWindowIconHandle = IntPtr.Zero;
}
if (_smallWindowIconHandle != IntPtr.Zero)
{
DestroyIcon(_smallWindowIconHandle);
_smallWindowIconHandle = IntPtr.Zero;
}
}
private static nint LoadNativeWindowIcon(string iconPath, int width, int height, nint existingHandle)
{
if (existingHandle != IntPtr.Zero)
{
DestroyIcon(existingHandle);
}
return LoadImage(IntPtr.Zero, iconPath, ImageIcon, width, height, LoadFromFile);
}
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern nint LoadImage(nint instanceHandle, string name, uint type, int width, int height, uint loadFlags);
[DllImport("user32.dll")]
private static extern nint SendMessage(IntPtr windowHandle, uint message, IntPtr wParam, nint lParam);
[DllImport("user32.dll")]
private static extern int GetSystemMetrics(int metric);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DestroyIcon(nint iconHandle);
}