Files

1174 lines
44 KiB
C#

#nullable enable
using System;
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.UIElements;
using UVC.Locale;
namespace UVC.UIToolkit
{
/// <summary>
/// UIToolkit 기반 날짜/시간 피커 모달.
/// 캘린더 그리드 + 시간 선택 + 범위 선택을 지원하는 날짜 선택 대화상자입니다.
/// </summary>
/// <remarks>
/// <para><b>DatePicker(날짜 피커)란?</b></para>
/// <para>
/// DatePicker는 사용자가 달력에서 날짜를 선택할 수 있는 UI 컴포넌트입니다.
/// 예약 시스템, 일정 관리, 검색 필터 등에서 널리 사용됩니다.
/// 텍스트 입력보다 직관적이고 유효한 날짜만 선택할 수 있어 오류를 방지합니다.
/// </para>
///
/// <para><b>피커 모드 (PickerMode):</b></para>
/// <list type="bullet">
/// <item><description><c>DateOnly</c> - 날짜만 선택</description></item>
/// <item><description><c>DateAndTime</c> - 날짜 + 시간(시/분) 선택</description></item>
/// <item><description><c>DateRange</c> - 시작일~종료일 범위 선택</description></item>
/// <item><description><c>DateTimeRange</c> - 시간 포함 범위 선택</description></item>
/// </list>
///
/// <para><b>UI 구성:</b></para>
/// <list type="bullet">
/// <item><description>네비게이션 - 년/월 이동 버튼</description></item>
/// <item><description>요일 헤더 - 일~토 요일 표시 (로컬라이제이션 지원)</description></item>
/// <item><description>달력 그리드 - 6행 7열 (42일) 버튼</description></item>
/// <item><description>시간 선택 - 시/분 NumberStepper (DateAndTime 모드)</description></item>
/// <item><description>범위 정보 - 선택된 시작일/종료일 표시 (Range 모드)</description></item>
/// </list>
///
/// <para><b>날짜 스타일:</b></para>
/// <list type="bullet">
/// <item><description>오늘 - 테두리로 강조</description></item>
/// <item><description>선택된 날짜 - 배경색으로 강조</description></item>
/// <item><description>일요일 - 빨간색</description></item>
/// <item><description>토요일 - 파란색</description></item>
/// <item><description>범위 내 날짜 - 연한 배경색</description></item>
/// </list>
///
/// <para><b>요일 이름 커스터마이징:</b></para>
/// <code>
/// // 직접 지정
/// UTKDatePicker.SetDayNames(new[] { "일", "월", "화", "수", "목", "금", "토" });
///
/// // 로컬라이제이션 키 지정
/// UTKDatePicker.SetDayNameKeys(new[] { "day_sun", "day_mon", ... });
///
/// // 기본값으로 초기화
/// UTKDatePicker.ResetDayNames();
/// </code>
///
/// <para><b>실제 활용 예시:</b></para>
/// <list type="bullet">
/// <item><description>예약 시스템 - 호텔, 항공권, 렌터카 예약일</description></item>
/// <item><description>일정 관리 - 이벤트, 회의, 마감일 설정</description></item>
/// <item><description>검색 필터 - 기간별 데이터 조회</description></item>
/// <item><description>생년월일 입력 - 회원 가입, 프로필 설정</description></item>
/// </list>
///
/// <para><b>사용 전 초기화:</b></para>
/// <para>
/// DatePicker를 표시하기 전에 <c>UTKDatePicker.SetRoot(rootVisualElement)</c>로 루트를 설정해야 합니다.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// // 초기화 (root 설정 필요)
/// UTKDatePicker.SetRoot(rootVisualElement);
///
/// // 기본 사용법 (날짜만)
/// var picker = UTKDatePicker.Show(DateTime.Today, UTKDatePicker.PickerMode.DateOnly, "Select Date");
/// picker.OnDateSelected += (date) =>
/// {
/// Debug.Log($"Date selected: {date:yyyy-MM-dd}");
/// };
///
/// // 날짜 + 시간 선택
/// var dateTimePicker = UTKDatePicker.Show(DateTime.Now, UTKDatePicker.PickerMode.DateAndTime, "Select Date &amp; Time");
/// dateTimePicker.OnDateSelected += (date) =>
/// {
/// Debug.Log($"DateTime selected: {date:yyyy-MM-dd HH:mm}");
/// };
///
/// // async/await 사용법 (UniTask)
/// DateTime? selectedDate = await UTKDatePicker.ShowAsync(DateTime.Today);
/// if (selectedDate.HasValue)
/// {
/// Debug.Log($"Selected: {selectedDate.Value:yyyy-MM-dd}");
/// }
/// else
/// {
/// Debug.Log("Cancelled");
/// }
///
/// // 날짜 범위 선택
/// var rangePicker = UTKDatePicker.ShowRange(DateTime.Today, DateTime.Today.AddDays(7));
/// rangePicker.OnDateRangeSelected += (start, end) =>
/// {
/// Debug.Log($"Range selected: {start:yyyy-MM-dd} ~ {end:yyyy-MM-dd}");
/// };
///
/// // 날짜 범위 async/await 사용법
/// var result = await UTKDatePicker.ShowRangeAsync(DateTime.Today, DateTime.Today.AddDays(7));
/// if (result.HasValue)
/// {
/// Debug.Log($"Range: {result.Value.Start:yyyy-MM-dd} ~ {result.Value.End:yyyy-MM-dd}");
/// }
/// else
/// {
/// Debug.Log("Cancelled");
/// }
///
/// // 요일 이름 커스터마이징 (static - 모든 인스턴스에 적용)
/// UTKDatePicker.SetDayNames(new[] { "일", "월", "화", "수", "목", "금", "토" });
/// UTKDatePicker.SetDayNames(new[] { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" });
///
/// // 요일 이름 로컬라이제이션 키 설정
/// UTKDatePicker.SetDayNameKeys(new[] { "day_sun", "day_mon", "day_tue", "day_wed", "day_thu", "day_fri", "day_sat" });
///
/// // 요일 이름 기본값으로 초기화
/// UTKDatePicker.ResetDayNames();
///
/// // 인스턴스 직접 생성
/// var datePicker = new UTKDatePicker();
/// datePicker.SetDate(DateTime.Today);
/// container.Add(datePicker);
/// </code>
/// </example>
[UxmlElement]
public partial class UTKDatePicker : VisualElement, IDisposable
{
#region Enums
public enum PickerMode
{
DateOnly,
DateAndTime,
DateRange,
DateTimeRange
}
/// <summary>
/// 범위 선택 시 현재 선택 중인 날짜 타입
/// </summary>
private enum RangeSelectionState
{
SelectingStart,
SelectingEnd
}
#endregion
#region Constants
private const string UXML_PATH = "UIToolkit/Modal/UTKDatePicker";
private const string USS_PATH = "UIToolkit/Modal/UTKDatePicker";
private const int DAYS_IN_GRID = 42; // 6 rows x 7 columns
private static readonly string[] DEFAULT_DAY_NAME_KEYS = { "day_sun", "day_mon", "day_tue", "day_wed", "day_thu", "day_fri", "day_sat" };
#endregion
#region Static Fields
private static VisualElement? _root;
private static string[] s_dayNameKeys = DEFAULT_DAY_NAME_KEYS;
private static string[]? s_customDayNames;
#endregion
#region Fields
private bool _disposed;
private PickerMode _mode;
private DateTime _selectedDate = DateTime.Today;
private DateTime _displayMonth = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1);
// 범위 선택 관련 필드
private DateTime? _rangeStartDate;
private DateTime? _rangeEndDate;
private RangeSelectionState _rangeState = RangeSelectionState.SelectingStart;
private UTKModalBlocker? _blocker;
private readonly List<Button> _dayButtons = new();
// 범위 선택 UI 요소
private Label? _rangeInfoLabel;
// UI 요소
private Label? _titleLabel;
private Button? _closeButton;
private Label? _yearMonthLabel;
private Button? _prevYearButton;
private Button? _prevMonthButton;
private Button? _nextMonthButton;
private Button? _nextYearButton;
private VisualElement? _dayNamesRow;
private VisualElement? _timeRow;
private UTKIntStepper? _hourStepper;
private UTKIntStepper? _minuteStepper;
private UTKButton? _cancelButton;
private UTKButton? _confirmButton;
private VisualElement? _header;
// 드래그 관련 필드
private bool _isDragging;
private Vector2 _dragStartPosition;
private Vector2 _dragStartMousePosition;
#endregion
#region Events
/// <summary>날짜가 선택되었을 때 발생</summary>
public event Action<DateTime>? OnDateSelected;
/// <summary>날짜 범위가 선택되었을 때 발생 (시작일, 종료일)</summary>
public event Action<DateTime, DateTime>? OnDateRangeSelected;
/// <summary>피커가 닫힐 때 발생</summary>
public event Action? OnClosed;
#endregion
#region Constructor
public UTKDatePicker()
{
// 테마 스타일시트 적용
UTKThemeManager.Instance.ApplyThemeToElement(this);
// 컴포넌트 스타일시트 로드
var styleSheet = Resources.Load<StyleSheet>(USS_PATH);
if (styleSheet != null)
{
styleSheets.Add(styleSheet);
}
var visualTree = Resources.Load<VisualTreeAsset>(UXML_PATH);
if (visualTree != null)
{
visualTree.CloneTree(this);
}
else
{
CreateUI();
}
QueryElements();
SetupEvents();
SubscribeToThemeChanges();
// 캘린더 초기화
UpdateCalendar();
}
private void SubscribeToThemeChanges()
{
UTKThemeManager.Instance.OnThemeChanged += OnThemeChanged;
RegisterCallback<AttachToPanelEvent>(OnAttachToPanelForTheme);
RegisterCallback<DetachFromPanelEvent>(OnDetachFromPanelForTheme);
}
private void OnAttachToPanelForTheme(AttachToPanelEvent evt)
{
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
UTKThemeManager.Instance.OnThemeChanged += OnThemeChanged;
UTKThemeManager.Instance.ApplyThemeToElement(this);
}
private void OnDetachFromPanelForTheme(DetachFromPanelEvent evt)
{
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
}
private void OnThemeChanged(UTKTheme theme)
{
UTKThemeManager.Instance.ApplyThemeToElement(this);
}
#endregion
#region Static Methods
/// <summary>
/// 기본 루트 요소 설정.
/// 모든 DatePicker는 이 루트의 panel.visualTree에 표시됩니다.
/// </summary>
/// <param name="root">DatePicker를 표시할 기본 루트 요소</param>
public static void SetRoot(VisualElement root)
{
_root = root;
}
/// <summary>
/// 기본 루트 요소 반환
/// </summary>
public static VisualElement? GetRoot() => _root;
private static void ValidateRoot()
{
if (_root == null)
{
throw new InvalidOperationException("UTKDatePicker.SetRoot()를 먼저 호출하여 기본 루트 요소를 설정해야 합니다.");
}
}
#endregion
#region Static Factory
/// <summary>
/// 날짜 피커를 표시합니다.
/// </summary>
public static UTKDatePicker Show(
DateTime initialDate,
PickerMode mode = PickerMode.DateOnly,
string title = "Select Date")
{
ValidateRoot();
var picker = new UTKDatePicker();
picker._mode = mode;
picker._selectedDate = initialDate;
picker._displayMonth = new DateTime(initialDate.Year, initialDate.Month, 1);
// 블로커 추가 (panel.visualTree에 추가됨)
picker._blocker = UTKModalBlocker.Show(_root!, 0.5f, false);
picker._blocker.OnBlockerClicked += picker.Close;
// 위치 계산 전까지 숨김 (깜빡임 방지)
picker.style.visibility = Visibility.Hidden;
// panel.visualTree에 직접 추가
var root = _root!.panel?.visualTree ?? _root!;
root.Add(picker);
// UI 초기화
picker.SetTitle(title);
picker.SetTimeVisible(mode == PickerMode.DateAndTime);
picker.UpdateTimeFields();
picker.UpdateCalendar();
picker.CenterOnScreen();
return picker;
}
/// <summary>
/// 날짜 피커를 표시하고 날짜 선택을 기다립니다.
/// OK 버튼 클릭 시 선택된 날짜를 반환하고, 취소/닫기 시 null을 반환합니다.
/// </summary>
/// <param name="initialDate">초기 날짜</param>
/// <param name="mode">피커 모드 (날짜만 또는 날짜+시간)</param>
/// <param name="title">피커 제목</param>
/// <returns>선택된 날짜 또는 null (취소 시)</returns>
/// <example>
/// <code>
/// // async/await 사용법
/// DateTime? selectedDate = await UTKDatePicker.ShowAsync(DateTime.Today);
/// if (selectedDate.HasValue)
/// {
/// Debug.Log($"Selected: {selectedDate.Value:yyyy-MM-dd}");
/// }
/// else
/// {
/// Debug.Log("Cancelled");
/// }
/// </code>
/// </example>
public static async UniTask<DateTime?> ShowAsync(
DateTime initialDate,
PickerMode mode = PickerMode.DateOnly,
string title = "Select Date")
{
var tcs = new UniTaskCompletionSource<DateTime?>();
DateTime? resultDate = null;
var picker = Show(initialDate, mode, title);
picker.OnDateSelected += (date) =>
{
resultDate = date;
tcs.TrySetResult(resultDate);
};
picker.OnClosed += () =>
{
// OnDateSelected가 먼저 호출된 경우 이미 완료됨
tcs.TrySetResult(resultDate);
};
return await tcs.Task;
}
/// <summary>
/// 날짜 범위 피커를 표시합니다.
/// </summary>
/// <param name="initialStartDate">초기 시작 날짜 (null이면 오늘)</param>
/// <param name="initialEndDate">초기 종료 날짜 (null이면 시작 날짜와 동일)</param>
/// <param name="includeTime">시간 선택 포함 여부</param>
/// <param name="title">피커 제목</param>
public static UTKDatePicker ShowRange(
DateTime? initialStartDate = null,
DateTime? initialEndDate = null,
bool includeTime = false,
string title = "Select Date Range")
{
ValidateRoot();
var picker = new UTKDatePicker();
picker._mode = includeTime ? PickerMode.DateTimeRange : PickerMode.DateRange;
var startDate = initialStartDate ?? DateTime.Today;
var endDate = initialEndDate ?? startDate;
// 시작일이 종료일보다 늦으면 스왑
if (startDate > endDate)
{
(startDate, endDate) = (endDate, startDate);
}
picker._rangeStartDate = startDate;
picker._rangeEndDate = endDate;
picker._rangeState = RangeSelectionState.SelectingStart;
picker._displayMonth = new DateTime(startDate.Year, startDate.Month, 1);
// 블로커 추가 (panel.visualTree에 추가됨)
picker._blocker = UTKModalBlocker.Show(_root!, 0.5f, false);
picker._blocker.OnBlockerClicked += picker.Close;
// 위치 계산 전까지 숨김 (깜빡임 방지)
picker.style.visibility = Visibility.Hidden;
// panel.visualTree에 직접 추가
var root = _root!.panel?.visualTree ?? _root!;
root.Add(picker);
// UI 초기화
picker.SetTitle(title);
picker.SetTimeVisible(includeTime);
picker.SetRangeInfoVisible(true);
picker.UpdateRangeInfo();
picker.UpdateCalendar();
picker.CenterOnScreen();
return picker;
}
/// <summary>
/// 날짜 범위 피커를 표시하고 범위 선택을 기다립니다.
/// OK 버튼 클릭 시 선택된 범위를 반환하고, 취소/닫기 시 null을 반환합니다.
/// </summary>
/// <param name="initialStartDate">초기 시작 날짜</param>
/// <param name="initialEndDate">초기 종료 날짜</param>
/// <param name="includeTime">시간 선택 포함 여부</param>
/// <param name="title">피커 제목</param>
/// <returns>선택된 범위 (시작일, 종료일) 또는 null (취소 시)</returns>
/// <example>
/// <code>
/// // async/await 사용법
/// var result = await UTKDatePicker.ShowRangeAsync();
/// if (result.HasValue)
/// {
/// Debug.Log($"Range: {result.Value.Start:yyyy-MM-dd} ~ {result.Value.End:yyyy-MM-dd}");
/// }
/// else
/// {
/// Debug.Log("Cancelled");
/// }
/// </code>
/// </example>
public static async UniTask<(DateTime Start, DateTime End)?> ShowRangeAsync(
DateTime? initialStartDate = null,
DateTime? initialEndDate = null,
bool includeTime = false,
string title = "Select Date Range")
{
var tcs = new UniTaskCompletionSource<(DateTime, DateTime)?>();
(DateTime, DateTime)? result = null;
var picker = ShowRange(initialStartDate, initialEndDate, includeTime, title);
picker.OnDateRangeSelected += (start, end) =>
{
result = (start, end);
tcs.TrySetResult(result);
};
picker.OnClosed += () =>
{
tcs.TrySetResult(result);
};
return await tcs.Task;
}
#endregion
#region Public Methods
public void SetDate(DateTime date)
{
_selectedDate = date;
_displayMonth = new DateTime(date.Year, date.Month, 1);
UpdateTimeFields();
UpdateCalendar();
}
public DateTime GetDate()
{
if (_mode == PickerMode.DateAndTime)
{
int hour = _hourStepper?.Value ?? 0;
int minute = _minuteStepper?.Value ?? 0;
return new DateTime(
_selectedDate.Year,
_selectedDate.Month,
_selectedDate.Day,
hour,
minute,
0
);
}
return _selectedDate.Date;
}
public void PreviousYear() => NavigateMonth(-12);
public void NextYear() => NavigateMonth(12);
public void PreviousMonth() => NavigateMonth(-1);
public void NextMonth() => NavigateMonth(1);
/// <summary>
/// 요일 이름을 직접 설정합니다. (일, 월, 화, 수, 목, 금, 토 순서로 7개)
/// 설정 후 새로 생성되는 피커부터 적용됩니다.
/// </summary>
/// <param name="dayNames">요일 이름 배열 (7개)</param>
/// <example>
/// <code>
/// UTKDatePicker.SetDayNames(new[] { "일", "월", "화", "수", "목", "금", "토" });
/// UTKDatePicker.SetDayNames(new[] { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" });
/// </code>
/// </example>
public static void SetDayNames(string[] dayNames)
{
if (dayNames == null || dayNames.Length != 7)
{
Debug.LogWarning("SetDayNames requires exactly 7 day names (Sun, Mon, Tue, Wed, Thu, Fri, Sat)");
return;
}
s_customDayNames = dayNames;
}
/// <summary>
/// 요일 이름의 로컬라이제이션 키를 설정합니다. (일, 월, 화, 수, 목, 금, 토 순서로 7개)
/// 설정 후 새로 생성되는 피커부터 적용됩니다.
/// </summary>
/// <param name="dayNameKeys">로컬라이제이션 키 배열 (7개)</param>
/// <example>
/// <code>
/// UTKDatePicker.SetDayNameKeys(new[] { "day_sun", "day_mon", "day_tue", "day_wed", "day_thu", "day_fri", "day_sat" });
/// </code>
/// </example>
public static void SetDayNameKeys(string[] dayNameKeys)
{
if (dayNameKeys == null || dayNameKeys.Length != 7)
{
Debug.LogWarning("SetDayNameKeys requires exactly 7 localization keys");
return;
}
s_customDayNames = null; // 커스텀 이름 초기화
s_dayNameKeys = dayNameKeys;
}
/// <summary>
/// 요일 이름을 기본값(로컬라이제이션)으로 초기화합니다.
/// 설정 후 새로 생성되는 피커부터 적용됩니다.
/// </summary>
public static void ResetDayNames()
{
s_customDayNames = null;
s_dayNameKeys = DEFAULT_DAY_NAME_KEYS;
}
/// <summary>
/// 피커를 닫고, 정리합니다.
/// </summary>
public void Close()
{
OnClosed?.Invoke();
_blocker?.Hide();
RemoveFromHierarchy();
Dispose();
}
#endregion
#region Private Methods - UI Creation
private void CreateUI()
{
AddToClassList("utk-date-picker");
// Header
var header = new VisualElement { name = "header" };
header.AddToClassList("utk-date-picker__header");
_titleLabel = new Label("Select Date") { name = "title" };
_titleLabel.AddToClassList("utk-date-picker__title");
_closeButton = new Button { name = "close-btn", text = UTKMaterialIcons.Close };
_closeButton.AddToClassList("utk-date-picker__close-btn");
UTKMaterialIcons.ApplyIconStyle(_closeButton, UTKStyleGuide.FontSizeBody2);
header.Add(_titleLabel);
header.Add(_closeButton);
Add(header);
// Navigation
var navRow = new VisualElement { name = "nav-row" };
navRow.AddToClassList("utk-date-picker__nav-row");
var leftNav = new VisualElement();
leftNav.AddToClassList("utk-date-picker__nav-group");
_prevYearButton = CreateNavButton("prev-year", UTKMaterialIcons.FirstPage);
_prevYearButton.AddToClassList("utk-date-picker__nav-btn--prev-year");
UTKMaterialIcons.ApplyIconStyle(_prevYearButton, 18);
_prevMonthButton = CreateNavButton("prev-month", UTKMaterialIcons.ChevronLeft);
UTKMaterialIcons.ApplyIconStyle(_prevMonthButton, 18);
leftNav.Add(_prevYearButton);
leftNav.Add(_prevMonthButton);
_yearMonthLabel = new Label { name = "year-month" };
_yearMonthLabel.AddToClassList("utk-date-picker__year-month-label");
var rightNav = new VisualElement();
rightNav.AddToClassList("utk-date-picker__nav-group");
_nextMonthButton = CreateNavButton("next-month", UTKMaterialIcons.ChevronRight);
UTKMaterialIcons.ApplyIconStyle(_nextMonthButton, 18);
_nextYearButton = CreateNavButton("next-year", UTKMaterialIcons.LastPage);
_nextYearButton.AddToClassList("utk-date-picker__nav-btn--next-year");
UTKMaterialIcons.ApplyIconStyle(_nextYearButton, 18);
rightNav.Add(_nextMonthButton);
rightNav.Add(_nextYearButton);
navRow.Add(leftNav);
navRow.Add(_yearMonthLabel);
navRow.Add(rightNav);
Add(navRow);
// Day names row
_dayNamesRow = new VisualElement { name = "day-names" };
_dayNamesRow.AddToClassList("utk-date-picker__day-names");
for (int i = 0; i < s_dayNameKeys.Length; i++)
{
var dayText = GetDayName(i);
var label = new Label(dayText) { name = $"day-name-{i}" };
label.AddToClassList("utk-date-picker__day-name");
// 요일별 색상 클래스 (0=일요일, 6=토요일)
if (i == 0) label.AddToClassList("utk-date-picker__day-name--sunday");
else if (i == 6) label.AddToClassList("utk-date-picker__day-name--saturday");
_dayNamesRow.Add(label);
}
Add(_dayNamesRow);
// Calendar grid - 6 rows
for (int row = 0; row < 6; row++)
{
var rowContainer = new VisualElement { name = $"calendar-row-{row}" };
rowContainer.AddToClassList("utk-date-picker__calendar-row");
for (int col = 0; col < 7; col++)
{
var dayButton = new Button { name = $"day-{row * 7 + col}" };
dayButton.AddToClassList("utk-date-picker__day-btn");
int index = row * 7 + col;
dayButton.clicked += () => OnDayClicked(index);
_dayButtons.Add(dayButton);
rowContainer.Add(dayButton);
}
Add(rowContainer);
}
// Time row (optional)
_timeRow = new VisualElement { name = "time-row" };
_timeRow.AddToClassList("utk-date-picker__time-row");
var timeLabel = new Label("Time:");
timeLabel.AddToClassList("utk-date-picker__time-label");
_hourStepper = new UTKIntStepper(0, 23, 0, 1) { name = "hour-stepper" };
_hourStepper.AddToClassList("utk-date-picker__time-stepper");
_hourStepper.WrapAround = true;
var colonLabel = new Label(":");
colonLabel.AddToClassList("utk-date-picker__time-separator");
_minuteStepper = new UTKIntStepper(0, 59, 0, 1) { name = "minute-stepper" };
_minuteStepper.AddToClassList("utk-date-picker__time-stepper");
_minuteStepper.WrapAround = true;
// Tab 키로 시간 <-> 분 이동
_hourStepper.OnTabPressed += () => _minuteStepper?.Focus();
_minuteStepper.OnShiftTabPressed += () => _hourStepper?.Focus();
_timeRow.Add(timeLabel);
_timeRow.Add(_hourStepper);
_timeRow.Add(colonLabel);
_timeRow.Add(_minuteStepper);
Add(_timeRow);
// Range Info Label (범위 선택 시 시작일/종료일 표시)
_rangeInfoLabel = new Label { name = "range-info" };
_rangeInfoLabel.AddToClassList("utk-date-picker__range-info");
_rangeInfoLabel.style.display = DisplayStyle.None;
Add(_rangeInfoLabel);
// Buttons
var buttonRow = new VisualElement { name = "button-row" };
buttonRow.AddToClassList("utk-date-picker__button-row");
_cancelButton = new UTKButton("Cancel", "", UTKButton.ButtonVariant.Normal) { name = "cancel-btn" };
_cancelButton.AddToClassList("utk-date-picker__cancel-btn");
_confirmButton = new UTKButton("OK", "", UTKButton.ButtonVariant.Primary) { name = "confirm-btn" };
_confirmButton.AddToClassList("utk-date-picker__confirm-btn");
buttonRow.Add(_cancelButton);
buttonRow.Add(_confirmButton);
Add(buttonRow);
}
private Button CreateNavButton(string name, string text)
{
var btn = new Button { name = name, text = text };
btn.AddToClassList("utk-date-picker__nav-btn");
return btn;
}
private void QueryElements()
{
_header ??= this.Q<VisualElement>("header");
_titleLabel ??= this.Q<Label>("title");
_closeButton ??= this.Q<Button>("close-btn");
_yearMonthLabel ??= this.Q<Label>("year-month");
_prevYearButton ??= this.Q<Button>("prev-year");
_prevMonthButton ??= this.Q<Button>("prev-month");
_nextMonthButton ??= this.Q<Button>("next-month");
_nextYearButton ??= this.Q<Button>("next-year");
_dayNamesRow ??= this.Q<VisualElement>("day-names");
_timeRow ??= this.Q<VisualElement>("time-row");
_hourStepper ??= this.Q<UTKIntStepper>("hour-stepper");
_minuteStepper ??= this.Q<UTKIntStepper>("minute-stepper");
_cancelButton ??= this.Q<UTKButton>("cancel-btn");
_confirmButton ??= this.Q<UTKButton>("confirm-btn");
_rangeInfoLabel ??= this.Q<Label>("range-info");
// Day buttons 쿼리
if (_dayButtons.Count == 0)
{
for (int i = 0; i < DAYS_IN_GRID; i++)
{
var btn = this.Q<Button>($"day-{i}");
if (btn != null)
{
_dayButtons.Add(btn);
int index = i;
btn.clicked += () => OnDayClicked(index);
}
}
}
}
private void SetupEvents()
{
_closeButton?.RegisterCallback<ClickEvent>(_ => Close());
if (_cancelButton != null) _cancelButton.OnClicked += Close;
if (_confirmButton != null) _confirmButton.OnClicked += Confirm;
_prevYearButton?.RegisterCallback<ClickEvent>(_ => PreviousYear());
_prevMonthButton?.RegisterCallback<ClickEvent>(_ => PreviousMonth());
_nextMonthButton?.RegisterCallback<ClickEvent>(_ => NextMonth());
_nextYearButton?.RegisterCallback<ClickEvent>(_ => NextYear());
// 헤더 드래그 이벤트
_header?.RegisterCallback<PointerDownEvent>(OnHeaderPointerDown);
_header?.RegisterCallback<PointerMoveEvent>(OnHeaderPointerMove);
_header?.RegisterCallback<PointerUpEvent>(OnHeaderPointerUp);
// 언어 변경 이벤트 구독
LocalizationManager.Instance.OnLanguageChanged += OnLanguageChanged;
}
private void OnHeaderPointerDown(PointerDownEvent evt)
{
if (evt.target == _closeButton) return;
_isDragging = true;
_dragStartMousePosition = evt.position;
_dragStartPosition = new Vector2(resolvedStyle.left, resolvedStyle.top);
_header?.CapturePointer(evt.pointerId);
evt.StopPropagation();
}
private void OnHeaderPointerMove(PointerMoveEvent evt)
{
if (!_isDragging) return;
Vector2 delta = (Vector2)evt.position - _dragStartMousePosition;
style.left = _dragStartPosition.x + delta.x;
style.top = _dragStartPosition.y + delta.y;
}
private void OnHeaderPointerUp(PointerUpEvent evt)
{
if (!_isDragging) return;
_isDragging = false;
_header?.ReleasePointer(evt.pointerId);
}
private void OnLanguageChanged(string newLanguage)
{
UpdateDayNameLabels();
}
private void UpdateDayNameLabels()
{
if (_dayNamesRow == null) return;
for (int i = 0; i < s_dayNameKeys.Length; i++)
{
var label = _dayNamesRow.Q<Label>($"day-name-{i}");
if (label != null)
{
label.text = GetDayName(i);
}
}
}
/// <summary>
/// 인덱스에 해당하는 요일 이름을 반환합니다.
/// 커스텀 이름이 설정되어 있으면 커스텀 이름을, 아니면 로컬라이제이션 키를 사용합니다.
/// </summary>
private string GetDayName(int index)
{
if (s_customDayNames != null && index < s_customDayNames.Length)
{
return s_customDayNames[index];
}
return LocalizationManager.Instance.GetString(s_dayNameKeys[index]);
}
#endregion
#region Private Methods - Logic
private void NavigateMonth(int months)
{
_displayMonth = _displayMonth.AddMonths(months);
UpdateCalendar();
}
private void UpdateCalendar()
{
if (_yearMonthLabel != null)
{
_yearMonthLabel.text = $"{_displayMonth.Year}년 {_displayMonth.Month:D2}월";
}
// 첫째 날의 요일
DateTime firstDay = new DateTime(_displayMonth.Year, _displayMonth.Month, 1);
int startDayOfWeek = (int)firstDay.DayOfWeek; // 0=Sunday
// 해당 월의 일 수
int daysInMonth = DateTime.DaysInMonth(_displayMonth.Year, _displayMonth.Month);
DateTime today = DateTime.Today;
for (int i = 0; i < DAYS_IN_GRID; i++)
{
var btn = _dayButtons[i];
// 동적 클래스 초기화
btn.RemoveFromClassList("utk-date-picker__day-btn--sunday");
btn.RemoveFromClassList("utk-date-picker__day-btn--saturday");
btn.RemoveFromClassList("utk-date-picker__day-btn--today");
btn.RemoveFromClassList("utk-date-picker__day-btn--selected");
btn.RemoveFromClassList("utk-date-picker__day-btn--other-month");
btn.RemoveFromClassList("utk-date-picker__day-btn--range-start");
btn.RemoveFromClassList("utk-date-picker__day-btn--range-end");
btn.RemoveFromClassList("utk-date-picker__day-btn--in-range");
int dayNumber = i - startDayOfWeek + 1;
if (dayNumber < 1 || dayNumber > daysInMonth)
{
// 이번 달이 아닌 날
btn.text = "";
btn.SetEnabled(false);
btn.AddToClassList("utk-date-picker__day-btn--other-month");
}
else
{
btn.text = dayNumber.ToString();
btn.SetEnabled(true);
DateTime currentDate = new DateTime(_displayMonth.Year, _displayMonth.Month, dayNumber);
DayOfWeek dow = currentDate.DayOfWeek;
// 범위 선택 모드 처리
if (IsRangeMode)
{
bool isRangeStart = _rangeStartDate.HasValue && currentDate.Date == _rangeStartDate.Value.Date;
bool isRangeEnd = _rangeEndDate.HasValue && currentDate.Date == _rangeEndDate.Value.Date;
bool isInRange = _rangeStartDate.HasValue && _rangeEndDate.HasValue &&
currentDate.Date > _rangeStartDate.Value.Date &&
currentDate.Date < _rangeEndDate.Value.Date;
if (isRangeStart)
{
btn.AddToClassList("utk-date-picker__day-btn--range-start");
}
if (isRangeEnd)
{
btn.AddToClassList("utk-date-picker__day-btn--range-end");
}
if (isInRange)
{
btn.AddToClassList("utk-date-picker__day-btn--in-range");
}
// 오늘 클래스 (범위에 포함되지 않은 경우에만)
bool isToday = currentDate.Date == today;
if (isToday && !isRangeStart && !isRangeEnd && !isInRange)
{
btn.AddToClassList("utk-date-picker__day-btn--today");
}
// 요일별 클래스 (범위에 포함되지 않은 경우에만)
if (!isRangeStart && !isRangeEnd && !isInRange)
{
if (dow == DayOfWeek.Sunday)
{
btn.AddToClassList("utk-date-picker__day-btn--sunday");
}
else if (dow == DayOfWeek.Saturday)
{
btn.AddToClassList("utk-date-picker__day-btn--saturday");
}
}
}
else
{
// 단일 날짜 선택 모드
bool isSelected = currentDate.Date == _selectedDate.Date;
bool isToday = currentDate.Date == today;
if (isSelected)
{
btn.AddToClassList("utk-date-picker__day-btn--selected");
}
if (isToday && !isSelected)
{
btn.AddToClassList("utk-date-picker__day-btn--today");
}
if (!isSelected)
{
if (dow == DayOfWeek.Sunday)
{
btn.AddToClassList("utk-date-picker__day-btn--sunday");
}
else if (dow == DayOfWeek.Saturday)
{
btn.AddToClassList("utk-date-picker__day-btn--saturday");
}
}
}
}
}
}
private void OnDayClicked(int index)
{
// 첫째 날의 요일
DateTime firstDay = new DateTime(_displayMonth.Year, _displayMonth.Month, 1);
int startDayOfWeek = (int)firstDay.DayOfWeek;
int dayNumber = index - startDayOfWeek + 1;
int daysInMonth = DateTime.DaysInMonth(_displayMonth.Year, _displayMonth.Month);
if (dayNumber >= 1 && dayNumber <= daysInMonth)
{
// 시간 유지
int hour = _hourStepper?.Value ?? _selectedDate.Hour;
int minute = _minuteStepper?.Value ?? _selectedDate.Minute;
DateTime clickedDate = new DateTime(
_displayMonth.Year,
_displayMonth.Month,
dayNumber,
hour,
minute,
0
);
if (IsRangeMode)
{
// 범위 선택 모드
if (_rangeState == RangeSelectionState.SelectingStart)
{
// 시작일 선택
_rangeStartDate = clickedDate;
_rangeEndDate = null;
_rangeState = RangeSelectionState.SelectingEnd;
}
else
{
// 종료일 선택
if (_rangeStartDate.HasValue && clickedDate < _rangeStartDate.Value)
{
// 클릭한 날짜가 시작일보다 이전이면 시작일로 설정하고 기존 시작일을 종료일로
_rangeEndDate = _rangeStartDate;
_rangeStartDate = clickedDate;
}
else
{
_rangeEndDate = clickedDate;
}
_rangeState = RangeSelectionState.SelectingStart;
}
UpdateRangeInfo();
}
else
{
// 단일 날짜 선택 모드
_selectedDate = clickedDate;
}
UpdateCalendar();
}
}
private void Confirm()
{
if (IsRangeMode)
{
// 범위 선택 모드
if (_rangeStartDate.HasValue && _rangeEndDate.HasValue)
{
OnDateRangeSelected?.Invoke(_rangeStartDate.Value, _rangeEndDate.Value);
}
else if (_rangeStartDate.HasValue)
{
// 종료일이 없으면 시작일을 종료일로도 사용
OnDateRangeSelected?.Invoke(_rangeStartDate.Value, _rangeStartDate.Value);
}
}
else
{
OnDateSelected?.Invoke(GetDate());
}
Close();
}
private void SetTitle(string title)
{
if (_titleLabel != null)
_titleLabel.text = title;
}
private void SetTimeVisible(bool visible)
{
if (_timeRow != null)
_timeRow.style.display = visible ? DisplayStyle.Flex : DisplayStyle.None;
}
private void UpdateTimeFields()
{
if (_hourStepper != null)
_hourStepper.SetValue(_selectedDate.Hour, notify: false);
if (_minuteStepper != null)
_minuteStepper.SetValue(_selectedDate.Minute, notify: false);
}
private void CenterOnScreen()
{
// position absolute 강제 적용
style.position = Position.Absolute;
schedule.Execute(() =>
{
var parent = this.parent;
if (parent == null) return;
float parentWidth = parent.resolvedStyle.width;
float parentHeight = parent.resolvedStyle.height;
float selfWidth = resolvedStyle.width;
float selfHeight = resolvedStyle.height;
// NaN 체크
if (float.IsNaN(parentWidth) || float.IsNaN(parentHeight) ||
float.IsNaN(selfWidth) || float.IsNaN(selfHeight))
{
// 다음 프레임에 다시 시도
schedule.Execute(() => CenterOnScreen());
return;
}
style.left = (parentWidth - selfWidth) / 2;
style.top = (parentHeight - selfHeight) / 2;
// 위치 계산 완료 후 표시 (깜빡임 방지)
style.visibility = Visibility.Visible;
});
}
private void SetRangeInfoVisible(bool visible)
{
if (_rangeInfoLabel != null)
{
_rangeInfoLabel.style.display = visible ? DisplayStyle.Flex : DisplayStyle.None;
}
}
private void UpdateRangeInfo()
{
if (_rangeInfoLabel == null) return;
string startText = _rangeStartDate?.ToString("yyyy-MM-dd") ?? "---";
string endText = _rangeEndDate?.ToString("yyyy-MM-dd") ?? "---";
string stateIndicator = _rangeState == RangeSelectionState.SelectingStart ? "" : " ";
string endStateIndicator = _rangeState == RangeSelectionState.SelectingEnd ? "" : " ";
_rangeInfoLabel.text = $"{stateIndicator}Start: {startText} {endStateIndicator}End: {endText}";
}
/// <summary>
/// 범위 선택 모드인지 확인
/// </summary>
private bool IsRangeMode => _mode == PickerMode.DateRange || _mode == PickerMode.DateTimeRange;
#endregion
#region IDisposable
public void Dispose()
{
if (_disposed) return;
_disposed = true;
// 테마 변경 이벤트 구독 해제
UTKThemeManager.Instance.OnThemeChanged -= OnThemeChanged;
UnregisterCallback<AttachToPanelEvent>(OnAttachToPanelForTheme);
UnregisterCallback<DetachFromPanelEvent>(OnDetachFromPanelForTheme);
// 언어 변경 이벤트 구독 해제
LocalizationManager.Instance.OnLanguageChanged -= OnLanguageChanged;
_dayButtons.Clear();
OnDateSelected = null;
OnClosed = null;
_blocker = null;
}
#endregion
}
}