[Phase L5-6] 자동화 스케줄러 구현 완료

ScheduleEntry 모델 (AppSettings.Models.cs):
- Id(8자 GUID), Name, Enabled, TriggerType(daily/weekdays/weekly/once)
- TriggerTime(HH:mm), WeekDays(List<int>), TriggerDate(nullable), LastRun(nullable)
- ActionType(app/notification), ActionTarget, ActionArgs

AppSettings.cs:
- [JsonPropertyName("schedules")] public List<ScheduleEntry> Schedules 추가

Services/SchedulerService.cs (197줄 신규):
- System.Threading.Timer 30초 간격 백그라운드 체크
- ShouldFire(): ±1분 윈도우, LastRun.Date == today 중복 방지, once 자동 비활성화
- ExecuteAction(): 앱 실행(ProcessStartInfo) / 알림(NotificationService.Notify)
- ComputeNextRun(): 다음 실행 예정 시각 계산 (핸들러·편집기 공유)
- TriggerLabel(): 트리거 유형 표시명 반환

Handlers/ScheduleHandler.cs (171줄 신규):
- prefix="sched", 서브커맨드: new / edit 이름 / del 이름
- 목록: 트리거 라벨·시각·액션 아이콘·다음 실행 시각 표시
- Enter: 활성/비활성 토글 + 저장

Views/ScheduleEditorWindow.xaml (307줄 신규):
- 트리거 유형 4-세그먼트(매일/주중/매주/한번), 요일 7버튼, 날짜 입력
- 액션 2-세그먼트(앱 실행/알림), 앱 경로+찾아보기+인자, 알림 메시지
- 활성화 ToggleSwitch, 저장/취소 하단바

Views/ScheduleEditorWindow.xaml.cs (230줄 신규):
- OnLoaded에서 기존 항목 로드 (편집) 또는 기본값 초기화
- SetTriggerUi(): 세그먼트 색상·WeekDaysPanel/DatePanel 표시 전환
- WeekDay_Click/SetDaySelected(): 요일 다중 토글
- SetActionUi(): 앱경로 패널 / 알림 패널 전환
- BtnSave_Click(): HH:mm 파싱 + 날짜 검증 + ScheduleEntry 생성·수정 저장

App.xaml.cs:
- _schedulerService 필드 + Phase L5-6 등록 블록 추가
- schedulerService.Start() 호출

docs/LAUNCHER_ROADMAP.md:
- L5-6  완료 표시 + 구현 내용 상세 기록

빌드: 경고 0, 오류 0
This commit is contained in:
2026-04-04 13:07:12 +09:00
parent 2d3e5f6a72
commit e92800165d
8 changed files with 1049 additions and 1 deletions

View File

@@ -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 구현 순서 (권장)

View File

@@ -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);

View File

@@ -0,0 +1,171 @@
using System.IO;
using AxCopilot.Models;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// L5-6: 자동화 스케줄 핸들러. "sched" 프리픽스로 사용합니다.
///
/// 예: sched → 등록된 스케줄 목록
/// sched 이름 → 이름으로 필터
/// sched new → 새 스케줄 편집기 열기
/// sched edit 이름 → 기존 스케줄 편집
/// sched del 이름 → 스케줄 삭제
/// sched toggle 이름 → 활성/비활성 전환 (Enter)
/// </summary>
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<IEnumerable<LauncherItem>> 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<IEnumerable<LauncherItem>>(new[]
{
new LauncherItem("새 스케줄 만들기",
"편집기에서 트리거 시각과 실행 액션을 설정합니다",
null, "__new__", Symbol: "\uE710")
});
}
// "edit 이름"
if (cmd == "edit" && parts.Length > 1)
{
return Task.FromResult<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<LauncherItem>();
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<IEnumerable<LauncherItem>>(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;
}
}

View File

@@ -310,6 +310,61 @@ public class SessionApp
public int DelayMs { get; set; } = 0;
}
// ─── 자동화 스케줄러 ─────────────────────────────────────────────────────────────
/// <summary>
/// 자동화 스케줄 항목.
/// 지정 시각에 앱 실행 또는 알림을 자동으로 발생시킵니다.
/// </summary>
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;
// ─── 트리거 ───────────────────────────────────────────────────────────
/// <summary>실행 주기. daily | weekdays | weekly | once</summary>
[JsonPropertyName("triggerType")]
public string TriggerType { get; set; } = "daily";
/// <summary>실행 시각 (HH:mm 형식). 예: "09:00"</summary>
[JsonPropertyName("triggerTime")]
public string TriggerTime { get; set; } = "09:00";
/// <summary>weekly 트리거: 요일 목록. 0=일, 1=월 … 6=토</summary>
[JsonPropertyName("weekDays")]
public List<int> WeekDays { get; set; } = new();
/// <summary>once 트리거: 실행 날짜 (yyyy-MM-dd). 한 번만 실행 후 비활성화.</summary>
[JsonPropertyName("triggerDate")]
public string? TriggerDate { get; set; }
// ─── 액션 ─────────────────────────────────────────────────────────────
/// <summary>실행 동작 유형. app | notification</summary>
[JsonPropertyName("actionType")]
public string ActionType { get; set; } = "app";
/// <summary>앱 경로 또는 알림 메시지 본문</summary>
[JsonPropertyName("actionTarget")]
public string ActionTarget { get; set; } = "";
/// <summary>앱 실행 시 추가 인자</summary>
[JsonPropertyName("actionArgs")]
public string ActionArgs { get; set; } = "";
// ─── 상태 ─────────────────────────────────────────────────────────────
[JsonPropertyName("lastRun")]
public DateTime? LastRun { get; set; }
}
// ─── 잠금 해제 알림 설정 ───────────────────────────────────────────────────────
public class ReminderSettings

View File

@@ -118,6 +118,12 @@ public class AppSettings
[JsonPropertyName("appSessions")]
public List<AppSession> AppSessions { get; set; } = new();
/// <summary>
/// 자동화 스케줄 목록. SchedulerService가 백그라운드에서 트리거를 확인·실행합니다.
/// </summary>
[JsonPropertyName("schedules")]
public List<ScheduleEntry> Schedules { get; set; } = new();
[JsonPropertyName("llm")]
public LlmSettings Llm { get; set; } = new();
}

View File

@@ -0,0 +1,197 @@
using System.Diagnostics;
using System.IO;
using System.Windows;
using AxCopilot.Models;
namespace AxCopilot.Services;
/// <summary>
/// L5-6: 자동화 스케줄 백그라운드 서비스.
/// 30초 간격으로 활성 스케줄을 검사하여 해당 시각에 액션을 실행합니다.
/// </summary>
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}");
}
}
// ─── 유틸리티 (핸들러·편집기에서 공유) ──────────────────────────────
/// <summary>지정 스케줄의 다음 실행 예정 시각을 계산합니다.</summary>
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<DateTime, bool> dayFilter)
{
for (int i = 0; i <= 7; i++)
{
var candidate = now.Date.AddDays(i) + t;
if (candidate > now && dayFilter(candidate))
return candidate;
}
return null;
}
/// <summary>트리거 유형 표시 이름.</summary>
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<int> days) =>
days.Count == 0 ? "매주(요일 미지정)" :
"매주 " + string.Join("·", days.OrderBy(d => d).Select(d => DayShort[d]));
}

View File

@@ -0,0 +1,307 @@
<Window x:Class="AxCopilot.Views.ScheduleEditorWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="AX Commander — 스케줄 편집"
Width="520" Height="480"
MinWidth="440" MinHeight="400"
WindowStyle="None" AllowsTransparency="True"
Background="Transparent"
WindowStartupLocation="CenterScreen"
ResizeMode="NoResize"
ShowInTaskbar="False">
<Border Background="{DynamicResource LauncherBackground}" CornerRadius="12"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
Margin="6">
<Border.Effect>
<DropShadowEffect BlurRadius="22" ShadowDepth="4" Opacity="0.32" Color="Black" Direction="270"/>
</Border.Effect>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="44"/> <!-- 타이틀바 -->
<RowDefinition Height="*"/> <!-- 콘텐츠 -->
<RowDefinition Height="Auto"/> <!-- 하단 버튼 -->
</Grid.RowDefinitions>
<!-- ─── 타이틀바 ─────────────────────────────────────────────── -->
<Border Grid.Row="0" CornerRadius="12,12,0,0"
Background="{DynamicResource ItemBackground}"
MouseLeftButtonDown="TitleBar_MouseDown">
<Grid Margin="14,0,8,0">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="&#xE916;"
FontFamily="Segoe MDL2 Assets" FontSize="15"
Foreground="{DynamicResource AccentColor}"
VerticalAlignment="Center" Margin="0,1,10,0"/>
<TextBlock Text="스케줄 편집"
FontSize="13" FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"
VerticalAlignment="Center"/>
</StackPanel>
<Border HorizontalAlignment="Right" VerticalAlignment="Center"
CornerRadius="4" Padding="8,4" Cursor="Hand"
MouseLeftButtonUp="BtnClose_Click">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="Transparent"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#40C05050"/>
</Trigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock Text="&#xE711;" FontFamily="Segoe MDL2 Assets" FontSize="13"
Foreground="{DynamicResource SecondaryText}"/>
</Border>
</Grid>
</Border>
<!-- ─── 콘텐츠 ───────────────────────────────────────────────── -->
<ScrollViewer Grid.Row="1"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel Margin="18,14,18,10">
<!-- 스케줄 이름 -->
<TextBlock Text="스케줄 이름" FontSize="10" FontWeight="Medium"
Foreground="{DynamicResource SecondaryText}" Margin="0,0,0,4"/>
<TextBox x:Name="NameBox"
FontSize="13" FontWeight="SemiBold"
Foreground="{DynamicResource PrimaryText}"
Background="{DynamicResource LauncherBackground}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
Padding="8,6" Margin="0,0,0,14"/>
<!-- ── 트리거 유형 ── -->
<TextBlock Text="실행 주기" FontSize="10" FontWeight="Medium"
Foreground="{DynamicResource SecondaryText}" Margin="0,0,0,6"/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
<Border x:Name="BtnDaily" CornerRadius="4,0,0,4" Padding="14,5" Cursor="Hand" MouseLeftButtonUp="TriggerType_Click" Tag="daily">
<TextBlock x:Name="TxtDaily" Text="매일" FontSize="12"/>
</Border>
<Border x:Name="BtnWeekdays" CornerRadius="0" Padding="14,5" Cursor="Hand" MouseLeftButtonUp="TriggerType_Click" Tag="weekdays">
<TextBlock x:Name="TxtWeekdays" Text="주중(월~금)" FontSize="12"/>
</Border>
<Border x:Name="BtnWeekly" CornerRadius="0" Padding="14,5" Cursor="Hand" MouseLeftButtonUp="TriggerType_Click" Tag="weekly">
<TextBlock x:Name="TxtWeekly" Text="매주" FontSize="12"/>
</Border>
<Border x:Name="BtnOnce" CornerRadius="0,4,4,0" Padding="14,5" Cursor="Hand" MouseLeftButtonUp="TriggerType_Click" Tag="once">
<TextBlock x:Name="TxtOnce" Text="한번" FontSize="12"/>
</Border>
</StackPanel>
<!-- 실행 시각 -->
<Grid Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="실행 시각 (HH:mm)"
FontSize="10" FontWeight="Medium"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center" Margin="0,0,12,0" Width="130"/>
<TextBox Grid.Column="1" x:Name="TimeBox"
Text="09:00"
FontFamily="Cascadia Code, Consolas"
FontSize="13"
Foreground="{DynamicResource PrimaryText}"
Background="{DynamicResource LauncherBackground}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
Padding="8,5" MaxWidth="100" HorizontalAlignment="Left"/>
</Grid>
<!-- 요일 선택 (weekly일 때만 표시) -->
<StackPanel x:Name="WeekDaysPanel" Visibility="Collapsed" Margin="0,0,0,12">
<TextBlock Text="요일 선택" FontSize="10" FontWeight="Medium"
Foreground="{DynamicResource SecondaryText}" Margin="0,0,0,6"/>
<StackPanel Orientation="Horizontal">
<Border x:Name="BtnSun" Tag="0" CornerRadius="4" Width="38" Height="32" Margin="0,0,4,0" Cursor="Hand" MouseLeftButtonUp="WeekDay_Click">
<TextBlock Text="일" FontSize="12" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Border x:Name="BtnMon" Tag="1" CornerRadius="4" Width="38" Height="32" Margin="0,0,4,0" Cursor="Hand" MouseLeftButtonUp="WeekDay_Click">
<TextBlock Text="월" FontSize="12" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Border x:Name="BtnTue" Tag="2" CornerRadius="4" Width="38" Height="32" Margin="0,0,4,0" Cursor="Hand" MouseLeftButtonUp="WeekDay_Click">
<TextBlock Text="화" FontSize="12" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Border x:Name="BtnWed" Tag="3" CornerRadius="4" Width="38" Height="32" Margin="0,0,4,0" Cursor="Hand" MouseLeftButtonUp="WeekDay_Click">
<TextBlock Text="수" FontSize="12" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Border x:Name="BtnThu" Tag="4" CornerRadius="4" Width="38" Height="32" Margin="0,0,4,0" Cursor="Hand" MouseLeftButtonUp="WeekDay_Click">
<TextBlock Text="목" FontSize="12" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Border x:Name="BtnFri" Tag="5" CornerRadius="4" Width="38" Height="32" Margin="0,0,4,0" Cursor="Hand" MouseLeftButtonUp="WeekDay_Click">
<TextBlock Text="금" FontSize="12" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<Border x:Name="BtnSat" Tag="6" CornerRadius="4" Width="38" Height="32" Cursor="Hand" MouseLeftButtonUp="WeekDay_Click">
<TextBlock Text="토" FontSize="12" HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
</StackPanel>
</StackPanel>
<!-- 날짜 선택 (once일 때만 표시) -->
<Grid x:Name="DatePanel" Visibility="Collapsed" Margin="0,0,0,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="실행 날짜 (yyyy-MM-dd)"
FontSize="10" FontWeight="Medium"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center" Margin="0,0,12,0" Width="130"/>
<TextBox Grid.Column="1" x:Name="DateBox"
FontFamily="Cascadia Code, Consolas"
FontSize="13"
Foreground="{DynamicResource PrimaryText}"
Background="{DynamicResource LauncherBackground}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
Padding="8,5" MaxWidth="140" HorizontalAlignment="Left"/>
</Grid>
<!-- 구분선 -->
<Border Height="1" Background="{DynamicResource BorderColor}" Margin="0,4,0,14"/>
<!-- ── 액션 유형 ── -->
<TextBlock Text="실행 액션" FontSize="10" FontWeight="Medium"
Foreground="{DynamicResource SecondaryText}" Margin="0,0,0,6"/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,12">
<Border x:Name="BtnActionApp" CornerRadius="4,0,0,4" Padding="14,5" Cursor="Hand" MouseLeftButtonUp="ActionType_Click" Tag="app">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xECAA;" FontFamily="Segoe MDL2 Assets" FontSize="12"
VerticalAlignment="Center" Margin="0,0,5,0"/>
<TextBlock x:Name="TxtActionApp" Text="앱 실행" FontSize="12"/>
</StackPanel>
</Border>
<Border x:Name="BtnActionNotif" CornerRadius="0,4,4,0" Padding="14,5" Cursor="Hand" MouseLeftButtonUp="ActionType_Click" Tag="notification">
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xEA8F;" FontFamily="Segoe MDL2 Assets" FontSize="12"
VerticalAlignment="Center" Margin="0,0,5,0"/>
<TextBlock x:Name="TxtActionNotif" Text="알림 표시" FontSize="12"/>
</StackPanel>
</Border>
</StackPanel>
<!-- 앱 경로 (app 모드) -->
<StackPanel x:Name="AppPathPanel" Visibility="Visible">
<TextBlock Text="앱 경로" FontSize="10" FontWeight="Medium"
Foreground="{DynamicResource SecondaryText}" Margin="0,0,0,4"/>
<Grid Margin="0,0,0,10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0" x:Name="AppPathBox"
FontSize="11"
Foreground="{DynamicResource PrimaryText}"
Background="{DynamicResource LauncherBackground}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
Padding="8,5" Margin="0,0,6,0"/>
<Border Grid.Column="1" CornerRadius="4" Padding="10,5" Cursor="Hand"
MouseLeftButtonUp="BtnBrowseApp_Click">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="#18FFFFFF"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#28FFFFFF"/>
</Trigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock Text="찾아보기" FontSize="11"
Foreground="{DynamicResource SecondaryText}"/>
</Border>
</Grid>
<TextBlock Text="실행 인자 (선택)" FontSize="10" FontWeight="Medium"
Foreground="{DynamicResource SecondaryText}" Margin="0,0,0,4"/>
<TextBox x:Name="AppArgsBox"
FontSize="11"
Foreground="{DynamicResource SecondaryText}"
Background="{DynamicResource LauncherBackground}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
Padding="8,5"/>
</StackPanel>
<!-- 알림 메시지 (notification 모드) -->
<StackPanel x:Name="NotifPanel" Visibility="Collapsed">
<TextBlock Text="알림 메시지" FontSize="10" FontWeight="Medium"
Foreground="{DynamicResource SecondaryText}" Margin="0,0,0,4"/>
<TextBox x:Name="NotifMsgBox"
FontSize="12"
Foreground="{DynamicResource PrimaryText}"
Background="{DynamicResource LauncherBackground}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="1"
Padding="8,6"
AcceptsReturn="False"/>
</StackPanel>
</StackPanel>
</ScrollViewer>
<!-- ─── 하단 버튼 바 ─────────────────────────────────────────── -->
<Border Grid.Row="2" CornerRadius="0,0,12,12"
Background="{DynamicResource ItemBackground}"
BorderBrush="{DynamicResource BorderColor}" BorderThickness="0,1,0,0"
Padding="12,8">
<Grid>
<!-- 활성화 토글 -->
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="활성화" FontSize="11"
Foreground="{DynamicResource SecondaryText}"
VerticalAlignment="Center" Margin="0,0,8,0"/>
<Border x:Name="EnabledToggle"
Width="36" Height="20" CornerRadius="10"
Background="{DynamicResource AccentColor}"
Cursor="Hand" MouseLeftButtonUp="EnabledToggle_Click">
<Border x:Name="EnabledThumb"
Width="16" Height="16" CornerRadius="8"
Background="White"
HorizontalAlignment="Right"
Margin="0,0,1,0"/>
</Border>
</StackPanel>
<!-- 취소·저장 -->
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
<Border CornerRadius="4" Padding="14,5" Cursor="Hand" Margin="0,0,8,0"
MouseLeftButtonUp="BtnCancel_Click">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="#18FFFFFF"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#28FFFFFF"/>
</Trigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock Text="취소" FontSize="12"
Foreground="{DynamicResource SecondaryText}"/>
</Border>
<Border CornerRadius="4" Padding="16,5" Cursor="Hand"
Background="{DynamicResource AccentColor}"
MouseLeftButtonUp="BtnSave_Click">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Background" Value="{DynamicResource AccentColor}"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Opacity" Value="0.85"/>
</Trigger>
</Style.Triggers>
</Style>
</Border.Style>
<StackPanel Orientation="Horizontal">
<TextBlock Text="&#xE74E;" FontFamily="Segoe MDL2 Assets" FontSize="12"
Foreground="White" VerticalAlignment="Center" Margin="0,0,6,0"/>
<TextBlock Text="저장" FontSize="12" FontWeight="SemiBold" Foreground="White"/>
</StackPanel>
</Border>
</StackPanel>
</Grid>
</Border>
</Grid>
</Border>
</Window>

View File

@@ -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<int> GetSelectedDays()
{
var accent = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
var list = new List<int>();
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<int>();
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();
}