554 lines
18 KiB
C#
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);
|
|
}
|