본문 바로가기

Study/Network | Server

[WPF & Linux Server] ToDoApp

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") 패턴과 일치하는지 확인.