using System.Windows; using Microsoft.Win32; namespace SheetMe.Designer.Services; /// 알림 성격 — 아이콘과 강조색만 결정한다(버튼 구성과 무관) public enum DialogKind { /// 정보 — 성공·빈 결과처럼 실패가 아닌 안내 Info, /// 경고 — 거부·미설정·입력 오류 Warning, /// 오류 — 예외 Error, /// 확인 — 사용자에게 묻는 자리 Question, } /// 버튼 구성 — 실제로 쓰이는 3종이 전부다 public enum DialogButtons { /// 확인 1개 Ok, /// 예 / 아니오 YesNo, /// 예 / 아니오 / 취소 YesNoCancel, } /// /// 대화상자 진입점 — 파일 대화상자 래퍼(ViewModel 에서 View 기술 의존 격리)와 /// 앱 테마를 따르는 알림·확인 창. /// /// 알림을 순정 MessageBox 대신 자체 창으로 띄우면서 새로 생긴 제약이 셋 있고, /// 그 셋을 이 클래스가 한곳에서 막는다 — 진단 모드에서는 창을 만들지 않고, UI 스레드가 아니면 /// 넘겨 주며, 소유 창은 살아 있는 것만 건다. 호출부는 이걸 몰라도 된다. /// public sealed class DialogService { #region Methods /// 열기 대화상자 — 취소 시 null public string? ShowOpenFile(string filter, string title) { var dialog = new OpenFileDialog { Filter = filter, Title = title, }; return dialog.ShowDialog() == true ? dialog.FileName : null; } /// 저장 대화상자 — 취소 시 null public string? ShowSaveFile(string filter, string title, string defaultFileName) { var dialog = new SaveFileDialog { Filter = filter, Title = title, FileName = defaultFileName, }; return dialog.ShowDialog() == true ? dialog.FileName : null; } /// /// 오류 안내 — 상세는 로그로, 화면에는 조치 가능한 내용만. /// /// 우리가 던진 안내성 예외( 등)는 메시지 자체에 조치가 /// 담겨 있으므로 그대로 보여준다. 그 외(Oracle 오류·NRE 등)는 SQL 조각이나 접속 단서가 섞일 수 있어 /// 일반화 문구 + 오류 코드만 노출한다 — 코드는 로그 줄머리와 같아서 전화 한 통으로 특정된다. /// public static void ShowError(string action, Exception exception, Window? owner = null) { var code = AppLog.Error(action, exception); var detail = exception is InvalidOperationException or ArgumentException ? exception.Message : $"{exception.GetType().Name} — 자세한 내용은 로그를 확인해 주세요.\n오류 코드: {code}"; Notify(DialogKind.Error, "오류", $"{action} 중 오류가 발생했습니다.", AppLog.Redact(detail), owner); } /// 알림(확인 1개). 은 머리말 아래에 보조 글씨로 붙는다. public static void Notify(DialogKind kind, string caption, string message, string? detail = null, Window? owner = null) => Ask(kind, caption, message, detail, DialogButtons.Ok, owner: owner); /// 확인(예/아니오) — 예=true. 닫기·Esc 는 아니오로 친다. /// 되돌리기 어려운 동작이면 기본 버튼을 부정 쪽에 둔다 public static bool Confirm(string caption, string message, string? detail = null, string yes = "예", string no = "아니오", bool destructive = false, Window? owner = null) => Ask(DialogKind.Question, caption, message, detail, DialogButtons.YesNo, yes, no, destructive, owner) == true; /// 확인(예/아니오/취소) — 취소=null. 닫기·Esc 는 취소로 친다. public static bool? ConfirmWithCancel(string caption, string message, string? detail = null, string yes = "예", string no = "아니오", Window? owner = null) => Ask(DialogKind.Question, caption, message, detail, DialogButtons.YesNoCancel, yes, no, destructive: false, owner); /// /// 실제 표시 — 세 가지 안전장치를 여기서 한 번에 건다. /// private static bool? Ask(DialogKind kind, string caption, string message, string? detail, DialogButtons buttons, string yes = "예", string no = "아니오", bool destructive = false, Window? owner = null) { // ① 진단 모드에서는 창을 만들지 않는다. // 스모크는 무인 실행이라 모달이 하나라도 뜨면 타임아웃 없이 영원히 멈춘다. // (실제로 편집 스모크가 지나는 경로에 붙여넣기·개명 가드가 있다) if (UserSession.IsDiagnostic) { AppLog.Warn($"[진단] {caption}: {message}{(detail is null ? "" : " / " + detail)}"); return DismissValue(buttons); } var app = Application.Current; if (app is null) { // 앱이 없는 호스트(단위 테스트 등) — 창을 만들 수 없다 AppLog.Warn($"[창 없음] {caption}: {message}"); return DismissValue(buttons); } // ② 스레드 친화성. MessageBox 는 Win32 호출이라 아무 스레드에서나 됐지만 Window 는 아니다. if (!app.Dispatcher.CheckAccess()) { return app.Dispatcher.Invoke( () => Ask(kind, caption, message, detail, buttons, yes, no, destructive, owner)); } var dialog = new Views.MessageDialogView(kind, caption, message, detail, buttons, yes, no, destructive); // ③ 소유 창은 '살아 있는 것'만 건다. 아직 보이지 않았거나 이미 닫힌 창을 Owner 로 주면 // WPF 가 예외를 던진다 — 진단 렌더러가 도는 동안 MainWindow 가 닫힌 창을 가리킬 수 있다. var target = owner ?? app.MainWindow; if (target is { IsLoaded: true, IsVisible: true } && !ReferenceEquals(target, dialog)) { dialog.Owner = target; dialog.WindowStartupLocation = WindowStartupLocation.CenterOwner; } else { // 소유 창이 없으면 화면 가운데에 띄우고 작업 표시줄에도 노출한다 — // 안 그러면 다른 창 뒤로 숨어 '앱이 멈춘' 것처럼 보인다 dialog.WindowStartupLocation = WindowStartupLocation.CenterScreen; dialog.ShowInTaskbar = true; } dialog.ShowDialog(); return dialog.Answer; } /// 창을 띄우지 못했을 때의 안전 기본값 — MessageBox 의 기각 의미론과 같다 private static bool? DismissValue(DialogButtons buttons) => buttons switch { DialogButtons.YesNo => false, DialogButtons.YesNoCancel => null, _ => true, }; #endregion }