https://github.com/applejin0105/Todo_Public
GitHub - applejin0105/Todo_Public
Contribute to applejin0105/Todo_Public development by creating an account on GitHub.
github.com
구조

1. XAML 구성
1.1 MainWindow
<mah:MetroWindow
x:Class="Todo.Views.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:Todo"
xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls"
xmlns:tb="http://www.hardcodet.net/taskbar"
xmlns:viewmodels="clr-namespace:Todo.ViewModels"
xmlns:models="clr-namespace:Todo.Models"
xmlns:converters="clr-namespace:Todo.Converters"
xmlns:helpers="clr-namespace:Todo.Helpers"
mc:Ignorable="d"
Title="ToDo" Height="700" Width="900"
FontFamily="/Resources/#D2Coding"
WindowTitleBrush="{DynamicResource SecondaryBackgroundColor}"
GlowBrush="Transparent">
<Window.Resources>
<converters:UtcToLocalTimeConverter x:Key="UtcDateConverter"/>
</Window.Resources>
<Grid Margin="10" Background="{DynamicResource PrimaryBackgroundColor}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid Grid.Row="0" Margin="0,0,0,10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<WrapPanel Grid.Row="0" Grid.Column="0">
<TextBox Text="{Binding NewItemTitle}" ToolTip="Title" MinWidth="150" Margin="5"/>
<ComboBox x:Name="PlatformComboBox" ToolTip="Platform" MinWidth="100" Margin="5" ItemsSource="{Binding AllPlatforms}" DisplayMemberPath="Name"/>
<DatePicker x:Name="StartDatePicker" ToolTip="Start Date" MinWidth="120" Margin="5" mah:TextBoxHelper.Watermark="yyyy/MM/dd"/>
<TextBox x:Name="StartTimeTextBox" ToolTip="Start Time" Width="60" Margin="0,5,5,5" mah:TextBoxHelper.Watermark="HH:mm"/>
<DatePicker x:Name="DueDatePicker" ToolTip="Deadline" MinWidth="120" Margin="5" mah:TextBoxHelper.Watermark="yyyy/MM/dd"/>
<TextBox x:Name="DueTimeTextBox" ToolTip="Closing Time" Width="60" Margin="0,5,5,5" mah:TextBoxHelper.Watermark="HH:mm"/>
</WrapPanel>
<Button Grid.Row="0" Grid.Column="1" Content="Add" Width="80" Margin="5" Click="AddButton_Click" HorizontalAlignment="Right"/>
<Grid Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="2" Margin="0,5,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Button Grid.Column="0" Content="Sort by Deadline" Margin="5" Click="SortByDueDate_Click"/>
<Button Grid.Column="1" Content="Sort by Start Date" Margin="5" Click="SortByStartDate_Click"/>
<Button Grid.Column="2" Content="Platform Management" Margin="5" Click="PlatformManagementButton_Click"/>
<Button Grid.Column="4" Content="Settings" Margin="5" Click="SettingsButton_Click"/>
</Grid>
</Grid>
<mah:MetroTabControl x:Name="MainTabControl" Grid.Row="1" Style="{DynamicResource MahApps.Styles.TabControl.Animated}">
<mah:MetroTabControl.Resources>
<Style x:Key="TabHeaderTextStyle" TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource MahApps.Brushes.Gray5}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsSelected, RelativeSource={RelativeSource AncestorType=mah:MetroTabItem}}" Value="True">
<Setter Property="Foreground" Value="{DynamicResource MahApps.Brushes.Accent}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</mah:MetroTabControl.Resources>
<mah:MetroTabItem>
<mah:MetroTabItem.Header>
<TextBlock Style="{StaticResource TabHeaderTextStyle}">
<TextBlock.Text>
<MultiBinding StringFormat="Not Started ({0})">
<Binding Path="NotStartedItems.Count" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</mah:MetroTabItem.Header>
<ListView ItemsSource="{Binding NotStartedItems}" HorizontalContentAlignment="Stretch">
<ListView.ItemTemplate>
<DataTemplate>
<Grid Background="Transparent">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="100"/>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="{Binding Title}" FontWeight="Bold" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding Platform.Name}" VerticalAlignment="Center" HorizontalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="{Binding StartDate, Converter={StaticResource UtcDateConverter}}" VerticalAlignment="Center" HorizontalAlignment="Center"/>
<TextBlock Grid.Column="3" Text="{Binding DueDate, Converter={StaticResource UtcDateConverter}}" VerticalAlignment="Center" HorizontalAlignment="Center"/>
<ComboBox Grid.Column="4" ItemsSource="{Binding Source={helpers:EnumBindingSource EnumType={x:Type models:WorkStatus}}}" SelectedValue="{Binding Status}" SelectionChanged="StatusComboBox_SelectionChanged" VerticalAlignment="Center"/>
<StackPanel Grid.Column="5" Orientation="Horizontal" VerticalAlignment="Center">
<Button Content="Edit" Click="EditButton_Click" Margin="0,0,5,0" Focusable="False"/>
<Button Content="Delete" Click="DeleteButton_Click" Focusable="False"/>
</StackPanel>
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</mah:MetroTabItem>
<mah:MetroTabItem>
<mah:MetroTabItem.Header>
<TextBlock Style="{StaticResource TabHeaderTextStyle}">
<TextBlock.Text>
<MultiBinding StringFormat="In Progress ({0})">
<Binding Path="InProgressItems.Count" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</mah:MetroTabItem.Header>
<ListView ItemsSource="{Binding InProgressItems}" HorizontalContentAlignment="Stretch">
<ListView.ItemTemplate>
<DataTemplate>
<Grid Background="Transparent">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="100"/>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="{Binding Title}" FontWeight="Bold" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding Platform.Name}" VerticalAlignment="Center" HorizontalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="{Binding StartDate, Converter={StaticResource UtcDateConverter}}" VerticalAlignment="Center" HorizontalAlignment="Center"/>
<TextBlock Grid.Column="3" Text="{Binding DueDate, Converter={StaticResource UtcDateConverter}}" VerticalAlignment="Center" HorizontalAlignment="Center"/>
<ComboBox Grid.Column="4" ItemsSource="{Binding Source={helpers:EnumBindingSource EnumType={x:Type models:WorkStatus}}}" SelectedValue="{Binding Status}" SelectionChanged="StatusComboBox_SelectionChanged" VerticalAlignment="Center"/>
<StackPanel Grid.Column="5" Orientation="Horizontal" VerticalAlignment="Center" Margin="10,0,0,0">
<Button Content="Edit" Click="EditButton_Click" Margin="0,0,5,0" Focusable="False"/>
<Button Content="Delete" Click="DeleteButton_Click" Focusable="False"/>
</StackPanel>
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</mah:MetroTabItem>
<mah:MetroTabItem>
<mah:MetroTabItem.Header>
<TextBlock Style="{StaticResource TabHeaderTextStyle}">
<TextBlock.Text>
<MultiBinding StringFormat="Completed ({0})">
<Binding Path="CompletedItems.Count" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</mah:MetroTabItem.Header>
<ListView ItemsSource="{Binding CompletedItems}" HorizontalContentAlignment="Stretch">
<ListView.ItemTemplate>
<DataTemplate>
<Grid Background="Transparent">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="100"/>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="120"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="{Binding Title}" FontWeight="Bold" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding Platform.Name}" VerticalAlignment="Center" HorizontalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="{Binding StartDate, Converter={StaticResource UtcDateConverter}}" VerticalAlignment="Center" HorizontalAlignment="Center"/>
<TextBlock Grid.Column="3" Text="{Binding DueDate, Converter={StaticResource UtcDateConverter}}" VerticalAlignment="Center" HorizontalAlignment="Center"/>
<ComboBox Grid.Column="4" ItemsSource="{Binding Source={helpers:EnumBindingSource EnumType={x:Type models:WorkStatus}}}" SelectedValue="{Binding Status}" SelectionChanged="StatusComboBox_SelectionChanged" VerticalAlignment="Center"/>
<StackPanel Grid.Column="5" Orientation="Horizontal" VerticalAlignment="Center">
<Button Content="Edit" Click="EditButton_Click" Margin="0,0,5,0" Focusable="False"/>
<Button Content="Delete" Click="DeleteButton_Click" Focusable="False"/>
</StackPanel>
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</mah:MetroTabItem>
</mah:MetroTabControl>
<tb:TaskbarIcon
x:Name="MyNotifyIcon"
IconSource="/Resources/app_icon.ico"
ToolTipText="Todo List"
TrayMouseDoubleClick="MyNotifyIcon_TrayMouseDoubleClick">
<tb:TaskbarIcon.ContextMenu>
<ContextMenu>
<MenuItem Header="Open" Click="ShowWindow_Click" />
<MenuItem Header="Exit" Click="ExitApplication_Click" />
</ContextMenu>
</tb:TaskbarIcon.ContextMenu>
</tb:TaskbarIcon>
</Grid>
</mah:MetroWindow>
// Todo 애플리케이션의 뷰(UI) 관련 클래스를 포함하는 네임스페이스
namespace Todo.Views
{
// MahApps.Metro의 MetroWindow를 상속받는 주 창 클래스
public partial class MainWindow : MetroWindow
{
// 뷰의 데이터와 비즈니스 로직을 처리하는 MainViewModel의 읽기 전용 인스턴스
private readonly MainViewModel _viewModel;
// 애플리케이션 설정을 관리하는 SettingsService의 읽기 전용 인스턴스
private readonly SettingsService _settingsService;
// 사용자가 명시적으로 종료를 원하는지 여부를 나타내는 플래그 (트레이 아이콘 메뉴의 '종료' 등)
private bool _isExplicitExit = false;
// 외부에서 MainViewModel 인스턴스를 가져갈 수 있도록 하는 메서드
public MainViewModel GetViewModel() => _viewModel;
// MainWindow의 생성자. 의존성 주입을 통해 viewModel과 settingsService를 받음
public MainWindow(MainViewModel viewModel, SettingsService settingsService)
{
// XAML에 정의된 UI 컴포넌트들을 초기화
InitializeComponent();
// 전달받은 viewModel 인스턴스를 클래스 필드에 할당
_viewModel = viewModel;
// 전달받은 settingsService 인스턴스를 클래스 필드에 할당
_settingsService = settingsService;
// 이 창의 데이터 컨텍스트를 viewModel로 설정. 이를 통해 XAML에서의 데이터 바인딩이 가능해짐
DataContext = _viewModel;
// 설정 파일에서 창 '항상 위에 표시' 여부를 불러와 적용
this.Topmost = _settingsService.Settings.IsAlwaysOnTop;
// 설정 파일에서 마지막 창 위치(Top)를 불러와 적용
this.Top = _settingsService.Settings.WindowTop;
// 설정 파일에서 마지막 창 위치(Left)를 불러와 적용
this.Left = _settingsService.Settings.WindowLeft;
// 설정 파일에서 마지막 창 높이를 불러와 적용
this.Height = _settingsService.Settings.WindowHeight;
// 설정 파일에서 마지막 창 너비를 불러와 적용
this.Width = _settingsService.Settings.WindowWidth;
// 설정 파일에서 창 '항상 위에 표시' 여부를 다시 적용 (앞선 코드와 중복)
this.Topmost = _settingsService.Settings.IsAlwaysOnTop;
// 창이 완전히 로드되었을 때 실행될 이벤트 핸들러 등록
Loaded += MainWindow_Loaded;
// 창이 닫히려고 할 때 실행될 이벤트 핸들러 등록
Closing += MainWindow_Closing;
}
// 창 로드 완료 시 발생하는 이벤트 핸들러
private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
// 뷰모델을 통해 비동기적으로 할 일 항목들을 데이터베이스에서 로드
await _viewModel.LoadItemsAsync();
// 마지막으로 선택했던 탭의 인덱스를 설정에서 불러와 적용
MainTabControl.SelectedIndex = _settingsService.Settings.LastSelectedTabIndex;
// 프로그램 시작 시 요약 알림을 보내는 메서드 호출
_viewModel.SendStartupSummaryNotification();
}
// 창이 닫힐 때 발생하는 이벤트 핸들러 (X 버튼 클릭 등)
private void MainWindow_Closing(object? sender, System.ComponentModel.CancelEventArgs e)
{
// 현재 창의 너비를 설정 객체에 저장
_settingsService.Settings.WindowWidth = this.Width;
// 현재 창의 Y 위치를 설정 객체에 저장
_settingsService.Settings.WindowTop = this.Top;
// 현재 창의 X 위치를 설정 객체에 저장
_settingsService.Settings.WindowLeft = this.Left;
// 현재 창의 높이를 설정 객체에 저장
_settingsService.Settings.WindowHeight = this.Height;
// 현재 창의 너비를 설정 객체에 다시 저장 (앞선 코드와 중복)
_settingsService.Settings.WindowWidth = this.Width;
// 변경된 설정들을 파일에 저장
_settingsService.Save();
// 현재 선택된 탭의 인덱스를 설정 객체에 저장
_settingsService.Settings.LastSelectedTabIndex = MainTabControl.SelectedIndex;
// 변경된 설정을 파일에 저장
_settingsService.Save();
}
// '추가' 버튼 클릭 이벤트 핸들러
private async void AddButton_Click(object sender, RoutedEventArgs e)
{
// 콤보박스에서 선택된 플랫폼 정보를 가져옴
var selectedPlatform = PlatformComboBox.SelectedItem as Platform;
// 날짜 선택기와 시간 텍스트박스의 값을 조합하여 시작 날짜와 시간 생성
var startDate = CombineDateAndTime(StartDatePicker.SelectedDate, StartTimeTextBox.Text);
// 날짜 선택기와 시간 텍스트박스의 값을 조합하여 마감 날짜와 시간 생성
var dueDate = CombineDateAndTime(DueDatePicker.SelectedDate, DueTimeTextBox.Text);
// 뷰모델을 통해 새 할 일 항목을 비동기적으로 추가
await _viewModel.AddItemAsync(selectedPlatform, startDate, dueDate);
// 입력 필드들 초기화
StartDatePicker.SelectedDate = null;
StartTimeTextBox.Clear();
DueDatePicker.SelectedDate = null;
DueTimeTextBox.Clear();
PlatformComboBox.SelectedItem = null;
}
// nullable DateTime과 시간 문자열을 결합하여 하나의 nullable DateTime으로 반환하는 헬퍼 메서드
private DateTime? CombineDateAndTime(DateTime? date, string timeString)
{
// 날짜 값이 없으면 null 반환
if (date is null)
{
return null;
}
// 시간 문자열이 비어있으면 날짜의 0시 0분 0초로 설정하여 반환
if (string.IsNullOrWhiteSpace(timeString))
{
return date.Value.Date;
}
// 시간 문자열을 TimeSpan으로 파싱 시도
if (TimeSpan.TryParse(timeString, out var time))
{
// 파싱 성공 시, 날짜와 시간을 합쳐서 반환
return date.Value.Date + time;
}
// 파싱 실패 시, 날짜의 0시 0분 0초로 설정하여 반환
return date.Value.Date;
}
// '마감일 순 정렬' 메뉴 클릭 이벤트 핸들러
private void SortByDueDate_Click(object sender, RoutedEventArgs e)
{
// 뷰모델의 정렬 메서드를 호출. 마감일(DueDate)을 기준으로 정렬하며, null 값은 가장 큰 값으로 취급하여 뒤로 보냄
_viewModel.SortAllLists(item => item.DueDate ?? DateTime.MaxValue);
}
// '시작일 순 정렬' 메뉴 클릭 이벤트 핸들러
private void SortByStartDate_Click(object sender, RoutedEventArgs e)
{
// 뷰모델의 정렬 메서드를 호출. 시작일(StartDate)을 기준으로 정렬하며, null 값은 가장 큰 값으로 취급하여 뒤로 보냄
_viewModel.SortAllLists(item => item.StartDate ?? DateTime.MaxValue);
}
// 리스트 항목의 '삭제' 버튼 클릭 이벤트 핸들러
private async void DeleteButton_Click(object sender, RoutedEventArgs e)
{
// 이벤트 발생 소스(sender)가 Button이고, 그 버튼의 DataContext가 TodoItem 객체인지 확인
if (sender is Button button && button.DataContext is TodoItem item)
{
// 뷰모델을 통해 해당 할 일 항목을 비동기적으로 삭제
await _viewModel.DeleteItemAsync(item);
}
}
// 리스트 항목의 '상태' 콤보박스 선택 변경 이벤트 핸들러
private async void StatusComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
// 이벤트 발생 소스(sender)가 ComboBox이고, 그 콤보박스의 DataContext가 TodoItem 객체인지 확인
if (sender is ComboBox comboBox && comboBox.DataContext is TodoItem item)
{
// 콤보박스가 UI에 로드되었고, 실제로 선택된 항목이 변경되었을 때만 실행 (초기 로드 시 불필요한 업데이트 방지)
if (comboBox.IsLoaded && e.AddedItems.Count > 0)
{
// 뷰모델을 통해 변경된 항목의 상태를 비동기적으로 업데이트
await _viewModel.UpdateItemAsync(item);
}
}
}
// '설정' 버튼 클릭 이벤트 핸들러
private void SettingsButton_Click(object sender, RoutedEventArgs e)
{
// 의존성 주입 컨테이너(AppHost)를 통해 필요한 서비스들을 가져옴
var settingsService = App.AppHost!.Services.GetRequiredService<SettingsService>();
var kakaoService = App.AppHost!.Services.GetRequiredService<KakaoService>();
var viewModel = App.AppHost!.Services.GetRequiredService<MainViewModel>();
var scheduler = App.AppHost!.Services.GetRequiredService<NotificationScheduler>();
// 필요한 서비스들을 전달하여 설정 창 인스턴스를 생성
var settingsWindow = new SettingsWindow(settingsService, kakaoService, viewModel, scheduler)
{
// 이 창(MainWindow)을 설정 창의 부모 창으로 지정
Owner = this
};
// 설정 창을 모달(Modal) 대화상자로 염. 이 창이 닫히기 전까지 부모 창은 조작 불가
settingsWindow.ShowDialog();
}
// '플랫폼 관리' 버튼 클릭 이벤트 핸들러
private async void PlatformManagementButton_Click(object sender, RoutedEventArgs e)
{
// DBContext 같은 일회성 서비스를 사용하기 위해 새로운 서비스 스코프 생성
using var scope = App.AppHost!.Services.CreateScope();
// 생성된 스코프에서 AppDbContext 인스턴스를 가져옴
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// DBContext를 전달하여 플랫폼 관리 창 인스턴스 생성
var managementWindow = new PlatformManagementWindow(dbContext)
{
// 이 창(MainWindow)을 플랫폼 관리 창의 부모 창으로 지정
Owner = this
};
// 플랫폼 관리 창을 모달 대화상자로 염
managementWindow.ShowDialog();
// 플랫폼 관리 창이 닫힌 후, 변경사항을 반영하기 위해 뷰모델의 플랫폼 목록을 다시 로드
await _viewModel.ReloadPlatformsAsync();
}
// '플랫폼' 콤보박스가 키보드 포커스를 받았을 때의 이벤트 핸들러
private void PlatformComboBox_GotKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e)
{
// 이벤트 발생 소스가 ComboBox인지 확인
if (sender is ComboBox comboBox)
{
// 콤보박스의 드롭다운 목록을 자동으로 열어줌
comboBox.IsDropDownOpen = true;
}
}
// 새 항목 입력 관련 컨트롤에서 키를 눌렀을 때의 이벤트 핸들러
private void NewItem_KeyDown(object sender, KeyEventArgs e)
{
// 눌린 키가 Enter 키인지 확인
if (e.Key == Key.Enter)
{
// '추가' 버튼을 클릭한 것과 동일한 로직을 실행
AddButton_Click(sender, e);
}
}
// 리스트 항목의 '수정' 버튼 클릭 이벤트 핸들러
private void EditButton_Click(object sender, RoutedEventArgs e)
{
// 이벤트 발생 소스가 Button이고, 그 버튼의 DataContext가 TodoItem 객체인지 확인
if (sender is Button button && button.DataContext is TodoItem item)
{
// DBContext를 사용하기 위해 새로운 서비스 스코프 생성
using var scope = App.AppHost!.Services.CreateScope();
// 생성된 스코프에서 AppDbContext 인스턴스를 가져옴
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// 수정할 항목, DBContext, MainViewModel을 전달하여 수정 창 인스턴스 생성
var editWindow = new EditTaskWindow(item, dbContext, _viewModel) { Owner = this };
// 수정 창을 모달 대화상자로 염
editWindow.ShowDialog();
}
}
// 창이 닫히기 직전에 호출되는 메서드 (재정의)
protected override void OnClosing(CancelEventArgs e)
{
// 부모 클래스의 OnClosing 로직을 먼저 호출
base.OnClosing(e);
// 명시적 종료 플래그(_isExplicitExit)가 false일 경우 (예: 'X' 버튼 클릭)
if (!_isExplicitExit)
{
// 창 닫기 이벤트를 취소하여 프로그램이 종료되지 않도록 함
e.Cancel = true;
// 창을 숨겨서 시스템 트레이에 아이콘만 남도록 함
this.Hide();
}
}
// 시스템 트레이 아이콘을 더블 클릭했을 때의 이벤트 핸들러
private void MyNotifyIcon_TrayMouseDoubleClick(object sender, RoutedEventArgs e)
{
// 숨겨진 창을 다시 화면에 표시
this.Show();
// 창의 상태를 최소화/최대화가 아닌 보통 상태로 복원
this.WindowState = WindowState.Normal;
}
// 트레이 아이콘 우클릭 메뉴의 '열기'를 클릭했을 때의 이벤트 핸들러
private void ShowWindow_Click(object sender, RoutedEventArgs e)
{
// 숨겨진 창을 다시 화면에 표시
this.Show();
// 창의 상태를 보통 상태로 복원
this.WindowState = WindowState.Normal;
}
// 트레이 아이콘 우클릭 메뉴의 '종료'를 클릭했을 때의 이벤트 핸들러
private void ExitApplication_Click(object sender, RoutedEventArgs e)
{
// 명시적 종료 플래그를 true로 설정
_isExplicitExit = true;
// 창 닫기를 시도. OnClosing 이벤트가 호출되지만, _isExplicitExit가 true이므로 프로그램이 완전히 종료됨
this.Close();
}
}
}
전역 변수 (필드)
- _viewModel: MainViewModel 타입. 뷰의 데이터와 로직을 관리하는 뷰모델.
- _settingsService: SettingsService 타입. 애플리케이션 설정을 로드하고 저장하는 서비스.
- _isExplicitExit: bool 타입. 사용자가 트레이 아이콘 메뉴 등을 통해 명시적으로 앱 종료를 선택했는지 여부를 나타내는 플래그.
함수 (메서드)
| 함수이름(파라미터) | 기능 |
| GetViewModel() |
_viewModel 인스턴스를 반환.
|
| MainWindow(MainViewModel viewModel, SettingsService settingsService) |
클래스 생성자. 의존성을 주입받고 창 상태 복원, 이벤트 핸들러 등록 등의 초기화를 수행.
|
| MainWindow_Loaded(object sender, RoutedEventArgs e) |
창 로드가 완료되면 할 일 목록을 비동기적으로 로드하고 마지막 선택 탭으로 이동.
|
| MainWindow_Closing(object? sender, System.ComponentModel.CancelEventArgs e) |
창이 닫힐 때 현재 창의 위치, 크기 및 마지막으로 선택된 탭 인덱스를 설정에 저장.
|
| AddButton_Click(object sender, RoutedEventArgs e) |
UI에서 입력된 정보로 새로운 할 일 항목을 생성하고 뷰모델에 추가.
|
| CombineDateAndTime(DateTime? date, string timeString) |
날짜(DateTime?)와 시간(string)을 조합하여 하나의 DateTime? 객체로 반환.
|
| SortByDueDate_Click(object sender, RoutedEventArgs e) |
마감일을 기준으로 할 일 목록을 정렬.
|
| SortByStartDate_Click(object sender, RoutedEventArgs e) |
시작일을 기준으로 할 일 목록을 정렬.
|
| DeleteButton_Click(object sender, RoutedEventArgs e) |
선택된 할 일 항목을 뷰모델을 통해 삭제.
|
| StatusComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) |
할 일 항목의 상태가 변경되면 뷰모델을 통해 업데이트.
|
| SettingsButton_Click(object sender, RoutedEventArgs e) |
설정 창(SettingsWindow)을 열람.
|
| PlatformManagementButton_Click(object sender, RoutedEventArgs e) |
플랫폼 관리 창(PlatformManagementWindow)을 열고, 창이 닫히면 플랫폼 목록을 새로고침.
|
| PlatformComboBox_GotKeyboardFocus(object sender, KeyboardFocusChangedEventArgs e) |
플랫폼 콤보박스가 포커스를 받으면 드롭다운 목록을 자동으로 표시.
|
| NewItem_KeyDown(object sender, KeyEventArgs e) |
새 항목 입력 필드에서 Enter 키를 누르면 항목 추가 로직을 실행.
|
| EditButton_Click(object sender, RoutedEventArgs e) |
선택된 할 일 항목의 수정 창(EditTaskWindow)을 열람.
|
| OnClosing(CancelEventArgs e) |
창 닫기 동작을 재정의. 명시적 종료가 아니면 앱을 종료하는 대신 창을 숨김.
|
| MyNotifyIcon_TrayMouseDoubleClick(object sender, RoutedEventArgs e) |
시스템 트레이 아이콘을 더블 클릭하면 숨겨진 창을 다시 표시.
|
| ShowWindow_Click(object sender, RoutedEventArgs e) |
트레이 아이콘 메뉴 '열기' 클릭 시 숨겨진 창을 다시 표시.
|
| ExitApplication_Click(object sender, RoutedEventArgs e) |
트레이 아이콘 메뉴 '종료' 클릭 시 앱을 완전히 종료.
|
1.2 EditTaskWindow
<mah:MetroWindow x:Class="Todo.Views.EditTaskWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls"
xmlns:models="clr-namespace:Todo.Models"
xmlns:helpers="clr-namespace:Todo.Helpers"
mc:Ignorable="d"
Title="Task Edit" Height="450" Width="400"
WindowStartupLocation="CenterOwner"
FontFamily="/Resources/#D2Coding"
WindowTitleBrush="{DynamicResource SecondaryBackgroundColor}"
GlowBrush="Transparent"
PreviewKeyDown="MetroWindow_PreviewKeyDown">
<StackPanel Margin="20">
<TextBlock Text="Title"/>
<TextBox Margin="0,5,0,10" Text="{Binding EditingItem.Title}"/>
<TextBlock Text="Platform"/>
<ComboBox Margin="0,5,0,10"
ItemsSource="{Binding AllPlatforms}"
DisplayMemberPath="Name"
SelectedItem="{Binding EditingItem.Platform}"/>
<TextBlock Text="Start Date / Start Time"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<DatePicker Grid.Column="0" Margin="0,5,5,10" SelectedDate="{Binding EditingItem.StartDate}"/>
<TextBox Grid.Column="1" Width="80" Margin="0,5,0,10" Text="{Binding StartTime, UpdateSourceTrigger=PropertyChanged}"/>
</Grid>
<TextBlock Text="Deadline / Closing Time"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<DatePicker Grid.Column="0" Margin="0,5,5,10" SelectedDate="{Binding EditingItem.DueDate}"/>
<TextBox Grid.Column="1" Width="80" Margin="0,5,0,10" Text="{Binding DueTime, UpdateSourceTrigger=PropertyChanged}"/>
</Grid>
<TextBlock Text="Status"/>
<ComboBox Margin="0,5,0,10"
ItemsSource="{Binding Source={helpers:EnumBindingSource EnumType={x:Type models:WorkStatus}}}"
SelectedItem="{Binding EditingItem.Status}"/>
<Button Content="Save" Click="SaveButton_Click" Margin="0,20,0,0"/>
</StackPanel>
</mah:MetroWindow>
// Todo 애플리케이션의 뷰(UI) 관련 클래스를 포함하는 네임스페이스
namespace Todo.Views
{
// 필요한 네임스페이스 using 선언
using Todo.Data;
using Todo.Models;
using Todo.ViewModels;
// 할 일 항목 수정을 위한 MetroWindow 창 클래스
public partial class EditTaskWindow : MetroWindow
{
// 현재 수정 중인 TodoItem 객체. XAML에서 바인딩하기 위해 public으로 선언
public TodoItem EditingItem { get; }
// 데이터베이스 컨텍스트의 읽기 전용 인스턴스. 데이터베이스 변경사항 저장을 위해 사용됨
private readonly AppDbContext _context;
// MainViewModel의 참조. 플랫폼 목록과 같은 공유 데이터에 접근하기 위해 사용
private readonly MainViewModel _mainViewModel;
// 시작 시간을 문자열로 바인딩하기 위한 속성
public string StartTime { get; set; }
// 마감 시간을 문자열로 바인딩하기 위한 속성
public string DueTime { get; set; }
// 플랫폼 목록을 바인딩하기 위한 속성. MainViewModel의 목록을 그대로 사용
public ObservableCollection<Platform> AllPlatforms => _mainViewModel.AllPlatforms;
// EditTaskWindow의 생성자. 수정할 항목(item), DB 컨텍스트, MainViewModel을 전달받음
public EditTaskWindow(TodoItem item, AppDbContext context, MainViewModel mainViewModel)
{
// XAML에 정의된 UI 컴포넌트들을 초기화
InitializeComponent();
// 전달받은 item을 EditingItem 속성에 할당
EditingItem = item;
// 전달받은 context를 _context 필드에 할당
_context = context;
// 전달받은 mainViewModel을 _mainViewModel 필드에 할당
_mainViewModel = mainViewModel;
// 기존 항목의 시작 시간을 "HH:mm" 형식의 문자열로 변환하여 StartTime 속성에 할당
// DB에는 UTC 시간으로 저장되어 있으므로, 표시를 위해 로컬 시간으로 변환
StartTime = EditingItem.StartDate?.ToLocalTime().ToString("HH:mm") ?? "";
// 기존 항목의 마감 시간을 "HH:mm" 형식의 문자열로 변환하여 DueTime 속성에 할당
DueTime = EditingItem.DueDate?.ToLocalTime().ToString("HH:mm") ?? "";
// 이 창의 데이터 컨텍스트를 자기 자신(this)으로 설정. XAML에서 이 클래스의 속성들을 바인딩할 수 있게 됨
DataContext = this;
}
// '저장' 버튼 클릭 이벤트 핸들러
private async void SaveButton_Click(object sender, RoutedEventArgs e)
{
// UI에서 수정된 시간(StartTime 문자열)을 다시 날짜와 조합하고, DB 저장을 위해 UTC로 변환
EditingItem.StartDate = CombineDateAndTime(EditingItem.StartDate, StartTime)?.ToUniversalTime();
// UI에서 수정된 시간(DueTime 문자열)을 다시 날짜와 조합하고, DB 저장을 위해 UTC로 변환
EditingItem.DueDate = CombineDateAndTime(EditingItem.DueDate, DueTime)?.ToUniversalTime();
// DB Context를 통해 변경된 내용을 데이터베이스에 비동기적으로 저장
await _context.SaveChangesAsync();
// 이 대화상자의 결과를 true로 설정하여, 부모 창에서 저장이 성공했음을 알림
this.DialogResult = true;
// 창을 닫음
this.Close();
}
// nullable DateTime과 시간 문자열을 결합하여 하나의 nullable DateTime으로 반환하는 헬퍼 메서드
private DateTime? CombineDateAndTime(DateTime? date, string timeString)
{
// 날짜 값이 없으면 null 반환
if (date is null) return null;
// 시간 문자열이 비어있으면 날짜의 0시 0분 0초로 설정하여 반환
if (string.IsNullOrWhiteSpace(timeString)) return date.Value.Date;
// 시간 문자열을 TimeSpan으로 파싱 시도
if (TimeSpan.TryParse(timeString, out var time))
{
// 파싱 성공 시, 날짜와 시간을 합쳐서 반환
return date.Value.Date + time;
}
// 파싱 실패 시, 날짜의 0시 0분 0초로 설정하여 반환
return date.Value.Date;
}
// 창 전체에서 키 입력 이벤트를 미리 감지하는 핸들러
private void MetroWindow_PreviewKeyDown(object sender, KeyEventArgs e)
{
// 눌린 키가 Escape 키인지 확인
if (e.Key == Key.Escape)
{
// '저장' 버튼을 클릭한 것과 동일한 로직을 실행 (저장 후 닫기)
SaveButton_Click(sender, e);
}
}
}
}
전역 변수 (필드 및 속성)
- EditingItem (TodoItem): 현재 창에서 수정하고 있는 할 일 항목 객체.
- _context (AppDbContext): 데이터베이스 작업을 위한 Entity Framework 컨텍스트.
- _mainViewModel (MainViewModel): 플랫폼 목록과 같은 데이터를 공유하기 위한 부모 뷰모델 참조.
- StartTime (string): 시작 시간을 UI 텍스트박스와 바인딩하기 위한 문자열 속성.
- DueTime (string): 마감 시간을 UI 텍스트박스와 바인딩하기 위한 문자열 속성.
- AllPlatforms (ObservableCollection<Platform>): 플랫폼 선택 콤보박스에 바인딩되는 전체 플랫폼 목록. _mainViewModel에서 가져옴.
함수 (메서드)
| 함수이름(파라미터) | 기능 |
| EditTaskWindow(TodoItem item, AppDbContext context, MainViewModel mainViewModel) |
클래스 생성자. 수정할 항목과 필요한 서비스들을 주입받아 창을 초기화.
|
| SaveButton_Click(object sender, RoutedEventArgs e) |
UI에서 수정된 내용을 EditingItem 객체에 반영하고 데이터베이스에 저장한 후 창을 닫음.
|
| CombineDateAndTime(DateTime? date, string timeString) |
날짜(DateTime)와 시간(string)을 조합하여 하나의 DateTime 객체로 반환하는 헬퍼 함수.
|
| MetroWindow_PreviewKeyDown(object sender, KeyEventArgs e) |
창 내에서 ESC 키를 누르면 저장 후 닫기 기능을 실행.
|
1.3 PlatformManagementWindow
<mah:MetroWindow
x:Class="Todo.Views.PlatformManagementWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:views="clr-namespace:Todo.Views"
xmlns:viewmodels="clr-namespace:Todo.ViewModels"
xmlns:converters="clr-namespace:Todo.Converters"
xmlns:helpers="clr-namespace:Todo.Helpers"
mc:Ignorable="d"
Title="Platform Management" Height="450" Width="400"
FontFamily="/Resources/#D2Coding"
WindowTitleBrush="{DynamicResource SecondaryBackgroundColor}"
GlowBrush="Transparent"
WindowStartupLocation="CenterOwner"
PreviewKeyDown="MetroWindow_PreviewKeyDown">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,10">
<TextBox x:Name="NewPlatformTextBox" Width="200" Margin="0,0,10,0" KeyDown="NewPlatform_KeyDown"/>
<Button Content="Add" Width="80" Click="AddPlatform_Click"/>
</StackPanel>
<ListView x:Name="PlatformListView" Grid.Row="1">
<ListView.ItemTemplate>
<DataTemplate>
<Grid Width="300">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="{Binding Name}" VerticalAlignment="Center"/>
<Button Grid.Column="1" Content="Delete" Tag="{Binding Id}" Click="DeletePlatform_Click" Focusable="False"/>
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</Grid>
</mah:MetroWindow>
// Todo 애플리케이션의 뷰(UI) 관련 클래스를 포함하는 네임스페이스
namespace Todo.Views
{
// 내부 using 선언으로 코드 간결화
using Todo.Data;
using Todo.Models;
// 플랫폼 관리를 위한 MetroWindow 창 클래스
public partial class PlatformManagementWindow : MetroWindow
{
// 데이터베이스 컨텍스트의 읽기 전용 인스턴스. 데이터베이스 작업을 위해 사용됨
private readonly AppDbContext _context;
// PlatformManagementWindow의 생성자. 의존성 주입을 통해 AppDbContext를 받음
public PlatformManagementWindow(AppDbContext context)
{
// XAML에 정의된 UI 컴포넌트들을 초기화
InitializeComponent();
// 전달받은 context 인스턴스를 클래스 필드에 할당
_context = context;
// 창이 열릴 때 플랫폼 목록을 로드하는 메서드 호출
LoadPlatforms();
}
// 데이터베이스에서 플랫폼 목록을 로드하여 ListView에 바인딩하는 메서드
private void LoadPlatforms()
{
// DB에서 플랫폼 목록을 이름 순으로 정렬하여 가져온 후, ListView의 ItemsSource에 할당
PlatformListView.ItemsSource = _context.Platforms.OrderBy(p => p.Name).ToList();
}
// '플랫폼 추가' 버튼 클릭 이벤트 핸들러
private async void AddPlatform_Click(object sender, RoutedEventArgs e)
{
// 텍스트박스에서 새 플랫폼 이름을 가져옴
var newName = NewPlatformTextBox.Text;
// 플랫폼 이름이 공백이 아닌지 확인
if (!string.IsNullOrWhiteSpace(newName))
{
// 데이터베이스에 동일한 이름의 플랫폼이 이미 존재하는지 비동기적으로 확인
if (!await _context.Platforms.AnyAsync(p => p.Name == newName))
{
// 존재하지 않으면 새 Platform 객체를 생성하여 DB Context에 추가
_context.Platforms.Add(new Platform { Name = newName });
// 변경사항을 데이터베이스에 비동기적으로 저장
await _context.SaveChangesAsync();
// 입력 텍스트박스를 비움
NewPlatformTextBox.Clear();
// 플랫폼 목록을 다시 로드하여 추가된 항목을 화면에 반영
LoadPlatforms();
}
else
{
// 이미 존재하는 경우 사용자에게 알림 메시지 표시
MessageBox.Show("Already exist Platform.");
}
}
}
// '플랫폼 삭제' 버튼 클릭 이벤트 핸들러
private async void DeletePlatform_Click(object sender, RoutedEventArgs e)
{
// 이벤트 발생 소스(sender)가 Button이고, 그 버튼의 Tag 속성에 플랫폼 ID(int)가 있는지 확인
if (sender is Button button && button.Tag is int platformId)
{
// 해당 ID를 가진 플랫폼을 데이터베이스에서 비동기적으로 찾음
var platformToDelete = await _context.Platforms.FindAsync(platformId);
// 플랫폼이 성공적으로 찾아졌는지 확인
if (platformToDelete != null)
{
// DB Context에서 해당 플랫폼을 제거 대상으로 표시
_context.Platforms.Remove(platformToDelete);
// 변경사항(삭제)을 데이터베이스에 비동기적으로 저장
await _context.SaveChangesAsync();
// 플랫폼 목록을 다시 로드하여 삭제된 항목을 화면에서 제거
LoadPlatforms();
}
}
}
// 새 플랫폼 입력 텍스트박스에서 키를 눌렀을 때의 이벤트 핸들러
private void NewPlatform_KeyDown(object sender, KeyEventArgs e)
{
// 눌린 키가 Enter 키인지 확인
if (e.Key == Key.Enter)
{
// '플랫폼 추가' 버튼을 클릭한 것과 동일한 로직을 실행
AddPlatform_Click(sender, e);
}
}
// 창 전체에서 키 입력 이벤트를 미리 감지하는 핸들러
private void MetroWindow_PreviewKeyDown(object sender, KeyEventArgs e)
{
// 눌린 키가 Escape 키인지 확인
if (e.Key == Key.Escape)
{
// 창을 닫음
this.Close();
}
}
}
}
전역 변수 (필드)
- _context: AppDbContext 타입. Entity Framework Core의 데이터베이스 컨텍스트로, 플랫폼 데이터의 조회, 추가, 삭제에 사용됨.
함수 (메서드)
| 함수이름(파라미터) | 기능 |
| PlatformManagementWindow(AppDbContext context) |
클래스 생성자. AppDbContext를 주입받아 초기화하고 플랫폼 목록을 로드.
|
| LoadPlatforms() |
데이터베이스에서 모든 플랫폼을 조회하여 이름순으로 정렬한 뒤 리스트에 표시.
|
| AddPlatform_Click(object sender, RoutedEventArgs e) |
입력된 이름으로 새 플랫폼을 데이터베이스에 추가. 중복 이름은 허용하지 않음.
|
| DeletePlatform_Click(object sender, RoutedEventArgs e) |
선택된 플랫폼을 데이터베이스에서 삭제.
|
| NewPlatform_KeyDown(object sender, KeyEventArgs e) |
새 플랫폼 이름 입력란에서 Enter 키를 누르면 플랫폼 추가 기능을 실행.
|
| MetroWindow_PreviewKeyDown(object sender, KeyEventArgs e) |
창 내에서 ESC 키를 누르면 창을 닫음.
|
1.4 SettingsWindow
<mah:MetroWindow
x:Class="Todo.Views.SettingsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:Todo.Views"
xmlns:viewmodels="clr-namespace:Todo.ViewModels"
xmlns:converters="clr-namespace:Todo.Converters"
xmlns:helpers="clr-namespace:Todo.Helpers"
mc:Ignorable="d"
Title="Settings" Height="600" Width="400"
FontFamily="/Resources/#D2Coding"
WindowTitleBrush="{DynamicResource SecondaryBackgroundColor}"
GlowBrush="Transparent"
WindowStartupLocation="CenterOwner"
PreviewKeyDown="MetroWindow_PreviewKeyDown">
<StackPanel Margin="20">
<TextBlock Text="Theme Settings" FontWeight="Bold" Margin="0,0,0,5"/>
<ComboBox x:Name="ThemeComboBox" SelectionChanged="ThemeComboBox_SelectionChanged">
<ComboBoxItem Content="Light Mode"/>
<ComboBoxItem Content="Dark Mode"/>
</ComboBox>
<TextBlock Text="Window Setting" FontWeight="Bold" Margin="0,20,0,5"/>
<CheckBox x:Name="TopmostCheckBox" Content="Always on top" Click="TopmostCheckBox_Click"/>
<TextBlock Text="Startup Setting" FontWeight="Bold" Margin="0,20,0,5"/>
<CheckBox x:Name="StartupCheckBox" Content="Auto-run at computer startup" Click="StartupCheckBox_Click"/>
<TextBlock Text="Notification Setting" FontWeight="Bold" Margin="0,20,0,5"/>
<CheckBox x:Name="NotificationCheckBox" Content="Get Notifications"/>
<StackPanel Margin="20">
<Button x:Name="KakaoLoginButton" Content="Login into Kakao account" Click="KakaoLoginButton_Click"/>
<Button x:Name="KakaoLogoutButton" Content="Logout" Click="KakaoLogoutButton_Click" Margin="0,5,0,0"/>
<GroupBox Margin="0,20,0,5">
<GroupBox.Header>
<TextBlock Text="Notification Test"
Foreground="White"
FontWeight="Bold"/>
</GroupBox.Header>
<GroupBox.Resources>
<Style TargetType="Button" BasedOn="{StaticResource MahApps.Styles.Button}">
<Setter Property="Foreground" Value="White"/>
</Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="White"/>
</Style>
</GroupBox.Resources>
<StackPanel>
<TextBlock Text="Windows Notification" FontWeight="Bold" Margin="5"/>
<StackPanel Orientation="Vertical" HorizontalAlignment="Stretch">
<Button Content="Notification (Now)" Margin="5" Click="WindowsNotification_Click"/>
<Button Content="Notification (5 minutes before closing)" Margin="5" Click="WindowsImminentNotification_Click"/>
</StackPanel>
</StackPanel>
</GroupBox>
</StackPanel>
</StackPanel>
</mah:MetroWindow>
// Todo 애플리케이션의 뷰(UI) 관련 클래스를 포함하는 네임스페이스
namespace Todo.Views
{
// 내부 using 선언으로 코드 간결화
using Todo.Services;
using Todo.ViewModels;
// 애플리케이션 설정을 관리하는 MetroWindow 창 클래스
public partial class SettingsWindow : MetroWindow
{
// 애플리케이션 설정 관리 서비스
private readonly SettingsService _settingsService;
// 카카오 로그인/알림 관련 서비스
private readonly KakaoService _kakaoService;
// 메인 뷰모델 참조. 다른 뷰모델 기능에 접근하기 위해 사용
private readonly MainViewModel _viewModel;
// 알림 스케줄링 서비스
private readonly NotificationScheduler _scheduler;
// SettingsWindow의 생성자. 필요한 서비스들을 의존성 주입으로 받음
public SettingsWindow(SettingsService settingsService, KakaoService kakaoService, MainViewModel viewModel, NotificationScheduler scheduler)
{
// XAML에 정의된 UI 컴포넌트들을 초기화
InitializeComponent();
// 전달받은 서비스 인스턴스들을 클래스 필드에 할당
_settingsService = settingsService;
_kakaoService = kakaoService;
_viewModel = viewModel;
_scheduler = scheduler;
// 현재 저장된 설정들을 UI에 로드
LoadCurrentSettings();
// 카카오 로그인 상태에 따라 버튼 UI를 업데이트
UpdateKakaoButtons();
}
// 카카오 로그인 상태에 따라 로그인/로그아웃 버튼의 표시 여부를 결정하는 메서드
private void UpdateKakaoButtons()
{
// 설정에 카카오 리프레시 토큰이 있는지 여부로 로그인 상태를 판단
bool isLoggedIn = !string.IsNullOrEmpty(_settingsService.Settings.KakaoRefreshToken);
// 로그인 상태이면 로그인 버튼 숨기고, 아니면 표시
KakaoLoginButton.Visibility = isLoggedIn ? Visibility.Collapsed : Visibility.Visible;
// 로그인 상태이면 로그아웃 버튼 표시하고, 아니면 숨김
KakaoLogoutButton.Visibility = isLoggedIn ? Visibility.Visible : Visibility.Collapsed;
}
// '카카오 로그인' 버튼 클릭 이벤트 핸들러
private async void KakaoLoginButton_Click(object sender, RoutedEventArgs e)
{
// 카카오 인증 URL을 가져옴
var authUrl = _kakaoService.GetAuthenticationUrl();
// 기본 웹 브라우저로 인증 URL을 열어 사용자 로그인을 유도
Process.Start(new ProcessStartInfo(authUrl) { UseShellExecute = true });
// 사용자에게 인증 코드를 입력받기 위한 비동기 대화상자를 표시
var result = await this.ShowInputAsync("Enter authentication code", "Copy the code that appears in your web browser,\nPaste it here.");
// 사용자가 코드를 입력하고 '확인'을 눌렀는지 확인
if (!string.IsNullOrWhiteSpace(result))
{
// 입력받은 코드로 인증(토큰 발급)을 시도
bool success = await _kakaoService.AuthorizeAsync(result);
// 인증 결과를 메시지 대화상자로 사용자에게 알림
await this.ShowMessageAsync("Authentication Results", success ? "Login Successful" : "Login Failed");
// 로그인 상태가 변경되었으므로 버튼 UI를 업데이트
UpdateKakaoButtons();
}
}
// 저장된 설정 값을 UI 컨트롤에 로드하는 메서드
private void LoadCurrentSettings()
{
// 저장된 테마 설정에 따라 콤보박스 선택
ThemeComboBox.SelectedIndex = _settingsService.Settings.Theme == "Dark" ? 1 : 0;
// '항상 위에 표시' 설정 로드
TopmostCheckBox.IsChecked = _settingsService.Settings.IsAlwaysOnTop;
// '알림 사용' 설정 로드
NotificationCheckBox.IsChecked = _settingsService.Settings.AreNotificationsEnabled;
// '윈도우 시작 시 자동 실행' 설정 로드
StartupCheckBox.IsChecked = _settingsService.IsStartupEnabled();
}
// '테마' 콤보박스 선택 변경 이벤트 핸들러
private void ThemeComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
// 창이 완전히 로드된 후에만 실행 (초기 로드 시 불필요한 실행 방지)
if (this.IsLoaded)
{
// 선택된 인덱스에 따라 테마 이름을 결정
_settingsService.Settings.Theme = ThemeComboBox.SelectedIndex == 0 ? "Light" : "Dark";
// 변경된 설정을 저장
_settingsService.Save();
// 현재 애플리케이션 전체에 변경된 테마를 적용
(Application.Current as App)?.ApplyTheme(_settingsService.Settings.Theme);
}
}
// '항상 위에 표시' 체크박스 클릭 이벤트 핸들러
private void TopmostCheckBox_Click(object sender, RoutedEventArgs e)
{
// 체크박스 상태를 설정에 저장 (null일 경우 false로 처리)
_settingsService.Settings.IsAlwaysOnTop = TopmostCheckBox.IsChecked ?? false;
_settingsService.Save();
// 현재 애플리케이션의 메인 창을 찾아 'Topmost' 속성을 즉시 변경
if (Application.Current.MainWindow is MainWindow mainWindow)
{
mainWindow.Topmost = _settingsService.Settings.IsAlwaysOnTop;
}
}
// '알림 사용' 체크박스 클릭 이벤트 핸들러
private void NotificationCheckBox_Click(object sender, RoutedEventArgs e)
{
// 체크박스 상태를 설정에 저장
_settingsService.Settings.AreNotificationsEnabled = NotificationCheckBox.IsChecked ?? false;
_settingsService.Save();
}
// '카카오 로그아웃' 버튼 클릭 이벤트 핸들러
private async void KakaoLogoutButton_Click(object sender, RoutedEventArgs e)
{
// 카카오 로그아웃(토큰 폐기)을 비동기적으로 처리
await _kakaoService.LogoutAsync();
// 로그아웃 완료 메시지를 표시
await this.ShowMessageAsync("Logout", "You are logged out.");
// 버튼 UI를 업데이트
UpdateKakaoButtons();
}
// '윈도우 시작 시 자동 실행' 체크박스 클릭 이벤트 핸들러
private async void StartupCheckBox_Click(object sender, RoutedEventArgs e)
{
// 체크박스 상태에 따라 시작 프로그램 등록/해제를 비동기적으로 처리
await _settingsService.SetStartupAsync(StartupCheckBox.IsChecked ?? false);
}
// '윈도우 알림 테스트' 버튼 클릭 이벤트 핸들러
private void WindowsNotification_Click(object sender, RoutedEventArgs e)
{
// AppNotificationBuilder를 사용하여 테스트용 알림 객체를 생성
var notification = new AppNotificationBuilder()
.AddText("🔔 Windows 즉시 알림 테스트")
.AddText("이 알림은 버튼 클릭 시 즉시 발생합니다.")
.BuildNotification();
// 생성된 알림을 즉시 표시
AppNotificationManager.Default.Show(notification);
}
// '마감 임박 알림 테스트' 버튼 클릭 이벤트 핸들러
private async void WindowsImminentNotification_Click(object sender, RoutedEventArgs e)
{
// 현재 시간으로부터 5분 뒤를 마감 시간으로 설정
var dueDate = DateTime.Now.AddMinutes(5);
// 뷰모델에 테스트용 작업 제목 설정
_viewModel.NewItemTitle = "마감 임박 알림 테스트용 작업";
// 테스트용 작업을 비동기적으로 추가
await _viewModel.AddItemAsync(null, DateTime.Now, dueDate);
// 마감 임박 알림 확인 로직을 즉시 실행
_viewModel.CheckForImminentNotifications();
// 테스트 작업이 추가되었음을 사용자에게 알림
await this.ShowMessageAsync("테스트 작업 추가", $"'{dueDate:HH:mm}'에 마감되는 작업을 추가하고 마감 임박 알림을 실행했습니다.");
}
// 창 전체에서 키 입력 이벤트를 미리 감지하는 핸들러
private void MetroWindow_PreviewKeyDown(object sender, KeyEventArgs e)
{
// 눌린 키가 Escape 키인지 확인
if (e.Key == Key.Escape)
{
// 창을 닫음
this.Close();
}
}
}
}
전역 변수 (필드)
- _settingsService: SettingsService 타입. 앱의 전반적인 설정(테마, 창 상태 등)을 관리.
- _kakaoService: KakaoService 타입. 카카오 API 연동(로그인, 알림)을 처리.
- _viewModel: MainViewModel 타입. 다른 뷰모델의 기능(작업 추가, 알림 확인)을 호출하기 위한 참조.
- _scheduler: NotificationScheduler 타입. 알림 스케줄링 관련 기능을 담당.
함수 (메서드)
| 함수이름(파라미터) | 기능 |
| SettingsWindow(SettingsService, KakaoService, MainViewModel, NotificationScheduler) |
클래스 생성자. 필요한 서비스들을 주입받고 UI를 초기화.
|
| UpdateKakaoButtons() |
카카오 로그인 상태에 따라 로그인/로그아웃 버튼의 표시 여부를 갱신.
|
| KakaoLoginButton_Click(object sender, RoutedEventArgs e) |
카카오 OAuth 인증 절차를 시작하고 사용자로부터 받은 코드로 로그인을 시도.
|
| LoadCurrentSettings() |
SettingsService에서 현재 설정 값을 불러와 UI 컨트롤(콤보박스, 체크박스)에 반영.
|
| ThemeComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) |
사용자가 선택한 테마를 설정에 저장하고 애플리케이션 전체에 즉시 적용.
|
| TopmostCheckBox_Click(object sender, RoutedEventArgs e) |
항상 위에 표시' 설정을 저장하고 메인 창에 즉시 적용.
|
| NotificationCheckBox_Click(object sender, RoutedEventArgs e) |
알림 활성화/비활성화 설정을 저장.
|
| KakaoLogoutButton_Click(object sender, RoutedEventArgs e) |
카카오 로그아웃을 처리하고 UI를 갱신.
|
| StartupCheckBox_Click(object sender, RoutedEventArgs e) |
윈도우 시작 시 자동 실행' 설정을 레지스트리에 등록 또는 해제.
|
| WindowsNotification_Click(object sender, RoutedEventArgs e) |
Windows App SDK를 통해 즉시 표시되는 테스트 알림을 발송.
|
| WindowsImminentNotification_Click(object sender, RoutedEventArgs e) |
마감 임박 알림 기능이 정상 동작하는지 테스트하기 위해 임시 작업을 추가하고 관련 로직을 실행.
|
| MetroWindow_PreviewKeyDown(object sender, KeyEventArgs e) |
창 내에서 ESC 키를 누르면 창을 닫음.
|
2. ViewModel
// 뷰모델 클래스를 포함하는 네임스페이스
namespace Todo.ViewModels
{
// 프로젝트 내부 네임스페이스 참조
using Todo.Data;
using Todo.Models;
using Todo.Services;
// INotifyPropertyChanged 인터페이스를 구현하여 속성 변경 시 UI에 알리는 기능을 제공
public class MainViewModel : INotifyPropertyChanged
{
// 데이터베이스 컨텍스트, 설정 서비스, 카카오 서비스를 위한 읽기 전용 필드
private readonly AppDbContext _context;
private readonly SettingsService _settingsService;
private readonly KakaoService _kakaoService;
// '시작 안 함' 상태의 할 일 목록. ObservableCollection은 UI와 데이터 바인딩 시 목록 변경을 자동으로 감지
public ObservableCollection<TodoItem> NotStartedItems { get; set; }
// '진행 중' 상태의 할 일 목록
public ObservableCollection<TodoItem> InProgressItems { get; set; }
// '완료' 상태의 할 일 목록
public ObservableCollection<TodoItem> CompletedItems { get; set; }
// 모든 플랫폼 목록
public ObservableCollection<Platform> AllPlatforms { get; set; }
// 새 할 일 항목의 제목. UI의 입력 필드와 바인딩됨
public string NewItemTitle { get; set; } = "";
// 새 할 일 항목의 플랫폼. UI와 바인딩됨
public string NewItemPlatform { get; set; } = "";
// MainViewModel 생성자. 의존성 주입으로 필요한 서비스들을 받음
public MainViewModel(AppDbContext context, SettingsService settingsService, KakaoService kakaoService)
{
// 전달받은 인스턴스들을 필드에 할당
_context = context;
// 각 할 일 목록과 플랫폼 목록을 초기화
NotStartedItems = new ObservableCollection<TodoItem>();
InProgressItems = new ObservableCollection<TodoItem>();
CompletedItems = new ObservableCollection<TodoItem>();
AllPlatforms = new ObservableCollection<Platform>();
_settingsService = settingsService;
_kakaoService = kakaoService;
}
// 데이터베이스에서 모든 항목과 플랫폼을 비동기적으로 로드하는 메서드
public async Task LoadItemsAsync()
{
// 플랫폼 목록을 DB에서 이름순으로 정렬하여 로드
var platforms = await _context.Platforms.OrderBy(p => p.Name).ToListAsync();
AllPlatforms.Clear(); // 기존 목록을 비움
foreach (var p in platforms) // 로드한 플랫폼을 컬렉션에 추가
{
AllPlatforms.Add(p);
}
// 할 일 항목들을 관련 플랫폼 정보와 함께(Include) DB에서 로드
var itemsFromDb = await _context.TodoItems.Include(i => i.Platform).ToListAsync();
NotStartedItems.Clear();
InProgressItems.Clear();
CompletedItems.Clear();
// 로드한 각 항목을 상태에 맞는 목록으로 분배
foreach (var item in itemsFromDb)
{
DistributeItem(item);
}
// 모든 목록을 마감일(기본값) 기준으로 정렬
SortAllLists(item => item.DueDate ?? DateTime.MaxValue);
}
// 새 할 일 항목을 추가하는 비동기 메서드
public async Task AddItemAsync(Platform? selectedPlatform, DateTime? startDate, DateTime? dueDate)
{
// 제목이 비어있으면 아무 작업도 하지 않음
if (string.IsNullOrWhiteSpace(NewItemTitle)) return;
// 새 TodoItem 객체 생성
var newItem = new TodoItem
{
Title = NewItemTitle,
Platform = selectedPlatform,
Status = WorkStatus.NotStarted,
// 날짜/시간은 DB 저장을 위해 UTC로 변환
StartDate = startDate?.ToUniversalTime(),
DueDate = dueDate?.ToUniversalTime()
};
_context.TodoItems.Add(newItem); // DB 컨텍스트에 새 항목 추가
await _context.SaveChangesAsync(); // 변경사항을 DB에 저장
NotStartedItems.Add(newItem); // UI의 '시작 안 함' 목록에 즉시 추가
await _kakaoService.SendMessageAsync($"[작업 추가] {newItem.Title}"); // 카카오톡으로 알림 전송
NewItemTitle = string.Empty; // 입력 필드 초기화
OnPropertyChanged(nameof(NewItemTitle)); // 제목 속성이 변경되었음을 UI에 알림
}
// 기존 할 일 항목을 업데이트하는 비동기 메서드 (주로 상태 변경 시 사용)
public async Task UpdateItemAsync(TodoItem item)
{
// 상태가 '완료'로 변경되면 완료 시간을 현재 UTC 시간으로 기록
if (item.Status == WorkStatus.Completed)
{
item.CompletedDate = DateTime.UtcNow;
}
else // 그 외의 상태로 변경되면 완료 시간을 null로 설정
{
item.CompletedDate = null;
}
_context.Update(item); // DB 컨텍스트에 항목 업데이트
await _context.SaveChangesAsync(); // 변경사항을 DB에 저장
RemoveItemFromAllLists(item); // 기존 목록에서 항목 제거
DistributeItem(item); // 변경된 상태에 맞는 새 목록으로 항목 분배
}
// 할 일 항목을 삭제하는 비동기 메서드
public async Task DeleteItemAsync(TodoItem item)
{
var itemToDelete = await _context.TodoItems.FindAsync(item.Id); // ID로 DB에서 항목 찾기
if (itemToDelete != null)
{
_context.TodoItems.Remove(itemToDelete); // DB 컨텍스트에서 항목 제거
await _context.SaveChangesAsync(); // 변경사항을 DB에 저장
RemoveItemFromAllLists(item); // 모든 UI 목록에서 항목 제거
}
}
// 모든 상태 목록을 주어진 정렬 기준(keySelector)에 따라 정렬하는 메서드
public void SortAllLists(Func<TodoItem, object> keySelector)
{
SortList(NotStartedItems, keySelector);
SortList(InProgressItems, keySelector);
}
// ObservableCollection을 정렬하는 private 헬퍼 메서드
private void SortList(ObservableCollection<TodoItem> collection, Func<TodoItem, object> keySelector)
{
var sorted = collection.OrderBy(keySelector).ToList(); // LINQ를 사용해 정렬된 새 리스트 생성
collection.Clear(); // 기존 컬렉션을 비움
foreach (var item in sorted) // 정렬된 리스트의 항목들을 다시 컬렉션에 추가
{
collection.Add(item);
}
}
// 할 일 항목을 상태(Status)에 따라 적절한 목록에 추가하는 헬퍼 메서드
private void DistributeItem(TodoItem item)
{
switch (item.Status)
{
case WorkStatus.NotStarted:
NotStartedItems.Add(item);
break;
case WorkStatus.InProgress:
InProgressItems.Add(item);
break;
case WorkStatus.Completed:
CompletedItems.Add(item);
break;
}
}
// 모든 목록에서 특정 항목을 제거하는 헬퍼 메서드
private void RemoveItemFromAllLists(TodoItem item)
{
if (NotStartedItems.Contains(item)) NotStartedItems.Remove(item);
if (InProgressItems.Contains(item)) InProgressItems.Remove(item);
if (CompletedItems.Contains(item)) CompletedItems.Remove(item);
}
// INotifyPropertyChanged 구현을 위한 이벤트와 메서드
public event PropertyChangedEventHandler? PropertyChanged;
protected void OnPropertyChanged(string propertyName) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
// 플랫폼 목록만 다시 로드하는 비동기 메서드 (플랫폼 관리 창이 닫힐 때 호출됨)
public async Task ReloadPlatformsAsync()
{
var platforms = await _context.Platforms.OrderBy(p => p.Name).ToListAsync();
AllPlatforms.Clear();
foreach (var p in platforms)
{
AllPlatforms.Add(p);
}
}
// 프로그램 시작 시 오늘 마감인 작업에 대한 요약 알림을 보내는 메서드
public void SendStartupSummaryNotification()
{
if (!_settingsService.Settings.AreNotificationsEnabled) return; // 알림 설정이 꺼져있으면 중단
// '시작 안 함'과 '진행 중' 목록에서 오늘이 마감일인 항목들을 필터링
var itemsToday = NotStartedItems.Concat(InProgressItems)
.Where(item => item.DueDate.HasValue && item.DueDate.Value.ToLocalTime().Date == DateTime.Today)
.ToList();
if (itemsToday.Any())
{
var summaryText = new System.Text.StringBuilder();
summaryText.AppendLine($"오늘 마감 예정인 작업이 {itemsToday.Count}개 있습니다.");
// 최대 4개의 항목만 요약에 포함
foreach (var item in itemsToday.Take(4))
{
summaryText.AppendLine($"- {item.Title}");
}
if (itemsToday.Count > 4)
{
summaryText.AppendLine("...");
}
// Windows 알림 생성 및 표시
var notification = new AppNotificationBuilder()
.AddText("📢 오늘의 할 일 목록")
.AddText(summaryText.ToString())
.BuildNotification();
AppNotificationManager.Default.Show(notification);
}
}
// 마감이 임박한(1시간 이내) 작업에 대한 알림을 확인하고 보내는 메서드
public void CheckForImminentNotifications()
{
if (!_settingsService.Settings.AreNotificationsEnabled) return; // 알림 설정이 꺼져있으면 중단
var now = DateTime.UtcNow;
// 마감까지 1시간 미만으로 남은 항목들을 필터링
var imminentItems = NotStartedItems.Concat(InProgressItems)
.Where(item => item.DueDate.HasValue &&
item.DueDate.Value > now &&
(item.DueDate.Value - now).TotalHours < 1)
.ToList();
// 각 임박 항목에 대해 알림 생성 및 표시
foreach (var item in imminentItems)
{
var notification = new AppNotificationBuilder()
.AddText("⏰ 마감 임박!")
.AddText($"'{item.Title}' 작업의 마감이 1시간 이내로 남았습니다.")
.BuildNotification();
AppNotificationManager.Default.Show(notification);
}
}
// 시작 시간이 된 작업을 자동으로 '진행 중' 상태로 변경하는 비동기 메서드
public async Task AutoProgressTasksAsync()
{
var now = DateTime.UtcNow;
// '시작 안 함' 목록에서 시작 시간이 현재 시간보다 이전인 항목들을 필터링
var tasksToStart = NotStartedItems
.Where(i => i.StartDate.HasValue && i.StartDate.Value <= now)
.ToList();
if (!tasksToStart.Any()) return; // 대상 작업이 없으면 중단
foreach (var item in tasksToStart)
{
item.Status = WorkStatus.InProgress; // 상태를 '진행 중'으로 변경
await UpdateItemAsync(item); // 변경사항을 DB와 UI에 반영
await _kakaoService.SendMessageAsync($"[작업 시작] '{item.Title}' 작업이 시작되었습니다.");
}
}
}
}
전역 변수 (필드 및 속성)
- _context (AppDbContext): 데이터베이스 상호작용을 위한 EF Core 컨텍스트.
- _settingsService (SettingsService): 알림 활성화 여부 등 앱 설정을 가져오기 위한 서비스.
- _kakaoService (KakaoService): 카카오톡 메시지 전송을 위한 서비스.
- NotStartedItems (ObservableCollection<TodoItem>): '시작 안 함' 상태의 작업 목록. UI에 바인딩됨.
- InProgressItems (ObservableCollection<TodoItem>): '진행 중' 상태의 작업 목록. UI에 바인딩됨.
- CompletedItems (ObservableCollection<TodoItem>): '완료' 상태의 작업 목록. UI에 바인딩됨.
- AllPlatforms (ObservableCollection<Platform>): 플랫폼 선택 UI에 사용될 전체 플랫폼 목록.
- NewItemTitle (string): 새 작업 추가 시 제목 입력란과 바인딩되는 속성.
함수 (메서드)
| 함수이름(파라미터) | 기능 |
| MainViewModel(AppDbContext, SettingsService, KakaoService) |
클래스 생성자. 의존성 주입을 통해 서비스들을 초기화하고 컬렉션들을 생성.
|
| LoadItemsAsync() |
앱 시작 시 데이터베이스에서 모든 작업과 플랫폼을 비동기적으로 로드하여 각 목록에 채움.
|
| AddItemAsync(Platform?, DateTime?, DateTime?) |
UI에서 입력된 정보로 새 작업을 생성하여 데이터베이스와 UI에 추가하고 카카오톡 알림을 전송.
|
| UpdateItemAsync(TodoItem item) |
작업의 상태 등 변경사항을 데이터베이스에 저장하고, UI 목록 간에 작업을 이동.
|
| DeleteItemAsync(TodoItem item) |
지정된 작업을 데이터베이스와 UI 목록에서 삭제.
|
| SortAllLists(Func<TodoItem, object> keySelector) |
모든 작업 목록을 주어진 정렬 기준에 따라 정렬.
|
| ReloadPlatformsAsync() |
데이터베이스에서 플랫폼 목록만 다시 로드. 플랫폼 관리 창 종료 후 호출됨.
|
| SendStartupSummaryNotification() |
앱 시작 시 오늘 마감인 작업들을 요약하여 Windows 알림으로 표시.
|
| CheckForImminentNotifications() |
마감 시간이 1시간 이내로 임박한 작업을 찾아 Windows 알림으로 알림.
|
| AutoProgressTasksAsync() |
설정된 시작 시간이 된 작업을 자동으로 '진행 중' 상태로 변경하고 카카오톡 알림을 전송.
|
| OnPropertyChanged(string propertyName) |
INotifyPropertyChanged 구현. 속성 값이 변경되었음을 UI에 알림.
|
3. Services
3.1. KakaoServices
// 서비스 관련 클래스를 포함하는 네임스페이스
namespace Todo.Services
{
// 카카오 API 연동을 담당하는 서비스 클래스
public class KakaoService
{
// 앱 설정을 읽고 쓰기 위한 서비스
private readonly SettingsService _settingsService;
// 카카오 REST API 키
private readonly string _apiKey;
// 카카오 OAuth 인증 후 리디렉션될 URI
private readonly string _redirectUri;
// HTTP 요청을 보내기 위한 정적 HttpClient 인스턴스 (성능 및 리소스 관리를 위해 정적으로 사용)
private static readonly HttpClient _httpClient = new HttpClient();
// KakaoService 생성자. 의존성 주입을 통해 필요한 서비스와 설정을 받음
public KakaoService(SettingsService settingsService, IConfiguration configuration)
{
_settingsService = settingsService;
// appsettings.json에서 카카오 API 키와 리디렉션 URI를 로드
_apiKey = configuration["KakaoConfig:ApiKey"]!;
_redirectUri = configuration["KakaoConfig:RedirectUri"]!;
}
// 사용자가 카카오 로그인을 위해 접속해야 할 인증 URL을 생성하는 메서드
public string GetAuthenticationUrl()
{
return $"https://kauth.kakao.com/oauth/authorize?client_id={_apiKey}&redirect_uri={_redirectUri}&response_type=code";
}
// 사용자가 인증 후 받은 인증 코드(authCode)를 사용하여 액세스 토큰과 리프레시 토큰을 발급받는 메서드
public async Task<bool> AuthorizeAsync(string authCode)
{
try
{
var tokenUrl = "https://kauth.kakao.com/oauth/token";
// 토큰 발급에 필요한 파라미터들을 FormUrlEncodedContent 형식으로 구성
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "grant_type", "authorization_code" }, // 인증 타입: 인증 코드 사용
{ "client_id", _apiKey },
{ "redirect_uri", _redirectUri },
{ "code", authCode }
});
var response = await _httpClient.PostAsync(tokenUrl, content); // 카카오 서버에 POST 요청
response.EnsureSuccessStatusCode(); // HTTP 응답이 성공(2xx)이 아니면 예외 발생
var json = await response.Content.ReadAsStringAsync(); // 응답 본문을 문자열로 읽음
var result = JsonConvert.DeserializeObject<KakaoTokenResponse>(json); // JSON을 KakaoTokenResponse 객체로 역직렬화
// 토큰이 정상적으로 발급되지 않은 경우 실패 반환
if (result?.AccessToken is null || result?.RefreshToken is null) return false;
// 발급받은 토큰 정보들을 설정에 저장
_settingsService.Settings.KakaoAccessToken = result.AccessToken;
_settingsService.Settings.KakaoRefreshToken = result.RefreshToken;
_settingsService.Settings.KakaoTokenExpiresAt = DateTime.UtcNow.AddSeconds(result.ExpiresIn);
_settingsService.Save(); // 설정 변경사항 저장
return true; // 성공 반환
}
catch { return false; } // 예외 발생 시 실패 반환
}
// 만료된 액세스 토큰을 리프레시 토큰을 사용해 갱신하는 메서드
public async Task<bool> TryRefreshAccessTokenAsync()
{
// 저장된 리프레시 토큰이 없으면 갱신 불가
if (string.IsNullOrEmpty(_settingsService.Settings.KakaoRefreshToken)) return false;
try
{
var tokenUrl = "https://kauth.kakao.com/oauth/token";
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "grant_type", "refresh_token" }, // 인증 타입: 리프레시 토큰 사용
{ "client_id", _apiKey },
{ "refresh_token", _settingsService.Settings.KakaoRefreshToken }
});
var response = await _httpClient.PostAsync(tokenUrl, content);
// 응답이 실패하면 (예: 리프레시 토큰 만료) 로그아웃 처리 후 실패 반환
if (!response.IsSuccessStatusCode)
{
await LogoutAsync();
return false;
}
var json = await response.Content.ReadAsStringAsync();
var result = JsonConvert.DeserializeObject<KakaoTokenResponse>(json);
// 새 액세스 토큰이 없으면 실패 반환
if (result?.AccessToken is null) return false;
// 새로 발급받은 액세스 토큰과 만료 시간 저장 (만료 5분 전으로 설정하여 안정성 확보)
_settingsService.Settings.KakaoAccessToken = result.AccessToken;
_settingsService.Settings.KakaoTokenExpiresAt = DateTime.UtcNow.AddSeconds(result.ExpiresIn - 300);
// 응답에 새 리프레시 토큰이 포함된 경우 (리프레시 토큰 만료가 임박한 경우) 함께 갱신
if (result.RefreshToken != null)
{
_settingsService.Settings.KakaoRefreshToken = result.RefreshToken;
}
_settingsService.Save();
return true;
}
catch { return false; }
}
// 카카오 로그아웃 처리 메서드
public async Task LogoutAsync()
{
// 로컬에 액세스 토큰이 저장되어 있는 경우에만 카카오 서버에 로그아웃 요청
if (!string.IsNullOrEmpty(_settingsService.Settings.KakaoAccessToken))
{
try
{
var logoutUrl = "https://kapi.kakao.com/v1/user/logout";
// HTTP 요청 헤더에 Bearer 토큰 인증 정보 추가
_httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _settingsService.Settings.KakaoAccessToken);
await _httpClient.PostAsync(logoutUrl, null);
}
catch { /* 서버와의 통신 실패는 무시하고 로컬 데이터만 초기화 */ }
}
// 로컬에 저장된 모든 카카오 관련 토큰 정보 및 만료 시간을 초기화
_settingsService.Settings.KakaoAccessToken = null;
_settingsService.Settings.KakaoRefreshToken = null;
_settingsService.Settings.KakaoTokenExpiresAt = DateTime.MinValue;
_settingsService.Save();
}
// '나에게 보내기' API를 사용하여 카카오톡 메시지를 전송하는 메서드
public async Task SendMessageAsync(string message)
{
// 액세스 토큰이 없으면(로그인 상태가 아니면) 메시지 전송 불가
if (string.IsNullOrEmpty(_settingsService.Settings.KakaoAccessToken)) return;
// 액세스 토큰이 만료되었으면 갱신 시도
if (DateTime.UtcNow >= _settingsService.Settings.KakaoTokenExpiresAt)
{
bool refreshed = await TryRefreshAccessTokenAsync();
if (!refreshed) return; // 갱신 실패 시 메시지 전송 불가
}
try
{
var messageUrl = "https://kapi.kakao.com/v2/api/talk/memo/default/send";
// 카카오 메시지 템플릿 객체 생성
var template = new
{
object_type = "text",
text = message,
link = new { web_url = "", mobile_web_url = "" }
};
// 템플릿 객체를 Form 데이터로 변환
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "template_object", JsonConvert.SerializeObject(template) }
});
// HTTP 요청 헤더에 Bearer 토큰 인증 정보 추가
_httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _settingsService.Settings.KakaoAccessToken);
var response = await _httpClient.PostAsync(messageUrl, content);
response.EnsureSuccessStatusCode(); // HTTP 응답이 성공이 아니면 예외 발생
}
catch (Exception ex)
{
// 메시지 전송 실패 시 디버그 창에 로그 출력
Debug.WriteLine($"카카오 메시지 전송 실패: {ex.Message}");
}
}
}
}
전역 변수 (필드)
- _settingsService (SettingsService): 카카오 관련 토큰(액세스, 리프레시)을 로컬 설정에 저장하고 읽기 위해 사용.
- _apiKey (string): appsettings.json에서 로드한 카카오 애플리케이션의 REST API 키.
- _redirectUri (string): appsettings.json에서 로드한 카카오 OAuth 인증 후 사용될 리디렉션 URI.
- _httpClient (HttpClient): 카카오 API 서버와 통신하기 위한 정적 HTTP 클라이언트 인스턴스.
함수 (메서드)
| 함수이름(파라미터) | 기능 |
| KakaoService(SettingsService, IConfiguration) |
클래스 생성자. SettingsService와 IConfiguration을 주입받아 서비스 필드를 초기화.
|
| GetAuthenticationUrl() |
카카오 OAuth 인증을 시작하기 위한 URL을 생성하여 반환.
|
| AuthorizeAsync(string authCode) |
사용자가 제공한 인증 코드를 사용하여 카카오 서버로부터 액세스 토큰과 리프레시 토큰을 비동기적으로 발급받고 설정에 저장.
|
| TryRefreshAccessTokenAsync() |
만료된 액세스 토큰을 리프레시 토큰을 이용해 비동기적으로 갱신. 갱신 실패 시 자동으로 로그아웃 처리.
|
| LogoutAsync() |
카카오 서버에 로그아웃을 요청하고 로컬에 저장된 모든 카카오 토큰 정보를 비동기적으로 삭제.
|
| SendMessageAsync(string message) |
나에게 보내기' API를 사용해 지정된 메시지를 비동기적으로 전송. 전송 전 토큰 만료 여부를 확인하고 필요 시 자동으로 갱신.
|
3.2. NotificationScheduler
// 서비스 관련 클래스를 포함하는 네임스페이스
namespace Todo.Services
{
// 주기적으로 알림 확인 작업을 수행하는 스케줄러 클래스
public class NotificationScheduler
{
// 알림 확인 로직이 포함된 MainViewModel의 참조
private readonly MainViewModel _viewModel;
// 주기적인 작업을 실행하기 위한 타이머 객체. null일 수 있음
private Timer? _timer;
// NotificationScheduler 생성자. MainViewModel을 의존성 주입으로 받음
public NotificationScheduler(MainViewModel viewModel)
{
_viewModel = viewModel;
}
// 타이머를 시작하여 주기적인 알림 확인을 개시하는 메서드
public void Start()
{
// 타이머를 생성하고 설정.
// CheckForImminentWindowsNotifications: 실행할 콜백 메서드
// null: 콜백에 전달할 상태 객체 (사용하지 않음)
// TimeSpan.Zero: 시작 후 첫 실행까지의 대기 시간 (즉시 실행)
// TimeSpan.FromMinutes(15): 반복 실행 간격 (15분마다)
_timer = new Timer(CheckForImminentWindowsNotifications, null, TimeSpan.Zero, TimeSpan.FromMinutes(15));
}
// 타이머에 의해 주기적으로 호출되는 콜백 메서드
private void CheckForImminentWindowsNotifications(object? state)
{
// MainViewModel에 있는 마감 임박 알림 확인 메서드를 호출
_viewModel.CheckForImminentNotifications();
}
}
}
전역 변수 (필드)
- _viewModel (MainViewModel): 알림 확인 로직을 실제로 수행하는 MainViewModel에 대한 참조.
- _timer (Timer?): 주기적인 작업 실행을 담당하는 System.Threading.Timer 인스턴스.
함수 (메서드)
| 함수이름(파라미터) | 기능 |
| NotificationScheduler(MainViewModel viewModel) |
클래스 생성자. MainViewModel을 주입받아 초기화.
|
| Start() |
15분 간격으로 알림 확인 작업을 수행하는 타이머를 시작.
|
| CheckForImminentWindowsNotifications(object? state) |
타이머가 호출하는 비공개 콜백 메서드. _viewModel의 알림 확인 메서드를 실행.
|
3.3. SettingsService
// 서비스 관련 클래스를 포함하는 네임스페이스
namespace Todo.Services
{
// INotifyPropertyChanged: 속성 변경 시 UI에 알리는 기능을 제공하는 인터페이스
public class SettingsService : INotifyPropertyChanged
{
// 애플리케이션 데이터를 저장할 폴더 경로 (%AppData%\Todo)
private static readonly string AppDataFolder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Todo");
// 설정 파일의 이름
private const string SettingsFile = "settings.json";
// 설정 파일의 전체 경로
private static readonly string SettingsFilePath = Path.Combine(AppDataFolder, SettingsFile);
// 속성 변경을 알리기 위한 이벤트
public event PropertyChangedEventHandler? PropertyChanged;
// 속성 변경 이벤트를 발생시키는 메서드
public void OnPropertyChanged(string propertyName) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
// 애플리케이션의 모든 설정을 담고 있는 객체
public AppSettings Settings { get; private set; }
// SettingsService 생성자
public SettingsService()
{
// 애플리케이션 데이터 폴더가 없으면 생성
Directory.CreateDirectory(AppDataFolder);
// 파일에서 설정을 로드하여 Settings 속성을 초기화
Settings = Load();
}
// 파일에서 설정을 로드하는 비공개 메서드
private AppSettings Load()
{
try
{
// 설정 파일이 존재하는지 확인
if (File.Exists(SettingsFilePath))
{
// 파일의 모든 텍스트(JSON)를 읽음
var json = File.ReadAllText(SettingsFilePath);
// JSON 문자열을 AppSettings 객체로 역직렬화. 실패 시 새 객체 생성
return JsonConvert.DeserializeObject<AppSettings>(json) ?? new AppSettings();
}
}
catch (Exception) { /* 파일 읽기 또는 역직렬화 오류 시 무시하고 기본 설정으로 진행 */ }
// 파일이 없거나 오류 발생 시 기본 설정을 담은 새 AppSettings 객체를 반환
return new AppSettings();
}
// 현재 설정 상태를 파일에 저장하는 공개 메서드
public void Save()
{
try
{
// Settings 객체를 읽기 좋은 형식(들여쓰기)의 JSON 문자열로 직렬화
var json = JsonConvert.SerializeObject(Settings, Formatting.Indented);
// JSON 문자열을 설정 파일에 씀 (기존 파일은 덮어씀)
File.WriteAllText(SettingsFilePath, json);
}
catch (Exception) { /* 파일 쓰기 오류 시 무시 */ }
}
// '윈도우 시작 시 자동 실행' 설정을 변경하는 비동기 메서드
public async Task SetStartupAsync(bool isEnabled)
{
try
{
// 앱 매니페스트에 정의된 StartupTask ID로 시작 작업 객체를 가져옴
var task = await StartupTask.GetAsync("StartupTask");
// 자동 실행을 활성화하려 하고, 현재 상태가 '비활성화'일 때
if (isEnabled && task.State == StartupTaskState.Disabled)
{
// 사용자에게 활성화를 요청. UAC 권한이 필요할 수 있음
var result = await task.RequestEnableAsync();
// 요청 후에도 활성화되지 않았다면 예외 발생
if (result != StartupTaskState.Enabled)
throw new Exception("자동 시작 활성화 실패");
}
// 자동 실행을 비활성화하려 하고, 현재 상태가 '활성화'일 때
else if (!isEnabled && task.State == StartupTaskState.Enabled)
{
// 자동 시작 작업을 비활성화
task.Disable();
}
}
catch (Exception ex)
{
// 설정 과정에서 오류 발생 시 사용자에게 메시지 박스로 알림
MessageBox.Show($"시작 프로그램 설정 오류:\n{ex.Message}");
}
}
// '윈도우 시작 시 자동 실행'이 현재 활성화되어 있는지 확인하는 메서드
public bool IsStartupEnabled()
{
try
{
// 시작 작업 객체를 동기적으로 가져와서 상태를 확인
var task = StartupTask.GetAsync("StartupTask").GetAwaiter().GetResult();
// 상태가 '활성화'이면 true 반환
return task.State == StartupTaskState.Enabled;
}
catch
{
// 오류 발생 시 false 반환
return false;
}
}
}
}
전역 변수 (필드 및 속성)
- AppDataFolder (string): 앱 설정 파일이 저장되는 폴더의 경로 (%AppData%\Todo).
- SettingsFile (string): 설정 파일의 이름 (settings.json).
- SettingsFilePath (string): 설정 파일의 전체 경로.
- Settings (AppSettings): 앱의 모든 설정 값(테마, 창 위치, 토큰 등)을 담고 있는 객체.
함수 (메서드)
| 함수이름(파라미터) | 기능 |
| SettingsService() |
클래스 생성자. 설정 폴더를 확인하고 settings.json 파일에서 설정을 로드.
|
| OnPropertyChanged(string propertyName) |
INotifyPropertyChanged 구현. 속성 값이 변경되었음을 UI에 알림.
|
| Load() |
settings.json 파일에서 JSON 데이터를 읽어 AppSettings 객체로 역직렬화. 파일이 없거나 오류 시 기본 객체 반환.
|
| Save() |
현재 Settings 객체를 JSON으로 직렬화하여 settings.json 파일에 저장.
|
| SetStartupAsync(bool isEnabled) |
Windows App SDK의 StartupTask API를 사용해 '윈도우 시작 시 자동 실행'을 활성화 또는 비활성화.
|
| IsStartupEnabled() |
윈도우 시작 시 자동 실행'이 현재 활성화되어 있는지 여부를 반환.
|
4. Models
4.1. AppSettings
// 모델(데이터 구조) 관련 클래스를 포함하는 네임스페이스
namespace Todo.Models
{
// 애플리케이션의 모든 설정을 저장하기 위한 데이터 클래스 (POCO)
public class AppSettings
{
// UI 테마 설정 ("Light" 또는 "Dark"). 기본값은 "Light".
public string Theme { get; set; } = "Light";
// '항상 위에 표시' 기능 활성화 여부. 기본값은 false.
public bool IsAlwaysOnTop { get; set; } = false;
// 알림 기능 전체 활성화 여부. 기본값은 true.
public bool AreNotificationsEnabled { get; set; } = true;
// --- 창 크기 및 위치 저장 ---
// 마지막으로 사용된 창의 높이. 기본값은 700.
public double WindowHeight { get; set; } = 700;
// 마지막으로 사용된 창의 너비. 기본값은 900.
public double WindowWidth { get; set; } = 900;
// 마지막으로 사용된 창의 Y 좌표. 기본값은 100.
public double WindowTop { get; set; } = 100;
// 마지막으로 사용된 창의 X 좌표. 기본값은 100.
public double WindowLeft { get; set; } = 100;
// 마지막으로 선택했던 탭의 인덱스. 기본값은 0 (미진행).
public int LastSelectedTabIndex { get; set; } = 0;
// --- 카카오 API 관련 설정 ---
// 카카오 API 액세스 토큰. null일 수 있음.
public string? KakaoAccessToken { get; set; }
// 카카오 API 리프레시 토큰. null일 수 있음.
public string? KakaoRefreshToken { get; set; }
// 카카오 액세스 토큰의 만료 시각 (UTC 기준).
public DateTime KakaoTokenExpiresAt { get; set; }
}
}
전역 변수 (속성)
- Theme (string): UI 테마를 저장 ("Light" / "Dark").
- IsAlwaysOnTop (bool): '항상 위에 표시' 옵션의 활성화 상태.
- AreNotificationsEnabled (bool): 앱의 전체 알림 기능 활성화 상태.
- WindowHeight (double): 앱 창의 높이.
- WindowWidth (double): 앱 창의 너비.
- WindowTop (double): 앱 창의 화면 상단에서의 Y 좌표.
- WindowLeft (double): 앱 창의 화면 좌측에서의 X 좌표.
- LastSelectedTabIndex (int): 마지막으로 선택했던 탭의 인덱스.
- KakaoAccessToken (string?): 카카오 API 인증을 위한 액세스 토큰.
- KakaoRefreshToken (string?): 카카오 액세스 토큰 갱신을 위한 리프레시 토큰.
- KakaoTokenExpiresAt (DateTime): 카카오 액세스 토큰의 만료 시각.
4.2. KakaoTokenResponse
// 카카오 토큰 API의 응답 데이터를 담기 위한 모델 클래스
public class KakaoTokenResponse
{
// JSON 응답의 "access_token" 필드와 매핑되는 속성
[JsonProperty("access_token")]
public string? AccessToken { get; set; }
// JSON 응답의 "refresh_token" 필드와 매핑되는 속성
[JsonProperty("refresh_token")]
public string? RefreshToken { get; set; }
// 액세스 토큰의 만료 시간(초 단위)을 나타내는 "expires_in" 필드와 매핑되는 속성
[JsonProperty("expires_in")]
public int ExpiresIn { get; set; }
}
전역 변수 (속성)
- AccessToken (string?): 카카오 서버로부터 발급받은 액세스 토큰. API 호출 시 인증에 사용됨.
- RefreshToken (string?): 액세스 토큰이 만료되었을 때 새로 발급받기 위해 사용되는 리프레시 토큰.
- ExpiresIn (int): 액세스 토큰의 유효 기간 (초 단위).
4.3. Platform
// 모델(데이터 구조) 관련 클래스를 포함하는 네임스페이스
namespace Todo.Models
{
// 할 일 항목(TodoItem)이 속한 플랫폼(예: 업무, 개인, 학습)을 나타내는 데이터 모델 클래스
// 이 클래스는 Entity Framework Core에 의해 데이터베이스의 테이블과 매핑될 가능성이 높음
public class Platform
{
// 플랫폼의 고유 식별자. 데이터베이스의 기본 키(Primary Key)로 사용됨
public int Id { get; set; }
// 플랫폼의 이름. 기본값은 빈 문자열
public string Name { get; set; } = string.Empty;
}
}
전역 변수 (속성)
- Id (int): 플랫폼의 고유 식별자 (Primary Key).
- Name (string): 플랫폼의 이름.
4.4. TodoItem
// 모델(데이터 구조) 관련 클래스를 포함하는 네임스페이스
namespace Todo.Models
{
// 하나의 할 일 항목을 나타내는 핵심 모델 클래스.
// INotifyPropertyChanged 인터페이스를 구현하여 속성 값이 변경될 때 UI에 자동으로 알릴 수 있음.
public class TodoItem : INotifyPropertyChanged
{
// 속성 변경을 알리기 위한 이벤트 핸들러
public event PropertyChangedEventHandler? PropertyChanged;
// PropertyChanged 이벤트를 안전하게 발생시키는 보호된(protected) 헬퍼 메서드
protected void OnPropertyChanged(string propertyName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
// --- 속성(Property) 및 백업 필드(Backing Field) ---
// Id 속성의 비공개 백업 필드
private int _id;
// 할 일 항목의 고유 식별자 (데이터베이스의 기본 키)
public int Id
{
get => _id;
set { _id = value; OnPropertyChanged(nameof(Id)); } // 값이 변경되면 UI에 알림
}
// Title 속성의 비공개 백업 필드
private string _title = string.Empty;
// 할 일 항목의 제목
public string Title
{
get => _title;
set { _title = value; OnPropertyChanged(nameof(Title)); }
}
// PlatformId 속성의 비공개 백업 필드
private int? _platformId;
// 연관된 Platform의 외래 키(Foreign Key). 플랫폼이 없을 수도 있으므로 nullable.
public int? PlatformId
{
get => _platformId;
set { _platformId = value; OnPropertyChanged(nameof(PlatformId)); }
}
// Platform 속성의 비공개 백업 필드
private Platform? _platform;
// 연관된 Platform 객체에 대한 참조 (네비게이션 속성). Entity Framework Core가 관계를 매핑하는 데 사용.
public Platform? Platform
{
get => _platform;
set { _platform = value; OnPropertyChanged(nameof(Platform)); }
}
// CreatedAt 속성의 비공개 백업 필드. 기본값은 현재 UTC 시간.
private DateTime _createdAt = DateTime.UtcNow;
// 할 일 항목이 생성된 시각
public DateTime CreatedAt
{
get => _createdAt;
set { _createdAt = value; OnPropertyChanged(nameof(CreatedAt)); }
}
// StartDate 속성의 비공개 백업 필드
private DateTime? _startDate;
// 작업 시작 예정일. 날짜가 지정되지 않을 수 있으므로 nullable.
public DateTime? StartDate
{
get => _startDate;
set { _startDate = value; OnPropertyChanged(nameof(StartDate)); }
}
// DueDate 속성의 비공개 백업 필드
private DateTime? _dueDate;
// 작업 마감 예정일. 날짜가 지정되지 않을 수 있으므로 nullable.
public DateTime? DueDate
{
get => _dueDate;
set { _dueDate = value; OnPropertyChanged(nameof(DueDate)); }
}
// Status 속성의 비공개 백업 필드. 기본값은 '시작 안 함'.
private WorkStatus _status = WorkStatus.NotStarted;
// 작업의 현재 진행 상태 (WorkStatus 열거형 사용)
public WorkStatus Status
{
get => _status;
set { _status = value; OnPropertyChanged(nameof(Status)); }
}
// CompletedDate 속성의 비공개 백업 필드
private DateTime? _completedDate;
// 작업이 완료된 시각. 완료되지 않았으면 null.
public DateTime? CompletedDate
{
get => _completedDate;
set { _completedDate = value; OnPropertyChanged(nameof(CompletedDate)); }
}
}
}
전역 변수 (속성)
- Id (int): 할 일 항목의 고유 식별자 (Primary Key).
- Title (string): 할 일의 제목 또는 내용.
- PlatformId (int?): 관련된 Platform의 ID (Foreign Key).
- Platform (Platform?): 관련된 Platform 객체에 대한 탐색 속성(Navigation Property).
- CreatedAt (DateTime): 항목이 생성된 시각.
- StartDate (DateTime?): 작업 시작 예정일.
- DueDate (DateTime?): 작업 마감 예정일.
- Status (WorkStatus): 작업의 현재 상태 ('시작 안 함', '진행 중', '완료').
- CompletedDate (DateTime?): 작업이 완료된 시각.
함수 (메서드)
| 함수이름(파라미터) | 기능 |
| OnPropertyChanged(string propertyName) |
속성 값이 변경되었을 때 PropertyChanged 이벤트를 발생시켜 UI에 알림.
|
4.5. WorkStatus
// 모델(데이터 구조) 관련 클래스를 포함하는 네임스페이스
namespace Todo.Models
{
// 할 일 항목(TodoItem)의 진행 상태를 나타내는 열거형(Enumeration)
public enum WorkStatus
{
// 작업이 아직 시작되지 않은 상태
NotStarted,
// 작업이 현재 진행 중인 상태
InProgress,
// 작업이 완료된 상태
Completed
}
}
열거형 멤버 (Enum Members)
- NotStarted: 작업을 아직 시작하지 않았음을 나타내는 상태.
- InProgress: 작업을 시작하여 현재 진행하고 있음을 나타내는 상태.
- Completed: 작업을 완료했음을 나타내는 상태.
5. Helpers
5.1. EnumBindingSource
// 애플리케이션의 헬퍼(도우미) 클래스를 포함하는 네임스페이스
namespace Todo.Helpers
{
// XAML에서 열거형(Enum)을 데이터 바인딩 소스로 쉽게 사용할 수 있도록 하는 사용자 정의 마크업 확장(Markup Extension) 클래스
public class EnumBindingSource : MarkupExtension
{
// XAML에서 바인딩할 열거형의 타입을 지정하는 속성
public Type? EnumType { get; set; }
// 마크업 확장이 XAML 파서에 의해 실행될 때 호출되는 핵심 메서드
public override object ProvideValue(IServiceProvider serviceProvider)
{
// EnumType 속성이 설정되지 않았거나, 설정된 타입이 열거형이 아닌 경우 예외를 발생
if (EnumType == null || !EnumType.IsEnum)
throw new ArgumentException("EnumType must not be null and of type Enum");
// 지정된 열거형의 모든 값들을 배열 형태로 반환. 이 값들은 ComboBox 등의 ItemsSource로 사용됨
return Enum.GetValues(EnumType);
}
}
}
전역 변수 (속성)
- EnumType (Type?): XAML에서 바인딩의 소스로 사용할 열거형의 타입을 지정하는 속성.
함수 (메서드)
| 함수이름(파라미터) | 기능 |
| ProvideValue(IServiceProvider serviceProvider) |
MarkupExtension의 핵심 메서드로, XAML에서 이 확장을 사용할 때 실행됨. EnumType으로 지정된 열거형의 모든 값들을 배열로 반환하여 데이터 바인딩 소스로 제공.
|
6. Data
6.1. AppDbContext
using Microsoft.EntityFrameworkCore;
// 데이터 접근 관련 클래스를 포함하는 네임스페이스
namespace Todo.Data
{
// 데이터베이스 테이블과 매핑될 모델 클래스가 있는 네임스페이스
using Todo.Models;
// Entity Framework Core의 DbContext를 상속받아 데이터베이스 세션을 나타내는 클래스.
// 이 클래스를 통해 데이터베이스와 상호작용(조회, 추가, 수정, 삭제)함.
public class AppDbContext : DbContext
{
// AppDbContext의 생성자.
// 의존성 주입을 통해 데이터베이스 연결 문자열 등의 설정을 담고 있는 DbContextOptions를 받아서
// 부모 클래스인 DbContext에 전달함.
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
// TodoItems 테이블에 접근하기 위한 DbSet.
// 이를 통해 TodoItem 객체들을 조회, 추가, 수정, 삭제(CRUD)할 수 있음.
public DbSet<TodoItem> TodoItems { get; set; }
// Platforms 테이블에 접근하기 위한 DbSet.
// 이를 통해 Platform 객체들을 조회, 추가, 수정, 삭제(CRUD)할 수 있음.
public DbSet<Platform> Platforms { get; set; }
}
}
전역 변수 (속성)
- TodoItems (DbSet<TodoItem>): TodoItem 엔티티(데이터베이스의 TodoItems 테이블)에 대한 접근을 제공.
- Platforms (DbSet<Platform>): Platform 엔티티(데이터베이스의 Platforms 테이블)에 대한 접근을 제공.
함수 (메서드)
| 함수이름(파라미터) | 기능 |
| AppDbContext(DbContextOptions<AppDbContext> options) |
클래스 생성자. 데이터베이스 연결 정보 등 DbContext의 설정을 의존성 주입을 통해 받아 초기화.
|
6.2. DesignTimeDbContextFactory
// 데이터 접근 관련 클래스를 포함하는 네임스페이스
namespace Todo.Data
{
// EF Core의 디자인 타임 도구(예: 'dotnet ef migrations add')가 DbContext 인스턴스를 생성하는 방법을 제공하는 팩토리 클래스.
// 이 클래스는 애플리케이션 실행 시점이 아닌, 개발 및 마이그레이션 단계에서만 사용됨.
public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
// IDesignTimeDbContextFactory 인터페이스의 유일한 메서드. DbContext 인스턴스를 생성하여 반환.
public AppDbContext CreateDbContext(string[] args)
{
// ConfigurationBuilder를 사용하여 appsettings.json 파일에서 설정을 로드
IConfigurationRoot configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory()) // 기본 경로를 현재 디렉터리로 설정
.AddJsonFile("appsettings.json") // 설정 파일 지정
.Build(); // 구성 빌드
// DbContext 옵션을 구성하기 위한 빌더 생성
var builder = new DbContextOptionsBuilder<AppDbContext>();
// appsettings.json에서 "DefaultConnection"이라는 이름의 연결 문자열을 가져옴
var connectionString = configuration.GetConnectionString("DefaultConnection");
// PostgreSQL 데이터베이스 공급자(Npgsql)를 사용하도록 설정하고 연결 문자열을 전달
builder.UseNpgsql(connectionString);
// 구성된 옵션을 사용하여 AppDbContext의 새 인스턴스를 생성하여 반환
return new AppDbContext(builder.Options);
}
}
}
함수 (메서드)
| 함수이름(파라미터) | 기능 |
| CreateDbContext(string[] args) |
Entity Framework Core의 디자인 타임 도구가 호출하는 메서드. appsettings.json에서 데이터베이스 연결 문자열을 읽어 AppDbContext의 인스턴스를 생성하고 구성하여 반환.
|
7. Converters
7.1. UtcToLocalTimeConverter
using System.Globalization;
using System.Windows.Data;
// XAML 컨버터 클래스를 포함하는 네임스페이스
namespace Todo.Converters
{
// UTC(협정 세계시) 시간을 사용자의 로컬 시간으로 변환하고, 특정 형식의 문자열로 만들어주는 WPF 값 변환기(Value Converter)
public class UtcToLocalTimeConverter : IValueConverter
{
// 소스(데이터 모델)에서 타겟(UI)으로 값을 변환할 때 호출되는 메서드
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
// 입력값이 DateTime 타입이 아니면 변환하지 않고 null 반환
if (value is not DateTime dateTime)
{
return null;
}
DateTime localTime;
// DateTime 객체의 종류(Kind)가 지정되지 않은 경우(Unspecified)
// 데이터베이스에서 읽어온 시간은 Kind가 지정되지 않는 경우가 많음
if (dateTime.Kind == DateTimeKind.Unspecified)
{
// 해당 시간을 UTC로 간주한 후, 로컬 시간으로 변환
localTime = DateTime.SpecifyKind(dateTime, DateTimeKind.Utc).ToLocalTime();
}
else
{
// 이미 Kind가 지정된 경우(Utc 또는 Local)에는 바로 로컬 시간으로 변환
localTime = dateTime.ToLocalTime();
}
// 변환된 로컬 시간을 "yyyy-MM-dd HH:mm" 형식의 문자열로 만들어 반환
return localTime.ToString("yyyy-MM-dd HH:mm");
}
// 타겟(UI)에서 소스(데이터 모델)로 값을 역변환할 때 호출되는 메서드
public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
// 이 컨버터는 단방향(데이터 -> UI)으로만 사용되므로, 역방향 변환은 구현하지 않음
throw new NotImplementedException();
}
}
}
함수 (메서드)
| 함수이름(파라미터) | 기능 |
| Convert(object value, Type targetType, object parameter, CultureInfo culture) |
DateTime 값을 입력받아 시스템의 로컬 시간으로 변환한 뒤, "yyyy-MM-dd HH:mm" 형식의 문자열로 만들어 UI에 표시하기 위해 반환.
|
| ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) |
UI의 값을 다시 데이터 모델의 값으로 변환하는 기능. 이 컨버터에서는 사용되지 않으므로 NotImplementedException을 발생시킴.
|
8. App
<Application x:Class="Todo.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:Todo">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/MahApps.Metro;component/Styles/Controls.xaml" />
<ResourceDictionary Source="pack://application:,,,/MahApps.Metro;component/Styles/Fonts.xaml" />
<ResourceDictionary Source="pack://application:,,,/MahApps.Metro;component/Styles/Themes/Light.Blue.xaml" />
<ResourceDictionary Source="Themes/LightTheme.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Windows.AppNotifications;
using System.Windows;
using System.Windows.Media;
using Microsoft.Windows.AppLifecycle;
// WPF 애플리케이션의 메인 클래스가 포함된 네임스페이스
namespace Todo
{
// 내부 using 선언으로 코드 간결화
using ControlzEx.Theming;
using Todo.Data;
using Todo.Services;
using Todo.ViewModels;
using Todo.Views;
// 애플리케이션의 주 진입점 및 수명 주기를 관리하는 클래스
public partial class App : Application
{
// 의존성 주입(DI) 컨테이너 역할을 하는 IHost. 앱 전역에서 서비스에 접근할 수 있도록 static으로 선언.
public static IHost? AppHost { get; private set; }
// --- 앱 중복 실행 방지를 위한 필드 ---
// Mutex: 시스템 전역에서 유일한 이름을 가진 객체로, 앱이 이미 실행 중인지 확인하는 데 사용됨.
private Mutex? _mutex;
// EventWaitHandle: 이미 실행 중인 앱에 신호를 보내 창을 활성화하도록 요청하는 데 사용됨.
private EventWaitHandle? _eventWaitHandle;
// App 클래스 생성자
public App()
{
// 의존성 주입을 위한 호스트(Host)를 구성하고 빌드
AppHost = Host.CreateDefaultBuilder()
// appsettings.json 파일을 읽어오도록 구성
.ConfigureAppConfiguration((hostContext, config) =>
{
config.SetBasePath(AppContext.BaseDirectory);
config.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true);
})
// 서비스(클래스)들을 의존성 주입 컨테이너에 등록
.ConfigureServices((hostContext, services) =>
{
// 요청할 때마다 새 인스턴스 생성 (Transient)
services.AddTransient<MainWindow>();
// DB 컨텍스트 등록. appsettings.json의 연결 문자열을 사용하여 PostgreSQL에 연결.
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(hostContext.Configuration.GetConnectionString("DefaultConnection")));
// 앱 수명 주기 동안 단 하나의 인스턴스만 생성 (Singleton)
services.AddSingleton<MainViewModel>();
services.AddSingleton<SettingsService>();
services.AddSingleton<KakaoService>();
services.AddSingleton<NotificationScheduler>();
}).Build();
}
// 애플리케이션 시작 시 호출되는 메서드
protected override async void OnStartup(StartupEventArgs e)
{
// --- 중복 실행 방지 로직 ---
string mutexName = "TodoAppMutex_UNIQUE_GUID"; // 시스템에서 유일해야 하는 뮤텍스 이름
string eventName = "TodoAppEvent_UNIQUE_GUID"; // 시스템에서 유일해야 하는 이벤트 이름
bool createdNew;
_mutex = new Mutex(true, mutexName, out createdNew);
_eventWaitHandle = new EventWaitHandle(false, EventResetMode.AutoReset, eventName);
// createdNew가 false이면 이미 다른 인스턴스가 실행 중이라는 의미
if (!createdNew)
{
_eventWaitHandle.Set(); // 이미 실행 중인 인스턴스에 신호를 보냄
Application.Current.Shutdown(); // 현재 인스턴스는 종료
return;
}
// --- 첫 인스턴스 실행 로직 ---
RegisterSignalListener(); // 다른 인스턴스로부터 신호를 받을 리스너 등록
await AppHost!.StartAsync(); // DI 호스트 시작
// 필요한 서비스들을 DI 컨테이너에서 가져옴
var settingsService = AppHost.Services.GetRequiredService<SettingsService>();
var kakaoService = AppHost.Services.GetRequiredService<KakaoService>();
await kakaoService.TryRefreshAccessTokenAsync(); // 시작 시 카카오 토큰 갱신 시도
ApplyTheme(settingsService.Settings.Theme); // 저장된 테마 적용
var startupForm = AppHost.Services.GetRequiredService<MainWindow>(); // 메인 창 인스턴스 생성
// 앱이 MSIX로 패키징되었는지 확인
bool isPackaged = AppInstance.GetCurrent().IsCurrent;
// 패키징되지 않은 경우(예: 디버그 모드) 수동으로 알림 등록
if (!isPackaged)
{
try
{
AppNotificationManager.Default.Register();
}
catch (Exception ex)
{
MessageBox.Show($"Alarm registration failed: {ex.Message}");
}
}
// 알림 스케줄러를 가져와서 시작
var scheduler = AppHost.Services.GetRequiredService<NotificationScheduler>();
scheduler.Start();
startupForm.Show(); // 메인 창 표시
base.OnStartup(e);
}
// 애플리케이션의 테마를 동적으로 변경하는 메서드
public void ApplyTheme(string themeName)
{
// MahApps.Metro의 테마 매니저를 사용하여 기본 테마와 강조 색상을 변경
ThemeManager.Current.ChangeTheme(this, themeName == "Dark" ? "Dark.Cobalt" : "Light.Cobalt");
// 기본 강조(Accent) 색상을 사용자 정의 색상으로 덮어씀
Application.Current.Resources["MahApps.Brushes.Accent"] = new SolidColorBrush(Color.FromRgb(0xbb, 0x86, 0xfc));
// 이전에 적용된 사용자 정의 테마 리소스가 있으면 제거
var oldTheme = Resources.MergedDictionaries.FirstOrDefault(d => d.Source != null && d.Source.OriginalString.Contains("Theme.xaml"));
if (oldTheme != null)
{
Resources.MergedDictionaries.Remove(oldTheme);
}
// 새 테마 경로를 결정하고 리소스 사전에 추가
string themePath = themeName == "Dark" ? "Themes/DarkTheme.xaml" : "Themes/LightTheme.xaml";
Resources.MergedDictionaries.Add(new ResourceDictionary
{
Source = new Uri(themePath, UriKind.Relative)
});
}
// 애플리케이션 종료 시 호출되는 메서드
protected override async void OnExit(ExitEventArgs e)
{
_mutex?.ReleaseMutex(); // 뮤텍스 해제
_eventWaitHandle?.Close(); // 이벤트 핸들 해제
await AppHost!.StopAsync(); // DI 호스트 정지
base.OnExit(e);
}
// 다른 인스턴스로부터 '창 활성화' 신호를 받기 위한 리스너를 등록하고 실행하는 메서드
private void RegisterSignalListener()
{
// 백그라운드 스레드에서 실행
Task.Run(() =>
{
// EventWaitHandle이 Set() 될 때까지 무한 대기
while (_eventWaitHandle.WaitOne())
{
// 신호를 받으면 UI 스레드에서 창 활성화 로직을 실행
Current.Dispatcher.Invoke(() =>
{
var mainWindow = Current.MainWindow as MainWindow;
if (mainWindow != null)
{
// 창이 숨겨져 있으면 보이게 함
if (mainWindow.Visibility == Visibility.Hidden)
{
mainWindow.Show();
}
// 창이 최소화되어 있으면 보통 상태로 복원
if (mainWindow.WindowState == WindowState.Minimized)
{
mainWindow.WindowState = WindowState.Normal;
}
// 창을 활성화하고 맨 앞으로 가져옴
mainWindow.Activate();
mainWindow.Topmost = true;
mainWindow.Topmost = false;
mainWindow.Focus();
}
});
}
});
}
}
}
전역 변수 (필드 및 속성)
- AppHost (IHost?): 의존성 주입(DI) 컨테이너. 앱 전역에서 서비스 인스턴스를 관리하고 제공.
- _mutex (Mutex?): 애플리케이션의 중복 실행을 방지하기 위한 시스템 전역 뮤텍스.
- _eventWaitHandle (EventWaitHandle?): 중복 실행 시, 이미 실행 중인 첫 번째 인스턴스에 신호를 보내 창을 활성화하도록 요청하는 데 사용.
함수 (메서드)
| 함수이름(파라미터) | 기능 |
| App() |
클래스 생성자. 의존성 주입 컨테이너(AppHost)를 구성하고 빌드. appsettings.json 로드 설정 및 각종 서비스(DbContext, ViewModel, Service 등)를 등록.
|
| OnStartup(StartupEventArgs e) |
앱 시작 시의 주 진입점. 중복 실행 방지 로직을 수행하고, 첫 인스턴스인 경우 DI 컨테이너 시작, 테마 적용, 알림 등록, 메인 창 표시 등의 초기화를 진행.
|
| ApplyTheme(string themeName) |
지정된 테마 이름("Light" 또는 "Dark")에 따라 애플리케이션의 전체적인 UI 테마와 강조 색상을 동적으로 변경.
|
| OnExit(ExitEventArgs e) |
앱 종료 시 호출됨. 사용했던 Mutex와 EventWaitHandle 리소스를 해제하고 DI 컨테이너를 정상적으로 중지.
|
| RegisterSignalListener() |
백그라운드 스레드를 생성하여, 다른 인스턴스로부터 신호를 받을 때마다 현재 인스턴스의 메인 창을 활성화하여 사용자에게 보여주는 역할을 수행.
|
Server Files
1. morning_report
#!/bin/bash
# --- 설정 ---
# PostgreSQL 데이터베이스 사용자 이름
DB_USER=""
# PostgreSQL 비밀번호. 환경 변수로 설정하여 psql이 자동으로 사용하도록 함
export PGPASSWORD=""
# 연결할 데이터베이스 이름
DB_NAME=""
# 메시지 전송을 담당하는 외부 스크립트의 경로
SEND_SCRIPT_PATH="/usr/local/bin/send_message.sh"
# --- 1. 보고서 내용(DB 쿼리) 작성 ---
# psql 명령어를 사용하여 'TodoItems' 테이블에서 'Status'가 0 (미진행)인 항목들의 'Title'을 조회
# -U: 사용자 지정, -d: 데이터베이스 지정, -t: 튜플 전용 모드 (헤더와 푸터 없이 데이터만 출력)
NOT_STARTED_LIST=$(psql -U $DB_USER -d $DB_NAME -t -c "SELECT \"Title\" FROM \"TodoItems\" WHERE \"Status\" = 0;")
# 'Status'가 1 (진행 중)인 항목들의 'Title'을 조회
IN_PROGRESS_LIST=$(psql -U $DB_USER -d $DB_NAME -t -c "SELECT \"Title\" FROM \"TodoItems\" WHERE \"Status\" = 1;")
# --- 2. 보고서 형식(메시지 포맷팅) 맞추기 ---
# 최종적으로 전송될 메시지의 제목 부분 초기화
MESSAGE="📢 오늘 할 일 목록 (아침 브리핑)"
# 줄 바꿈(\n)을 포함하여 메시지에 [미진행] 섹션 추가
MESSAGE+=$'\n\n[미진행]\n'
# NOT_STARTED_LIST 변수가 비어있는지 확인 (-z)
if [ -z "$NOT_STARTED_LIST" ]; then
# 비어있으면 "- 없음" 텍스트 추가
MESSAGE+="- 없음"
else
# 내용이 있으면, 각 줄의 시작(^)에 " - "를 추가하여 리스트 형식으로 만듦 (sed 명령어 사용)
MESSAGE+=$(echo "$NOT_STARTED_LIST" | sed 's/^/ - /')
fi
# [진행 중] 섹션 추가 및 위와 동일한 로직으로 포맷팅
MESSAGE+=$'\n\n[진행 중]\n'
if [ -z "$IN_PROGRESS_LIST" ]; then MESSAGE+="- 없음"; else MESSAGE+=$(echo "$IN_PROGRESS_LIST" | sed 's/^/ - /'); fi
# --- 3. 우편 배달부에게 보고서 전달 ---
# 완성된 메시지를 인자로 하여 외부 메시지 전송 스크립트를 실행
bash "$SEND_SCRIPT_PATH" "$MESSAGE"
전역 변수
- DB_USER: PostgreSQL 데이터베이스 접속에 사용할 사용자 이름.
- PGPASSWORD: PostgreSQL 접속 비밀번호. export를 통해 환경 변수로 설정됨.
- DB_NAME: 접속할 데이터베이스의 이름.
- SEND_SCRIPT_PATH: 메시지 발송을 처리하는 외부 셸 스크립트의 경로.
- NOT_STARTED_LIST: DB에서 조회한 '미진행' 상태의 작업 목록을 저장.
- IN_PROGRESS_LIST: DB에서 조회한 '진행 중' 상태의 작업 목록을 저장.
- MESSAGE: 최종적으로 발송될 보고서의 전체 내용을 저장.
주요 명령어
| 명령어(파라미터) | 기능 |
| export PGPASSWORD="" |
PGPASSWORD 변수를 환경 변수로 설정하여 psql 명령어가 비밀번호를 자동으로 인식하게 함.
|
| psql -U $DB_USER -d $DB_NAME -t -c "..." |
PostgreSQL 데이터베이스에 접속하여 지정된 SQL 쿼리(-c)를 실행하고 결과만(-t) 반환.
|
| [ -z "$VAR" ] |
셸의 조건문. 변수 $VAR의 내용이 비어있는지(zero-length) 확인.
|
| `echo "$VAR" | sed 's/^/ - /'` |
| bash "$SEND_SCRIPT_PATH" "$MESSAGE" |
지정된 경로의 셸 스크립트를 bash로 실행하며, 완성된 $MESSAGE를 인자로 전달.
|
2. nightly_report
#!/bin/bash
# --- 설정 ---
# PostgreSQL 데이터베이스 사용자 이름
DB_USER=""
# PostgreSQL 비밀번호. 환경 변수로 설정하여 psql이 자동으로 사용하도록 함
export PGPASSWORD="!"
# 연결할 데이터베이스 이름
DB_NAME=""
# 메시지 전송을 담당하는 외부 스크립트의 경로
SEND_SCRIPT_PATH="/usr/local/bin/send_message.sh"
# --- 1. 보고서 내용(DB 쿼리) 작성 ---
# 어제 날짜를 "YYYY-MM-DD" 형식으로 생성하여 YESTERDAY 변수에 저장
YESTERDAY=$(date -d "yesterday" +%Y-%m-%d)
# psql 명령어를 사용하여 'TodoItems' 테이블에서 어제 완료된 항목들의 'Title'을 조회
# 참고: WHERE 절의 조건이 불완전하여 구문 오류가 발생할 수 있음.
COMPLETED_LIST=$(psql -U $DB_USER -d $DB_NAME -t -c "SELECT \"Title\" FROM \"TodoItems\" WHERE \"CompletedDate\"::date >
# --- 2. 보고서 형식(메시지 포맷팅)---
# 최종적으로 전송될 메시지의 제목 부분 초기화. YESTERDAY 변수를 포함.
MESSAGE="🌙 $YESTERDAY 완료 작업 리포트"
# 줄 바꿈 추가
MESSAGE+=$'\n'
# COMPLETED_LIST 변수가 비어있는지 확인
if [ -z "$COMPLETED_LIST" ]; then
# 비어있으면 "- 없음" 텍스트 추가
MESSAGE+="- 없음"
else
# 내용이 있으면, 각 줄의 시작(^)에 "✅ "를 추가하여 리스트 형식으로 만듦 (sed 명령어 사용)
MESSAGE+=$(echo "$COMPLETED_LIST" | sed 's/^/✅ /')
fi
# 완성된 메시지를 인자로 하여 외부 메시지 전송 스크립트를 실행
bash "$SEND_SCRIPT_PATH" "$MESSAGE"
전역 변수
- DB_USER: PostgreSQL 데이터베이스 접속에 사용할 사용자 이름.
- PGPASSWORD: PostgreSQL 접속 비밀번호. export를 통해 환경 변수로 설정됨.
- DB_NAME: 접속할 데이터베이스의 이름.
- SEND_SCRIPT_PATH: 메시지 발송을 처리하는 외부 셸 스크립트의 경로.
- YESTERDAY: 어제 날짜를 "YYYY-MM-DD" 형식으로 저장.
- COMPLETED_LIST: DB에서 조회한 '어제 완료' 상태의 작업 목록을 저장.
- MESSAGE: 최종적으로 발송될 보고서의 전체 내용을 저장.
주요 명령어
| 명령어(파라미터) | 기능 |
| date -d "yesterday" +%Y-%m-%d |
시스템의 어제 날짜를 지정된 형식(YYYY-MM-DD)으로 출력.
|
| psql -U $DB_USER -d $DB_NAME -t -c "..." |
PostgreSQL 데이터베이스에 접속하여 지정된 SQL 쿼리(-c)를 실행하고 결과만(-t) 반환.
|
| [ -z "$VAR" ] |
셸의 조건문. 변수 $VAR의 내용이 비어있는지(zero-length) 확인.
|
| echo "$VAR" | sed 's/^/✅ /' |
echo로 변수 내용을 출력하고, 파이프(`
|
| bash "$SEND_SCRIPT_PATH" "$MESSAGE" |
지정된 경로의 셸 스크립트를 bash로 실행하며, 완성된 $MESSAGE를 인자로 전달.
|
3. refresh_token
#!/bin/bash
# --- 설정 ---
# 카카오 토큰이 저장된 JSON 파일의 경로
TOKEN_FILE="/usr/local/bin/kakao_token.json"
# 카카오 REST API 클라이언트 ID(API 키)
CLIENT_ID=""
# 토큰 갱신을 요청할 카카오 인증 서버 URL
REFRESH_URL="https://kauth.kakao.com/oauth/token"
# --- 토큰 파일 존재 확인 ---
# -f: 파일이 존재하고 일반 파일인지 확인
if [ ! -f "$TOKEN_FILE" ]; then
echo "오류: 토큰 파일($TOKEN_FILE)이 없습니다. 최초 인증이 필요합니다."
exit 1 # 스크립트 비정상 종료
fi
# --- 토큰 정보 읽기 ---
# jq: JSON 파서. -r 옵션은 raw output(따옴표 없이)을 의미. .refresh_token 키의 값을 읽어옴.
REFRESH_TOKEN=$(jq -r '.refresh_token' "$TOKEN_FILE")
# .expires_at 키의 값을 읽어옴 (Unix 타임스탬프)
EXPIRES_AT=$(jq -r '.expires_at' "$TOKEN_FILE")
# 현재 시간을 Unix 타임스탬프(1970년 1월 1일 이후 경과된 초)로 가져옴
NOW=$(date +%s)
# --- 만료 여부 확인 및 갱신 ---
# 현재 시간이 만료 시간보다 크거나 같은지(-ge) 확인
if [[ "$NOW" -ge "$EXPIRES_AT" ]]; then
echo "토큰이 만료되어 갱신을 시도합니다..."
# curl: HTTP 요청을 보내는 도구. -s(silent) 옵션으로 진행률 표시 숨김, -X POST로 POST 요청 지정, -d로 폼 데이터 전송
RESPONSE=$(curl -s -X POST "$REFRESH_URL" \
-d "grant_type=refresh_token" \
-d "client_id=$CLIENT_ID" \
-d "refresh_token=$REFRESH_TOKEN")
# 응답(RESPONSE)에서 새로운 액세스 토큰을 추출
NEW_ACCESS_TOKEN=$(echo "$RESPONSE" | jq -r .access_token)
# 추출한 새 액세스 토큰이 "null" 문자열이면 갱신 실패로 간주
if [[ "$NEW_ACCESS_TOKEN" == "null" ]]; then
echo "오류: 액세스 토큰 갱신에 실패했습니다."
echo "응답: $RESPONSE"
exit 1
fi
# 응답에서 새 토큰의 유효 기간(초)을 추출
EXPIRES_IN=$(echo "$RESPONSE" | jq -r .expires_in)
# 새 만료 시각 계산: 현재시간 + 유효기간 - 300초(5분 여유)
NEW_EXPIRES_AT=$(( $(date +%s) + EXPIRES_IN - 300 ))
# 응답에서 새 리프레시 토큰을 추출 시도. 없으면(null) 빈 문자열 반환 (// empty)
NEW_REFRESH_TOKEN=$(echo "$RESPONSE" | jq -r '.refresh_token // empty')
# 만약 새 리프레시 토큰이 반환되지 않았다면, 기존 리프레시 토큰을 계속 사용
if [[ -z "$NEW_REFRESH_TOKEN" ]]; then
NEW_REFRESH_TOKEN=$REFRESH_TOKEN
fi
# jq를 사용하여 새로운 JSON 객체를 생성하고 토큰 파일을 덮어씀
# -n: 입력 없이 JSON 생성, --arg/--argjson: 변수를 jq 내부 변수로 전달
jq -n \
--arg at "$NEW_ACCESS_TOKEN" \
--arg rt "$NEW_REFRESH_TOKEN" \
--argjson exp $NEW_EXPIRES_AT \
'{access_token:$at, refresh_token:$rt, expires_at:$exp}' > "$TOKEN_FILE"
echo "토큰이 성공적으로 갱신되었습니다."
else
echo "토큰이 아직 유효합니다."
fi
전역 변수
- TOKEN_FILE: 액세스 토큰, 리프레시 토큰, 만료 시각이 저장된 JSON 파일의 경로.
- CLIENT_ID: 카카오 애플리케이션의 REST API 키.
- REFRESH_URL: 토큰 갱신 API의 엔드포인트 URL.
- REFRESH_TOKEN: JSON 파일에서 읽어온 현재 리프레시 토큰.
- EXPIRES_AT: JSON 파일에서 읽어온 현재 액세스 토큰의 만료 시각 (Unix 타임스탬프).
- NOW: 스크립트 실행 시점의 현재 시각 (Unix 타임스탬프).
- RESPONSE: curl을 통해 카카오 서버로부터 받은 토큰 갱신 API의 응답 (JSON 형식).
주요 명령어
| 명령어(파라미터) | 기능 |
| [ ! -f "$FILE" ] |
파일($FILE)이 존재하지 않으면 true를 반환하는 조건문.
|
| jq -r '.key' "$FILE" |
JSON 처리기. 파일($FILE)에서 지정된 키(.key)의 값을 따옴표 없이(-r) 추출.
|
| date +%s |
현재 날짜와 시간을 Unix 타임스탬프(초 단위) 형식으로 출력.
|
| [[ "$A" -ge "$B" ]] |
셸의 숫자 비교 조건문. $A가 $B보다 크거나 같으면(greater than or equal to) true.
|
| curl -s -X POST -d "..." |
curl을 사용하여 -d로 지정된 데이터를 담아 -X POST 요청을 조용히(-s) 전송.
|
| $(( ... )) |
셸 산술 연산. 괄호 안의 수식을 계산.
|
| jq -n --arg ... '{...}' > "$FILE" |
입력 없이(-n) 새로운 JSON 객체를 생성하여 파일($FILE)에 덮어쓰기. --arg와 --argjson은 외부 변수를 jq 내부에서 사용할 수 있게 함.
|
4. send_message
#!/bin/bash
# --- 설정 ---
# 카카오 토큰이 저장된 JSON 파일의 경로
TOKEN_FILE="/usr/local/bin/kakao_token.json"
# 토큰 갱신을 담당하는 외부 스크립트의 경로
REFRESH_SCRIPT_PATH="/usr/local/bin/refresh_token.sh"
# '나에게 보내기' API의 엔드포인트 URL
MESSAGE_URL="https://kapi.kakao.com/v2/api/talk/memo/default/send"
# --- 1. 토큰 갱신 시도 ---
# 메시지 전송에 앞서, 토큰 갱신 스크립트를 먼저 실행하여 액세스 토큰이 유효하도록 보장
bash "$REFRESH_SCRIPT_PATH"
# --- 2. 최신 열쇠(액세스 토큰) 읽기 ---
# 갱신된 토큰 파일에서 액세스 토큰 값을 읽어옴
ACCESS_TOKEN=$(jq -r '.access_token' "$TOKEN_FILE")
# --- 3. 전달받은 소포(메시지) 전송 ---
# 이 스크립트에 전달된 첫 번째 인자($1)를 MESSAGE 변수에 저장
MESSAGE="$1"
# jq를 사용하여 카카오톡 메시지 템플릿 JSON 객체를 생성
# 참고: link의 web_url 값이 불완전함.
TEMPLATE_OBJECT=$(jq -n --arg msg "$MESSAGE" '{object_type:"text", text:$msg, link:{web_url:"https://jongjinportfolio.c>
# curl을 사용하여 카카오 서버에 메시지 전송 API를 호출
# -H: HTTP 헤더 추가. Bearer 인증을 위해 액세스 토큰을 전달.
# -d: 폼 데이터 전송. 생성한 템플릿 객체를 전달.
RESPONSE=$(curl -s -X POST "$MESSAGE_URL" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d "template_object=$TEMPLATE_OBJECT")
# 메시지 전송 완료 후, 카카오 서버로부터 받은 응답을 출력
echo "메시지 전송 완료. 응답: $RESPONSE"
전역 변수
- TOKEN_FILE: 액세스 토큰이 저장된 JSON 파일의 경로.
- REFRESH_SCRIPT_PATH: 토큰의 유효성을 확인하고 필요 시 갱신하는 외부 스크립트의 경로.
- MESSAGE_URL: 카카오 '나에게 보내기' API의 엔드포인트 URL.
- ACCESS_TOKEN: TOKEN_FILE에서 읽어온, API 인증에 사용할 액세스 토큰.
- MESSAGE: 스크립트 실행 시 첫 번째 인자로 전달받은, 전송할 메시지 내용.
- TEMPLATE_OBJECT: 카카오 API 형식에 맞게 jq로 생성된 JSON 메시지 객체.
- RESPONSE: curl로 API를 호출한 후 카카오 서버로부터 받은 응답 내용.
주요 명령어
| 명령어(파라미터) | 기능 |
| bash "$SCRIPT_PATH" |
지정된 경로($SCRIPT_PATH)의 셸 스크립트를 실행.
|
| jq -r '.key' "$FILE" |
JSON 처리기. 파일($FILE)에서 지정된 키(.key)의 값을 따옴표 없이(-r) 추출.
|
| jq -n --arg var "$VAL" '{...}' |
입력 없이(-n) 새로운 JSON 객체를 생성. 셸 변수 $VAL을 jq 내부 변수 $var로 전달하여(--arg) JSON 구조 내에서 사용.
|
| curl -s -X POST -H "..." -d "..." |
curl을 사용하여 -H로 지정된 헤더와 -d로 지정된 데이터를 담아 -X POST 요청을 조용히(-s) 전송.
|
5. kakao_notify
#!/bin/bash
# 1. 설정
# 카카오 토큰이 저장된 JSON 파일의 경로
TOKEN_FILE="/usr/local/bin/kakao_token.json"
# 카카오 REST API 클라이언트 ID(API 키)
CLIENT_ID=""
# 토큰 갱신을 요청할 카카오 인증 서버 URL
REFRESH_URL="https://kauth.kakao.com/oauth/token"
# 2. 토큰 파일 읽기
# jq를 사용하여 파일에서 각 토큰 정보와 만료 시각을 읽어와 변수에 저장
ACCESS_TOKEN=$(jq -r '.access_token' "$TOKEN_FILE")
REFRESH_TOKEN=$(jq -r '.refresh_token' "$TOKEN_FILE")
EXPIRES_AT=$(jq -r '.expires_at' "$TOKEN_FILE")
# 3. 만료 확인 (사전 갱신)
# 현재 시각을 Unix 타임스탬프로 가져옴
NOW=$(date +%s)
# 현재 시각이 저장된 만료 시각보다 크거나 같은지 확인하여 토큰 만료 여부를 판단
if [[ "$NOW" -ge "$EXPIRES_AT" ]]; then
# 3-1. 만료된 경우, Refresh Token을 사용하여 액세스 토큰 갱신 요청
RESPONSE=$(curl -s -X POST "$REFRESH_URL" \
-d "grant_type=refresh_token" \
-d "client_id=$CLIENT_ID" \
-d "refresh_token=$REFRESH_TOKEN")
# 응답으로부터 새로운 액세스 토큰과 유효 기간을 추출
NEW_ACCESS_TOKEN=$(echo "$RESPONSE" | jq -r .access_token)
EXPIRES_IN=$(echo "$RESPONSE" | jq -r .expires_in)
# 새 액세스 토큰 발급에 실패했는지 확인
if [[ "$NEW_ACCESS_TOKEN" == "null" ]]; then
echo "[ERROR] Access Token 갱신 실패"
echo "$RESPONSE"
exit 1
fi
# 새 만료 시각 계산 (현재 시각 + 유효 기간)
NEW_EXPIRES_AT=$(( $(date +%s) + EXPIRES_IN ))
# 새 리프레시 토큰 추출 (없으면 null이 됨)
NEW_REFRESH_TOKEN=$(echo "$RESPONSE" | jq -r .refresh_token)
# 만약 새 리프레시 토큰이 반환되지 않았다면, 기존 리프레시 토큰을 계속 사용
if [[ "$NEW_REFRESH_TOKEN" == "null" ]]; then
NEW_REFRESH_TOKEN=$REFRESH_TOKEN
fi
# jq를 사용하여 새로운 토큰 정보로 JSON 객체를 만들어 토큰 파일을 덮어씀
jq -n \
--arg at "$NEW_ACCESS_TOKEN" \
--arg rt "$NEW_REFRESH_TOKEN" \
--argjson exp $NEW_EXPIRES_AT \
'{access_token:$at, refresh_token:$rt, expires_at:$exp}' > "$TOKEN_FILE"
# 스크립트 내에서 사용할 변수들도 새로운 값으로 갱신
ACCESS_TOKEN=$NEW_ACCESS_TOKEN
REFRESH_TOKEN=$NEW_REFRESH_TOKEN
EXPIRES_AT=$NEW_EXPIRES_AT
fi
# 4. 메시지 전송 (첫 번째 시도)
# 스크립트에 전달된 첫 번째 인자($1)를 메시지 내용으로 사용
MSG="$1"
# curl을 사용하여 '나에게 보내기' API 호출. Authorization 헤더에 액세스 토큰을 담아 전송.
RESPONSE=$(curl -s -X POST "https://kapi.kakao.com/v2/api/talk/memo/default/send" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d 'template_object={"object_type":"text","text":"'"${MSG}"'","link":{"web_url":"https://jongjinportfolio.com/"}}')
# 5. 만약 토큰 만료 에러라면, 한 번 더 갱신 & 재시도 (사후 갱신)
# 첫 시도 응답에 토큰 만료 관련 에러 메시지가 포함되어 있는지 정규식으로 확인
if [[ "$RESPONSE" =~ "access token is already expired" || "$RESPONSE" =~ "access token does not exist" ]]; then
# 3-1과 동일한 로직으로 토큰을 다시 갱신
RESPONSE2=$(curl -s -X POST "$REFRESH_URL" \
-d "grant_type=refresh_token" \
-d "client_id=$CLIENT_ID" \
-d "refresh_token=$REFRESH_TOKEN")
NEW_ACCESS_TOKEN=$(echo "$RESPONSE2" | jq -r .access_token)
EXPIRES_IN=$(echo "$RESPONSE2" | jq -r .expires_in)
# 갱신이 성공적으로 이루어졌다면
if [[ "$NEW_ACCESS_TOKEN" != "null" ]]; then
NEW_EXPIRES_AT=$(( $(date +%s) + EXPIRES_IN ))
NEW_REFRESH_TOKEN=$(echo "$RESPONSE2" | jq -r .refresh_token)
if [[ "$NEW_REFRESH_TOKEN" == "null" ]]; then
NEW_REFRESH_TOKEN=$REFRESH_TOKEN
fi
# 토큰 파일과 스크립트 변수를 모두 갱신
jq -n \
--arg at "$NEW_ACCESS_TOKEN" \
--arg rt "$NEW_REFRESH_TOKEN" \
--argjson exp $NEW_EXPIRES_AT \
'{access_token:$at, refresh_token:$rt, expires_at:$exp}' > "$TOKEN_FILE"
ACCESS_TOKEN=$NEW_ACCESS_TOKEN
# 갱신된 새 토큰으로 메시지 전송을 재시도
RESPONSE=$(curl -s -X POST "https://kapi.kakao.com/v2/api/talk/memo/default/send" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-d 'template_object={"object_type":"text","text":"'"${MSG}"'","link":{"web_url":"https://jongjinportfolio.com/"}}')
fi
fi
# 최종 응답 결과를 출력
echo "$RESPONSE"
전역 변수
- TOKEN_FILE: 카카오 토큰 정보가 저장된 JSON 파일의 경로.
- CLIENT_ID: 카카오 애플리케이션의 REST API 키.
- REFRESH_URL: 토큰 갱신 API의 엔드포인트 URL.
- ACCESS_TOKEN: API 호출에 사용할 현재 액세스 토큰.
- REFRESH_TOKEN: 액세스 토큰 갱신에 사용할 리프레시 토큰.
- EXPIRES_AT: 현재 액세스 토큰의 만료 시각 (Unix 타임스탬프).
- NOW: 스크립트 실행 시점의 현재 시각 (Unix 타임스탬프).
- MSG: 스크립트에 첫 번째 인자로 전달된, 전송할 메시지 내용.
- RESPONSE, RESPONSE2: curl을 통해 카카오 서버로부터 받은 API 응답.
주요 명령어
| 명령어(파라미터) | 기능 |
| jq -r '.key' "$FILE" |
JSON 처리기. 파일($FILE)에서 지정된 키(.key)의 값을 따옴표 없이(-r) 추출.
|
| date +%s |
현재 날짜와 시간을 Unix 타임스탬프(초 단위) 형식으로 출력.
|
| [[ "$A" -ge "$B" ]] |
셸의 숫자 비교 조건문. $A가 $B보다 크거나 같으면(greater than or equal to) true.
|
| curl -H "..." -d "..." |
curl을 사용하여 -H로 지정된 헤더와 -d로 지정된 데이터를 담아 POST 요청을 전송.
|
| $(( ... )) |
셸 산술 연산. 괄호 안의 수식을 계산.
|
| [[ "$VAR" =~ "regex" ]] |
정규 표현식 비교. 변수 $VAR의 내용이 지정된 정규식("regex") 패턴과 일치하는지 확인.
|
'Study > Network | Server' 카테고리의 다른 글
| [Server] PHP, Apache2 그리고 Nginx + PHP-FPM (0) | 2025.10.17 |
|---|---|
| [Home Server] fail2ban 그리고 카카오톡 토큰 갱신 (2) | 2025.07.07 |
| [Home Server] 서버 유지보수, Git을 이용한 서버 자동 업데이트 (1) | 2025.07.06 |
| [Home Server] 패키지 설치, 도메인 연결 (4) | 2025.07.05 |
| [Home Server] 미니 PC 환경 설정 (1) | 2025.07.03 |