diff --git a/docs/LAUNCHER_ROADMAP.md b/docs/LAUNCHER_ROADMAP.md index 35736d4..42bb0ff 100644 --- a/docs/LAUNCHER_ROADMAP.md +++ b/docs/LAUNCHER_ROADMAP.md @@ -134,7 +134,7 @@ | L5-3 | **QuickLook 인라인 편집** ✅ | F3 미리보기 → Ctrl+E 편집 모드 토글. 텍스트/코드 전체 읽기(300줄 제한 없음). Ctrl+S 저장, ● 수정 마커, Esc 취소 확인, 저장 후 미리보기 새로고침 | 중간 | | L5-4 | **앱 세션 스냅** ✅ | `session` 프리픽스. `AppSession/SessionApp` 모델 추가 + `AppSettings.AppSessions` 저장. `SessionHandler`: 목록·실행·new/edit/del 서브커맨드. `SessionEditorWindow`: 세션 이름·설명·앱 행(경로+라벨+스냅 팝업 14종+삭제) 인라인 편집. 실행 시 Process.Start → 창 핸들 대기(6초) → ApplySnapToWindow(P/Invoke SetWindowPos+ShowWindow) | 중간 | | L5-5 | **배치 파일 이름 변경** ✅ | `batchren` 프리픽스로 BatchRenameWindow 오픈. 변수 패턴(`{name}`, `{n:3}`, `{date:format}`, `{ext}`) + 정규식 모드(`/old/new/`). 드래그 앤 드롭·폴더/파일 추가, DataGrid 실시간 미리보기, 충돌 감지(배경 붉은 강조), 확장자 유지 토글, 시작 번호 지정, 적용 후 엔트리 갱신 | 중간 | -| L5-6 | **자동화 스케줄러** | `sched` 프리픽스로 시간·앱 기반 트리거 등록. "매일 09:00 = 크롬 열기", "캐치 앱 실행 시 = 알림" | 낮음 | +| L5-6 | **자동화 스케줄러** ✅ | `sched` 프리픽스. `ScheduleEntry` 모델(Id·Name·Enabled·TriggerType·TriggerTime·WeekDays·TriggerDate·ActionType·ActionTarget·ActionArgs·LastRun) + `AppSettings.Schedules` 저장. `SchedulerService`: 30초 간격 타이머, ±1분 트리거 윈도우, `LastRun.Date == today` 중복 방지, once 실행 후 자동 비활성화. `ScheduleHandler`: 목록(다음 실행 시각 표시)·new·edit·del·Enter 토글. `ScheduleEditorWindow`: 트리거 유형 4종(매일/주중/매주/한번)·요일 다중 선택·날짜 입력, 액션 2종(앱 실행/알림). `ComputeNextRun` + `TriggerLabel` 유틸 공유 | 낮음 | ### Phase L5 구현 순서 (권장) diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index 60f5a86..39ad071 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -20,6 +20,7 @@ public partial class App : System.Windows.Application private SettingsService? _settings; private SettingsWindow? _settingsWindow; private PluginHost? _pluginHost; + private SchedulerService? _schedulerService; private ClipboardHistoryService? _clipboardHistory; private DockBarWindow? _dockBar; private FileDialogWatcher? _fileDialogWatcher; @@ -186,6 +187,11 @@ public partial class App : System.Windows.Application commandResolver.RegisterHandler(new SessionHandler(settings)); // Phase L5-5: 배치 파일 이름변경 (prefix=batchren) commandResolver.RegisterHandler(new BatchRenameHandler()); + // Phase L5-6: 자동화 스케줄러 (prefix=sched) + var schedulerService = new SchedulerService(settings); + schedulerService.Start(); + _schedulerService = schedulerService; + commandResolver.RegisterHandler(new ScheduleHandler(settings)); // ─── 플러그인 로드 ──────────────────────────────────────────────────── var pluginHost = new PluginHost(settings, commandResolver); diff --git a/src/AxCopilot/Handlers/ScheduleHandler.cs b/src/AxCopilot/Handlers/ScheduleHandler.cs new file mode 100644 index 0000000..8aaf343 --- /dev/null +++ b/src/AxCopilot/Handlers/ScheduleHandler.cs @@ -0,0 +1,171 @@ +using System.IO; +using AxCopilot.Models; +using AxCopilot.SDK; +using AxCopilot.Services; +using AxCopilot.Themes; + +namespace AxCopilot.Handlers; + +/// +/// L5-6: 자동화 스케줄 핸들러. "sched" 프리픽스로 사용합니다. +/// +/// 예: sched → 등록된 스케줄 목록 +/// sched 이름 → 이름으로 필터 +/// sched new → 새 스케줄 편집기 열기 +/// sched edit 이름 → 기존 스케줄 편집 +/// sched del 이름 → 스케줄 삭제 +/// sched toggle 이름 → 활성/비활성 전환 (Enter) +/// +public class ScheduleHandler : IActionHandler +{ + private readonly SettingsService _settings; + + public ScheduleHandler(SettingsService settings) { _settings = settings; } + + public string? Prefix => "sched"; + + public PluginMetadata Metadata => new( + "Scheduler", + "자동화 스케줄 — sched", + "1.0", + "AX"); + + // ─── 항목 목록 ────────────────────────────────────────────────────────── + public Task> GetItemsAsync(string query, CancellationToken ct) + { + var q = query.Trim(); + var parts = q.Split(' ', 2, StringSplitOptions.TrimEntries); + var cmd = parts.Length > 0 ? parts[0].ToLowerInvariant() : ""; + + // "new" — 새 스케줄 + if (cmd == "new") + { + return Task.FromResult>(new[] + { + new LauncherItem("새 스케줄 만들기", + "편집기에서 트리거 시각과 실행 액션을 설정합니다", + null, "__new__", Symbol: "\uE710") + }); + } + + // "edit 이름" + if (cmd == "edit" && parts.Length > 1) + { + return Task.FromResult>(new[] + { + new LauncherItem($"'{parts[1]}' 스케줄 편집", "편집기 열기", + null, $"__edit__{parts[1]}", Symbol: "\uE70F") + }); + } + + // "del 이름" or "delete 이름" + if ((cmd == "del" || cmd == "delete") && parts.Length > 1) + { + return Task.FromResult>(new[] + { + new LauncherItem($"'{parts[1]}' 스케줄 삭제", + "Enter로 삭제 확인", + null, $"__del__{parts[1]}", Symbol: Symbols.Delete) + }); + } + + // 목록 표시 + var schedules = _settings.Settings.Schedules; + var filter = q.ToLowerInvariant(); + var items = new List(); + + foreach (var s in schedules) + { + if (!string.IsNullOrEmpty(filter) && + !s.Name.Contains(filter, StringComparison.OrdinalIgnoreCase)) + continue; + + var nextRun = SchedulerService.ComputeNextRun(s); + var nextStr = nextRun.HasValue ? nextRun.Value.ToString("MM/dd HH:mm") : "─"; + var trigger = SchedulerService.TriggerLabel(s); + var symbol = s.Enabled ? "\uE916" : "\uE8D8"; // 타이머 / 멈춤 + + var actionIcon = s.ActionType == "notification" ? "🔔" : "▶"; + var actionName = s.ActionType == "notification" + ? s.ActionTarget + : Path.GetFileNameWithoutExtension(s.ActionTarget); + + var subtitle = s.Enabled + ? $"{trigger} {s.TriggerTime} · {actionIcon} {actionName} · 다음: {nextStr}" + : $"[비활성] {trigger} {s.TriggerTime} · {actionIcon} {actionName}"; + + items.Add(new LauncherItem( + s.Name, subtitle, null, s, Symbol: symbol)); + } + + if (items.Count == 0 && string.IsNullOrEmpty(filter)) + { + items.Add(new LauncherItem( + "등록된 스케줄 없음", + "'sched new'로 자동화 스케줄을 추가하세요", + null, null, Symbol: Symbols.Info)); + } + + items.Add(new LauncherItem( + "새 스케줄 만들기", + "sched new · 시각·요일 기반 앱 실행 / 알림 자동화", + null, "__new__", Symbol: "\uE710")); + + return Task.FromResult>(items); + } + + // ─── 실행 ───────────────────────────────────────────────────────────── + public Task ExecuteAsync(LauncherItem item, CancellationToken ct) + { + if (item.Data is string s) + { + if (s == "__new__") + { + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + var win = new Views.ScheduleEditorWindow(null, _settings); + win.Show(); + }); + return Task.CompletedTask; + } + + if (s.StartsWith("__edit__")) + { + var name = s["__edit__".Length..]; + var entry = _settings.Settings.Schedules + .FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + var win = new Views.ScheduleEditorWindow(entry, _settings); + win.Show(); + }); + return Task.CompletedTask; + } + + if (s.StartsWith("__del__")) + { + var name = s["__del__".Length..]; + var entry = _settings.Settings.Schedules + .FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + if (entry != null) + { + _settings.Settings.Schedules.Remove(entry); + _settings.Save(); + NotificationService.Notify("AX Copilot", $"스케줄 '{name}' 삭제됨"); + } + return Task.CompletedTask; + } + } + + // 스케줄 항목 Enter → 활성/비활성 토글 + if (item.Data is ScheduleEntry se) + { + se.Enabled = !se.Enabled; + _settings.Save(); + var state = se.Enabled ? "활성화" : "비활성화"; + NotificationService.Notify("AX Copilot", $"스케줄 '{se.Name}' {state}됨"); + } + + return Task.CompletedTask; + } +} diff --git a/src/AxCopilot/Models/AppSettings.Models.cs b/src/AxCopilot/Models/AppSettings.Models.cs index 919ea78..7fc3f20 100644 --- a/src/AxCopilot/Models/AppSettings.Models.cs +++ b/src/AxCopilot/Models/AppSettings.Models.cs @@ -310,6 +310,61 @@ public class SessionApp public int DelayMs { get; set; } = 0; } +// ─── 자동화 스케줄러 ───────────────────────────────────────────────────────────── + +/// +/// 자동화 스케줄 항목. +/// 지정 시각에 앱 실행 또는 알림을 자동으로 발생시킵니다. +/// +public class ScheduleEntry +{ + [JsonPropertyName("id")] + public string Id { get; set; } = Guid.NewGuid().ToString("N")[..8]; + + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("enabled")] + public bool Enabled { get; set; } = true; + + // ─── 트리거 ─────────────────────────────────────────────────────────── + + /// 실행 주기. daily | weekdays | weekly | once + [JsonPropertyName("triggerType")] + public string TriggerType { get; set; } = "daily"; + + /// 실행 시각 (HH:mm 형식). 예: "09:00" + [JsonPropertyName("triggerTime")] + public string TriggerTime { get; set; } = "09:00"; + + /// weekly 트리거: 요일 목록. 0=일, 1=월 … 6=토 + [JsonPropertyName("weekDays")] + public List WeekDays { get; set; } = new(); + + /// once 트리거: 실행 날짜 (yyyy-MM-dd). 한 번만 실행 후 비활성화. + [JsonPropertyName("triggerDate")] + public string? TriggerDate { get; set; } + + // ─── 액션 ───────────────────────────────────────────────────────────── + + /// 실행 동작 유형. app | notification + [JsonPropertyName("actionType")] + public string ActionType { get; set; } = "app"; + + /// 앱 경로 또는 알림 메시지 본문 + [JsonPropertyName("actionTarget")] + public string ActionTarget { get; set; } = ""; + + /// 앱 실행 시 추가 인자 + [JsonPropertyName("actionArgs")] + public string ActionArgs { get; set; } = ""; + + // ─── 상태 ───────────────────────────────────────────────────────────── + + [JsonPropertyName("lastRun")] + public DateTime? LastRun { get; set; } +} + // ─── 잠금 해제 알림 설정 ─────────────────────────────────────────────────────── public class ReminderSettings diff --git a/src/AxCopilot/Models/AppSettings.cs b/src/AxCopilot/Models/AppSettings.cs index ddc2dca..057b482 100644 --- a/src/AxCopilot/Models/AppSettings.cs +++ b/src/AxCopilot/Models/AppSettings.cs @@ -118,6 +118,12 @@ public class AppSettings [JsonPropertyName("appSessions")] public List AppSessions { get; set; } = new(); + /// + /// 자동화 스케줄 목록. SchedulerService가 백그라운드에서 트리거를 확인·실행합니다. + /// + [JsonPropertyName("schedules")] + public List Schedules { get; set; } = new(); + [JsonPropertyName("llm")] public LlmSettings Llm { get; set; } = new(); } diff --git a/src/AxCopilot/Services/SchedulerService.cs b/src/AxCopilot/Services/SchedulerService.cs new file mode 100644 index 0000000..29e57ce --- /dev/null +++ b/src/AxCopilot/Services/SchedulerService.cs @@ -0,0 +1,197 @@ +using System.Diagnostics; +using System.IO; +using System.Windows; +using AxCopilot.Models; + +namespace AxCopilot.Services; + +/// +/// L5-6: 자동화 스케줄 백그라운드 서비스. +/// 30초 간격으로 활성 스케줄을 검사하여 해당 시각에 액션을 실행합니다. +/// +public sealed class SchedulerService : IDisposable +{ + private readonly SettingsService _settings; + private Timer? _timer; + private bool _disposed; + + public SchedulerService(SettingsService settings) + { + _settings = settings; + } + + // ─── 시작 / 중지 ───────────────────────────────────────────────────── + public void Start() + { + // 30초 간격 체크 (즉시 1회 실행 후) + _timer = new Timer(OnTick, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)); + LogService.Info("SchedulerService 시작"); + } + + public void Stop() + { + _timer?.Change(Timeout.Infinite, Timeout.Infinite); + LogService.Info("SchedulerService 중지"); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _timer?.Dispose(); + _timer = null; + } + + // ─── 트리거 검사 ───────────────────────────────────────────────────── + private void OnTick(object? _) + { + try + { + var now = DateTime.Now; + var schedules = _settings.Settings.Schedules; + bool dirty = false; + + foreach (var entry in schedules.ToList()) // ToList: 반복 중 수정 방지 + { + if (!ShouldFire(entry, now)) continue; + + LogService.Info($"스케줄 실행: '{entry.Name}' ({entry.TriggerType} {entry.TriggerTime})"); + ExecuteAction(entry); + + entry.LastRun = now; + dirty = true; + + // once 트리거는 실행 후 비활성화 + if (entry.TriggerType == "once") + entry.Enabled = false; + } + + if (dirty) _settings.Save(); + } + catch (Exception ex) + { + LogService.Error($"SchedulerService 오류: {ex.Message}"); + } + } + + // ─── 트리거 조건 검사 ───────────────────────────────────────────────── + private static bool ShouldFire(ScheduleEntry entry, DateTime now) + { + if (!entry.Enabled) return false; + if (!TimeSpan.TryParse(entry.TriggerTime, out var triggerTime)) return false; + + // 트리거 시각과 ±1분 이내인지 확인 + var targetDt = now.Date + triggerTime; + if (Math.Abs((now - targetDt).TotalMinutes) > 1.0) return false; + + // 오늘 이미 실행했는지 확인 (once 제외) + if (entry.TriggerType != "once" && + entry.LastRun.HasValue && + entry.LastRun.Value.Date == now.Date) + return false; + + return entry.TriggerType switch + { + "daily" => true, + "weekdays" => now.DayOfWeek >= DayOfWeek.Monday && + now.DayOfWeek <= DayOfWeek.Friday, + "weekly" => entry.WeekDays.Count > 0 && + entry.WeekDays.Contains((int)now.DayOfWeek), + "once" => !entry.LastRun.HasValue && + entry.TriggerDate != null && + DateTime.TryParse(entry.TriggerDate, out var d) && + now.Date == d.Date, + _ => false + }; + } + + // ─── 액션 실행 ──────────────────────────────────────────────────────── + private static void ExecuteAction(ScheduleEntry entry) + { + try + { + switch (entry.ActionType) + { + case "app": + if (!string.IsNullOrWhiteSpace(entry.ActionTarget)) + Process.Start(new ProcessStartInfo + { + FileName = entry.ActionTarget, + Arguments = entry.ActionArgs ?? "", + UseShellExecute = true + }); + break; + + case "notification": + var msg = string.IsNullOrWhiteSpace(entry.ActionTarget) + ? entry.Name + : entry.ActionTarget; + Application.Current?.Dispatcher.Invoke(() => + NotificationService.Notify($"[스케줄] {entry.Name}", msg)); + break; + } + } + catch (Exception ex) + { + LogService.Warn($"스케줄 액션 실행 실패 '{entry.Name}': {ex.Message}"); + } + } + + // ─── 유틸리티 (핸들러·편집기에서 공유) ────────────────────────────── + + /// 지정 스케줄의 다음 실행 예정 시각을 계산합니다. + public static DateTime? ComputeNextRun(ScheduleEntry entry) + { + if (!entry.Enabled) return null; + if (!TimeSpan.TryParse(entry.TriggerTime, out var t)) return null; + + var now = DateTime.Now; + + return entry.TriggerType switch + { + "once" when + !entry.LastRun.HasValue && + entry.TriggerDate != null && + DateTime.TryParse(entry.TriggerDate, out var d) && + (now.Date + t) >= now => + d.Date + t, + + "daily" => NextOccurrence(now, t, _ => true), + + "weekdays" => NextOccurrence(now, t, dt => + dt.DayOfWeek >= DayOfWeek.Monday && dt.DayOfWeek <= DayOfWeek.Friday), + + "weekly" when entry.WeekDays.Count > 0 => NextOccurrence(now, t, dt => + entry.WeekDays.Contains((int)dt.DayOfWeek)), + + _ => null + }; + } + + private static DateTime? NextOccurrence(DateTime now, TimeSpan t, Func dayFilter) + { + for (int i = 0; i <= 7; i++) + { + var candidate = now.Date.AddDays(i) + t; + if (candidate > now && dayFilter(candidate)) + return candidate; + } + return null; + } + + /// 트리거 유형 표시 이름. + public static string TriggerLabel(ScheduleEntry e) => e.TriggerType switch + { + "daily" => "매일", + "weekdays" => "주중(월~금)", + "weekly" => WeekDayLabel(e.WeekDays), + "once" => $"한번({e.TriggerDate})", + _ => e.TriggerType + }; + + private static readonly string[] DayShort = ["일", "월", "화", "수", "목", "금", "토"]; + + private static string WeekDayLabel(List days) => + days.Count == 0 ? "매주(요일 미지정)" : + "매주 " + string.Join("·", days.OrderBy(d => d).Select(d => DayShort[d])); +} diff --git a/src/AxCopilot/Views/ScheduleEditorWindow.xaml b/src/AxCopilot/Views/ScheduleEditorWindow.xaml new file mode 100644 index 0000000..175774b --- /dev/null +++ b/src/AxCopilot/Views/ScheduleEditorWindow.xaml @@ -0,0 +1,307 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AxCopilot/Views/ScheduleEditorWindow.xaml.cs b/src/AxCopilot/Views/ScheduleEditorWindow.xaml.cs new file mode 100644 index 0000000..a0c80af --- /dev/null +++ b/src/AxCopilot/Views/ScheduleEditorWindow.xaml.cs @@ -0,0 +1,306 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Animation; +using AxCopilot.Models; +using AxCopilot.Services; +using Microsoft.Win32; + +namespace AxCopilot.Views; + +public partial class ScheduleEditorWindow : Window +{ + private readonly SettingsService _settings; + private readonly ScheduleEntry? _editing; // null = 새 스케줄 + + private string _triggerType = "daily"; + private string _actionType = "app"; + private bool _enabled = true; + + // 요일 버튼 → Border 참조 + private Border[] _dayBtns = null!; + + public ScheduleEditorWindow(ScheduleEntry? entry, SettingsService settings) + { + InitializeComponent(); + _settings = settings; + _editing = entry; + + _dayBtns = new[] { BtnSun, BtnMon, BtnTue, BtnWed, BtnThu, BtnFri, BtnSat }; + + Loaded += OnLoaded; + } + + // ─── 초기화 ───────────────────────────────────────────────────────────── + private void OnLoaded(object sender, RoutedEventArgs e) + { + // 다크 테마 색상 + var dimBg = TryFindResource("ItemBackground") as Brush ?? new SolidColorBrush(Color.FromRgb(0x25, 0x26, 0x37)); + var accent = TryFindResource("AccentColor") as Brush ?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)); + var border = TryFindResource("BorderColor") as Brush ?? new SolidColorBrush(Color.FromRgb(0x2E, 0x2F, 0x4A)); + + // 요일 버튼 기본 색 + foreach (var b in _dayBtns) + { + b.Background = dimBg; + b.BorderBrush = border; + b.BorderThickness = new Thickness(1); + if (b.Child is TextBlock tb) + tb.Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + } + + if (_editing != null) + LoadFromEntry(_editing); + else + SetTriggerUi("daily"); + + SetActionUi(_actionType); + UpdateToggleUi(_enabled); + } + + private void LoadFromEntry(ScheduleEntry e) + { + NameBox.Text = e.Name; + TimeBox.Text = e.TriggerTime; + _enabled = e.Enabled; + _triggerType = e.TriggerType; + _actionType = e.ActionType; + + if (e.TriggerDate != null) + DateBox.Text = e.TriggerDate; + + SetTriggerUi(e.TriggerType); + + // 요일 복원 + foreach (var b in _dayBtns) + { + if (int.TryParse(b.Tag?.ToString(), out var day) && e.WeekDays.Contains(day)) + SetDaySelected(b, true); + } + + if (e.ActionType == "app") + { + AppPathBox.Text = e.ActionTarget; + AppArgsBox.Text = e.ActionArgs ?? ""; + } + else + { + NotifMsgBox.Text = e.ActionTarget; + } + } + + // ─── 트리거 유형 ───────────────────────────────────────────────────────── + private void TriggerType_Click(object sender, MouseButtonEventArgs e) + { + if (sender is Border b && b.Tag is string tag) + SetTriggerUi(tag); + } + + private void SetTriggerUi(string type) + { + _triggerType = type; + + var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; + var dimBg = TryFindResource("ItemBackground") as Brush ?? Brushes.DimGray; + var secFg = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var white = Brushes.White; + + // 버튼 배경·텍스트 색 초기화 + void SetBtn(Border btn, TextBlock txt, bool active) + { + btn.Background = active ? accent : dimBg; + txt.Foreground = active ? white : secFg; + } + + SetBtn(BtnDaily, TxtDaily, type == "daily"); + SetBtn(BtnWeekdays, TxtWeekdays, type == "weekdays"); + SetBtn(BtnWeekly, TxtWeekly, type == "weekly"); + SetBtn(BtnOnce, TxtOnce, type == "once"); + + // 요일 패널 / 날짜 패널 표시 + WeekDaysPanel.Visibility = type == "weekly" ? Visibility.Visible : Visibility.Collapsed; + DatePanel.Visibility = type == "once" ? Visibility.Visible : Visibility.Collapsed; + + // once 기본값 + if (type == "once" && string.IsNullOrWhiteSpace(DateBox.Text)) + DateBox.Text = DateTime.Now.AddDays(1).ToString("yyyy-MM-dd"); + } + + // ─── 요일 선택 ────────────────────────────────────────────────────────── + private void WeekDay_Click(object sender, MouseButtonEventArgs e) + { + if (sender is not Border btn) return; + var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; + var dimBg = TryFindResource("ItemBackground") as Brush ?? Brushes.DimGray; + bool current = btn.Background == accent; + SetDaySelected(btn, !current); + } + + private void SetDaySelected(Border btn, bool selected) + { + var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; + var dimBg = TryFindResource("ItemBackground") as Brush ?? Brushes.DimGray; + var secFg = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + + btn.Background = selected ? accent : dimBg; + if (btn.Child is TextBlock tb) + tb.Foreground = selected ? Brushes.White : secFg; + } + + private List GetSelectedDays() + { + var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; + var list = new List(); + foreach (var b in _dayBtns) + { + if (b.Background == accent && int.TryParse(b.Tag?.ToString(), out var day)) + list.Add(day); + } + return list; + } + + // ─── 액션 유형 ────────────────────────────────────────────────────────── + private void ActionType_Click(object sender, MouseButtonEventArgs e) + { + if (sender is Border b && b.Tag is string tag) + SetActionUi(tag); + } + + private void SetActionUi(string type) + { + _actionType = type; + + var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue; + var dimBg = TryFindResource("ItemBackground") as Brush ?? Brushes.DimGray; + var secFg = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; + var white = Brushes.White; + + bool isApp = type == "app"; + + BtnActionApp.Background = isApp ? accent : dimBg; + BtnActionNotif.Background = !isApp ? accent : dimBg; + + TxtActionApp.Foreground = isApp ? white : secFg; + TxtActionNotif.Foreground = !isApp ? white : secFg; + + // 아이콘 TextBlock은 StackPanel의 첫 번째 자식 + if (BtnActionApp.Child is StackPanel spApp && spApp.Children.Count > 0) + ((TextBlock)spApp.Children[0]).Foreground = isApp ? white : secFg; + if (BtnActionNotif.Child is StackPanel spNotif && spNotif.Children.Count > 0) + ((TextBlock)spNotif.Children[0]).Foreground = !isApp ? white : secFg; + + AppPathPanel.Visibility = isApp ? Visibility.Visible : Visibility.Collapsed; + NotifPanel.Visibility = !isApp ? Visibility.Visible : Visibility.Collapsed; + } + + // ─── 앱 찾아보기 ───────────────────────────────────────────────────────── + private void BtnBrowseApp_Click(object sender, MouseButtonEventArgs e) + { + var dlg = new OpenFileDialog + { + Title = "실행 파일 선택", + Filter = "실행 파일|*.exe;*.bat;*.cmd;*.lnk;*.ps1|모든 파일|*.*" + }; + if (dlg.ShowDialog(this) == true) + AppPathBox.Text = dlg.FileName; + } + + // ─── 활성화 토글 ───────────────────────────────────────────────────────── + private void EnabledToggle_Click(object sender, MouseButtonEventArgs e) + { + _enabled = !_enabled; + UpdateToggleUi(_enabled); + } + + private void UpdateToggleUi(bool enabled) + { + var accent = TryFindResource("AccentColor") as Brush + ?? new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)); + var off = new SolidColorBrush(Color.FromRgb(0x3A, 0x3B, 0x5A)); + + EnabledToggle.Background = enabled ? accent : off; + + // 썸 위치 애니메이션 + var da = new DoubleAnimation( + enabled ? 1.0 : -1.0, // 실제 HorizontalAlignment·Margin으로 처리 + TimeSpan.FromMilliseconds(150)); + + EnabledThumb.HorizontalAlignment = enabled ? HorizontalAlignment.Right : HorizontalAlignment.Left; + EnabledThumb.Margin = enabled ? new Thickness(0, 0, 2, 0) : new Thickness(2, 0, 0, 0); + } + + // ─── 저장 ──────────────────────────────────────────────────────────────── + private void BtnSave_Click(object sender, MouseButtonEventArgs e) + { + var name = NameBox.Text.Trim(); + if (string.IsNullOrWhiteSpace(name)) + { + MessageBox.Show("스케줄 이름을 입력하세요.", "저장 오류", + MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + var timeStr = TimeBox.Text.Trim(); + if (!TimeSpan.TryParseExact(timeStr, new[] { @"hh\:mm", @"h\:mm" }, + System.Globalization.CultureInfo.InvariantCulture, out _)) + { + MessageBox.Show("실행 시각을 HH:mm 형식으로 입력하세요. (예: 09:00)", + "저장 오류", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + if (_triggerType == "once") + { + var dateStr = DateBox.Text.Trim(); + if (!DateTime.TryParse(dateStr, out _)) + { + MessageBox.Show("실행 날짜를 yyyy-MM-dd 형식으로 입력하세요.", + "저장 오류", MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + } + + if (_actionType == "app" && string.IsNullOrWhiteSpace(AppPathBox.Text)) + { + MessageBox.Show("실행할 앱 경로를 입력하세요.", "저장 오류", + MessageBoxButton.OK, MessageBoxImage.Warning); + return; + } + + // 기존 항목 편집 or 신규 생성 + var entry = _editing ?? new ScheduleEntry(); + + entry.Name = name; + entry.Enabled = _enabled; + entry.TriggerType = _triggerType; + entry.TriggerTime = timeStr; + entry.WeekDays = _triggerType == "weekly" ? GetSelectedDays() : new List(); + entry.TriggerDate = _triggerType == "once" ? DateBox.Text.Trim() : null; + entry.ActionType = _actionType; + entry.ActionTarget = _actionType == "app" + ? AppPathBox.Text.Trim() + : NotifMsgBox.Text.Trim(); + entry.ActionArgs = _actionType == "app" ? AppArgsBox.Text.Trim() : ""; + + var schedules = _settings.Settings.Schedules; + + if (_editing == null) + schedules.Add(entry); + // 편집 모드: 이미 리스트 내 참조이므로 별도 추가 불필요 + + _settings.Save(); + NotificationService.Notify("AX Copilot", $"스케줄 '{entry.Name}' 저장됨"); + Close(); + } + + // ─── 윈도우 컨트롤 ────────────────────────────────────────────────────── + private void TitleBar_MouseDown(object sender, MouseButtonEventArgs e) + { + if (e.LeftButton == MouseButtonState.Pressed) + DragMove(); + } + + private void BtnClose_Click(object sender, MouseButtonEventArgs e) => Close(); + private void BtnCancel_Click(object sender, MouseButtonEventArgs e) => Close(); +}