Some checks failed
Release Gate / gate (push) Has been cancelled
- ReminderPopupWindow의 자동 닫힘 경로를 점검하고 UI 타이머와 실제 종료 타이머를 분리함 - 남은 시간 계산을 절대 시각 기준으로 바꾸고 Task.Delay 기반 종료 fail-safe를 추가해 팝업이 남는 경우를 줄임 - 창 종료 시 CancellationToken과 타이머를 함께 정리해 후속 종료 처리도 안전하게 맞춤 - README와 DEVELOPMENT 문서에 변경 목적과 검증 결과를 로컬 시각 기준으로 기록함 - 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0, 오류 0)
173 lines
6.3 KiB
C#
173 lines
6.3 KiB
C#
using System.Runtime.InteropServices;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using System.Windows;
|
|
using System.Windows.Interop;
|
|
using System.Windows.Threading;
|
|
using AxCopilot.Services;
|
|
|
|
namespace AxCopilot.Views;
|
|
|
|
/// <summary>
|
|
/// 잠금 해제 시 화면 모서리에 표시되는 격려 팝업.
|
|
/// 테마는 Application.Current.Resources의 DynamicResource로 자동 적용됩니다.
|
|
/// 포커스를 빼앗지 않으며(WS_EX_NOACTIVATE), 지정된 초 후 자동으로 닫힙니다.
|
|
/// </summary>
|
|
public partial class ReminderPopupWindow : Window
|
|
{
|
|
// ─── 포커스 방지 P/Invoke (64비트 안전) ────────────────────────────────────
|
|
[DllImport("user32.dll", EntryPoint = "GetWindowLongPtr")]
|
|
private static extern IntPtr GetWindowLongPtr(IntPtr hwnd, int nIndex);
|
|
[DllImport("user32.dll", EntryPoint = "SetWindowLongPtr")]
|
|
private static extern IntPtr SetWindowLongPtr(IntPtr hwnd, int nIndex, IntPtr dwNewLong);
|
|
|
|
private const int GWL_EXSTYLE = -20;
|
|
private const int WS_EX_NOACTIVATE = 0x08000000;
|
|
private const int WS_EX_TOOLWINDOW = 0x00000080;
|
|
|
|
// ─── 타이머 ───────────────────────────────────────────────────────────────
|
|
private readonly DispatcherTimer _timer;
|
|
private readonly EventHandler _tickHandler;
|
|
private readonly CancellationTokenSource _autoCloseCts = new();
|
|
private readonly DateTime _closeAtUtc;
|
|
private readonly int _displaySeconds;
|
|
|
|
public ReminderPopupWindow(
|
|
string quoteText,
|
|
string? author,
|
|
TimeSpan todayUsage,
|
|
SettingsService settings)
|
|
{
|
|
InitializeComponent();
|
|
|
|
var cfg = settings.Settings.Reminder;
|
|
|
|
// ── 문구 / 출처 ──
|
|
QuoteText.Text = quoteText;
|
|
if (!string.IsNullOrWhiteSpace(author))
|
|
{
|
|
AuthorText.Text = $"— {author}";
|
|
AuthorText.Visibility = Visibility.Visible;
|
|
}
|
|
|
|
// ── 근무 시간 ──
|
|
var h = (int)todayUsage.TotalHours;
|
|
var m = todayUsage.Minutes;
|
|
UsageText.Text = h > 0
|
|
? $"오늘 총 {h}시간 {m}분 근무 중"
|
|
: m >= 1
|
|
? $"오늘 총 {m}분 근무 중"
|
|
: "오늘 방금 시작했습니다";
|
|
|
|
// ── 카운트다운 ──
|
|
_displaySeconds = Math.Max(3, cfg.DisplaySeconds);
|
|
_closeAtUtc = DateTime.UtcNow.AddSeconds(_displaySeconds);
|
|
CountdownBar.Maximum = _displaySeconds;
|
|
CountdownBar.Value = _displaySeconds;
|
|
|
|
// ── 위치: 레이아웃 완료 후 설정 ──
|
|
Loaded += (_, _) =>
|
|
{
|
|
SetNoActivate();
|
|
PositionWindow(cfg.Corner);
|
|
AnimateIn();
|
|
};
|
|
|
|
// ── 타이머 ──
|
|
_tickHandler = (_, _) =>
|
|
{
|
|
var remainingSeconds = Math.Max(0, (_closeAtUtc - DateTime.UtcNow).TotalSeconds);
|
|
CountdownBar.Value = remainingSeconds;
|
|
if (remainingSeconds <= 0)
|
|
Close();
|
|
};
|
|
_timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
|
|
_timer.Tick += _tickHandler;
|
|
_timer.Start();
|
|
_ = StartAutoCloseAsync(_autoCloseCts.Token);
|
|
|
|
// ── Esc 키 닫기 ──
|
|
KeyDown += (_, e) =>
|
|
{
|
|
if (e.Key == System.Windows.Input.Key.Escape) Close();
|
|
};
|
|
}
|
|
|
|
// ─── 포커스 방지 ─────────────────────────────────────────────────────────
|
|
|
|
protected override void OnSourceInitialized(EventArgs e)
|
|
{
|
|
base.OnSourceInitialized(e);
|
|
SetNoActivate();
|
|
}
|
|
|
|
private void SetNoActivate()
|
|
{
|
|
var hwnd = new WindowInteropHelper(this).Handle;
|
|
if (hwnd == IntPtr.Zero) return;
|
|
var style = (long)GetWindowLongPtr(hwnd, GWL_EXSTYLE);
|
|
SetWindowLongPtr(hwnd, GWL_EXSTYLE, (IntPtr)(style | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW));
|
|
}
|
|
|
|
// ─── 위치 계산 ────────────────────────────────────────────────────────────
|
|
|
|
private void PositionWindow(string corner)
|
|
{
|
|
var area = SystemParameters.WorkArea;
|
|
const double margin = 20;
|
|
|
|
Left = corner.Contains("left")
|
|
? area.Left + margin
|
|
: area.Right - ActualWidth - margin;
|
|
|
|
Top = corner.Contains("top")
|
|
? area.Top + margin
|
|
: area.Bottom - ActualHeight - margin;
|
|
}
|
|
|
|
// ─── 등장 애니메이션 ──────────────────────────────────────────────────────
|
|
|
|
private void AnimateIn()
|
|
{
|
|
Opacity = 0;
|
|
var anim = new System.Windows.Media.Animation.DoubleAnimation(0, 1,
|
|
TimeSpan.FromMilliseconds(280));
|
|
BeginAnimation(OpacityProperty, anim);
|
|
}
|
|
|
|
// ─── 이벤트 ───────────────────────────────────────────────────────────────
|
|
|
|
private void CloseBtn_Click(object sender, RoutedEventArgs e) => Close();
|
|
|
|
private async Task StartAutoCloseAsync(CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
await Task.Delay(TimeSpan.FromSeconds(_displaySeconds), cancellationToken);
|
|
if (cancellationToken.IsCancellationRequested)
|
|
return;
|
|
|
|
await Dispatcher.InvokeAsync(() =>
|
|
{
|
|
if (IsVisible)
|
|
Close();
|
|
}, DispatcherPriority.Background, cancellationToken);
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
}
|
|
}
|
|
|
|
protected override void OnClosed(EventArgs e)
|
|
{
|
|
_autoCloseCts.Cancel();
|
|
_timer.Stop();
|
|
_timer.Tick -= _tickHandler;
|
|
_autoCloseCts.Dispose();
|
|
base.OnClosed(e);
|
|
}
|
|
}
|