Flutter 앱: 일정 화면 2단계 - 달력 팝업 구현

달력 아이콘 클릭시 달력 팝업 표시:
- 월 그리드 (요일 헤더 + 날짜 + 일정 점 표시)
- 년월 선택 모드 (년도 범위 + 월 선택)
- 오늘 버튼
- 배경 터치로 닫기

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-13 18:25:14 +09:00
parent 671618442c
commit 21bd887f5e
2 changed files with 477 additions and 20 deletions

View file

@ -110,6 +110,19 @@ class ScheduleController extends Notifier<ScheduleState> {
loadSchedules();
}
/// ( )
void goToDate(DateTime date) {
final currentMonth = state.selectedDate.month;
final currentYear = state.selectedDate.year;
state = state.copyWith(selectedDate: date);
//
if (date.month != currentMonth || date.year != currentYear) {
loadSchedules();
}
}
///
bool isToday(DateTime date) {
final today = DateTime.now();

View file

@ -32,6 +32,12 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
final ScrollController _contentScrollController = ScrollController();
DateTime? _lastSelectedDate;
//
bool _showCalendar = false;
DateTime _calendarViewDate = DateTime.now();
bool _showYearMonthPicker = false;
int _yearRangeStart = (DateTime.now().year ~/ 12) * 12;
@override
void dispose() {
_dateScrollController.dispose();
@ -97,22 +103,54 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
});
}
return Column(
return Stack(
children: [
//
_buildToolbar(scheduleState, controller),
//
_buildDateSelector(scheduleState, controller),
//
Expanded(
child: _buildScheduleList(scheduleState),
//
Column(
children: [
//
_buildToolbar(scheduleState, controller),
//
_buildDateSelector(scheduleState, controller),
//
Expanded(
child: _buildScheduleList(scheduleState),
),
],
),
//
if (_showCalendar) ...[
//
Positioned.fill(
child: GestureDetector(
onTap: () {
setState(() {
_showCalendar = false;
_showYearMonthPicker = false;
});
},
child: Container(
color: Colors.black.withValues(alpha: 0.4),
),
),
),
//
Positioned(
top: MediaQuery.of(context).padding.top + 56,
left: 0,
right: 0,
child: _buildCalendarPopup(scheduleState, controller),
),
],
],
);
}
///
Widget _buildToolbar(ScheduleState state, ScheduleController controller) {
// ,
final displayDate = _showCalendar ? _calendarViewDate : state.selectedDate;
return Container(
color: Colors.white,
child: SafeArea(
@ -122,36 +160,97 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
children: [
// (2 )
//
IconButton(
onPressed: () {
// TODO:
setState(() {
if (_showCalendar) {
_showCalendar = false;
_showYearMonthPicker = false;
} else {
_calendarViewDate = state.selectedDate;
_showCalendar = true;
}
});
},
icon: const Icon(Icons.calendar_today_outlined, size: 20),
color: AppColors.textSecondary,
color: _showCalendar ? AppColors.primary : AppColors.textSecondary,
),
//
IconButton(
onPressed: () => controller.changeMonth(-1),
onPressed: () {
if (_showCalendar) {
setState(() {
_calendarViewDate = DateTime(
_calendarViewDate.year,
_calendarViewDate.month - 1,
1,
);
});
} else {
controller.changeMonth(-1);
}
},
icon: const Icon(Icons.chevron_left, size: 24),
color: AppColors.textPrimary,
),
//
Expanded(
child: Center(
child: Text(
'${state.selectedDate.year}${state.selectedDate.month}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppColors.textPrimary,
child: GestureDetector(
onTap: _showCalendar
? () {
setState(() {
_showYearMonthPicker = !_showYearMonthPicker;
_yearRangeStart = (_calendarViewDate.year ~/ 12) * 12;
});
}
: null,
child: Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${displayDate.year}${displayDate.month}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: _showYearMonthPicker
? AppColors.primary
: AppColors.textPrimary,
),
),
if (_showCalendar) ...[
const SizedBox(width: 4),
Icon(
_showYearMonthPicker
? Icons.keyboard_arrow_up
: Icons.keyboard_arrow_down,
size: 18,
color: _showYearMonthPicker
? AppColors.primary
: AppColors.textPrimary,
),
],
],
),
),
),
),
//
IconButton(
onPressed: () => controller.changeMonth(1),
onPressed: () {
if (_showCalendar) {
setState(() {
_calendarViewDate = DateTime(
_calendarViewDate.year,
_calendarViewDate.month + 1,
1,
);
});
} else {
controller.changeMonth(1);
}
},
icon: const Icon(Icons.chevron_right, size: 24),
color: AppColors.textPrimary,
),
@ -170,6 +269,351 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
);
}
///
Widget _buildCalendarPopup(ScheduleState state, ScheduleController controller) {
return Material(
color: Colors.white,
elevation: 8,
child: AnimatedCrossFade(
duration: const Duration(milliseconds: 150),
crossFadeState: _showYearMonthPicker
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
firstChild: _buildCalendarGrid(state, controller),
secondChild: _buildYearMonthPicker(),
),
);
}
///
Widget _buildYearMonthPicker() {
final yearRange = List.generate(12, (i) => _yearRangeStart + i);
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
//
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
onPressed: () {
setState(() {
_yearRangeStart -= 12;
});
},
icon: const Icon(Icons.chevron_left, size: 18),
),
Text(
'$_yearRangeStart - ${_yearRangeStart + 11}',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
IconButton(
onPressed: () {
setState(() {
_yearRangeStart += 12;
});
},
icon: const Icon(Icons.chevron_right, size: 18),
),
],
),
const SizedBox(height: 8),
//
const Text(
'년도',
style: TextStyle(fontSize: 12, color: AppColors.textTertiary),
),
const SizedBox(height: 8),
//
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 2,
),
itemCount: 12,
itemBuilder: (context, index) {
final year = yearRange[index];
final isSelected = year == _calendarViewDate.year;
return GestureDetector(
onTap: () {
setState(() {
_calendarViewDate = DateTime(year, _calendarViewDate.month, 1);
});
},
child: Container(
decoration: BoxDecoration(
color: isSelected ? AppColors.primary : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: Text(
'$year',
style: TextStyle(
fontSize: 14,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
color: isSelected ? Colors.white : AppColors.textPrimary,
),
),
),
);
},
),
const SizedBox(height: 16),
//
const Text(
'',
style: TextStyle(fontSize: 12, color: AppColors.textTertiary),
),
const SizedBox(height: 8),
//
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 2,
),
itemCount: 12,
itemBuilder: (context, index) {
final month = index + 1;
final isSelected = month == _calendarViewDate.month;
return GestureDetector(
onTap: () {
setState(() {
_calendarViewDate = DateTime(_calendarViewDate.year, month, 1);
_showYearMonthPicker = false;
});
},
child: Container(
decoration: BoxDecoration(
color: isSelected ? AppColors.primary : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: Text(
'$month월',
style: TextStyle(
fontSize: 14,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
color: isSelected ? Colors.white : AppColors.textPrimary,
),
),
),
);
},
),
],
),
);
}
///
Widget _buildCalendarGrid(ScheduleState state, ScheduleController controller) {
final year = _calendarViewDate.year;
final month = _calendarViewDate.month;
//
final firstDay = DateTime(year, month, 1);
final lastDay = DateTime(year, month + 1, 0);
final startWeekday = firstDay.weekday % 7; // 0 =
final daysInMonth = lastDay.day;
//
final prevMonth = DateTime(year, month, 0);
final prevMonthDays = List.generate(
startWeekday,
(i) => DateTime(year, month - 1, prevMonth.day - startWeekday + 1 + i),
);
//
final currentMonthDays = List.generate(
daysInMonth,
(i) => DateTime(year, month, i + 1),
);
// ( )
final totalDays = prevMonthDays.length + currentMonthDays.length;
final remaining = (7 - (totalDays % 7)) % 7;
final nextMonthDays = List.generate(
remaining,
(i) => DateTime(year, month + 1, i + 1),
);
final allDays = [...prevMonthDays, ...currentMonthDays, ...nextMonthDays];
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
//
Row(
children: ['', '', '', '', '', '', ''].asMap().entries.map((entry) {
final index = entry.key;
final day = entry.value;
return Expanded(
child: Center(
child: Text(
day,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: index == 0
? Colors.red.shade400
: index == 6
? Colors.blue.shade400
: AppColors.textSecondary,
),
),
),
);
}).toList(),
),
const SizedBox(height: 8),
//
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 7,
mainAxisSpacing: 4,
crossAxisSpacing: 4,
childAspectRatio: 1,
),
itemCount: allDays.length,
itemBuilder: (context, index) {
final date = allDays[index];
final isCurrentMonth = date.month == month;
final isSelected = controller.isSelected(date);
final isToday = controller.isToday(date);
final dayOfWeek = index % 7;
final daySchedules = isCurrentMonth ? state.getDaySchedules(date) : <Schedule>[];
return GestureDetector(
onTap: () {
//
controller.goToDate(date);
//
if (_contentScrollController.hasClients) {
_contentScrollController.jumpTo(0);
}
//
setState(() {
_showCalendar = false;
_showYearMonthPicker = false;
});
},
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: isSelected ? AppColors.primary : Colors.transparent,
shape: BoxShape.circle,
boxShadow: isSelected
? [
BoxShadow(
color: AppColors.primary.withValues(alpha: 0.4),
blurRadius: 8,
spreadRadius: 1,
),
]
: null,
),
alignment: Alignment.center,
child: Text(
'${date.day}',
style: TextStyle(
fontSize: 14,
fontWeight: isSelected || isToday
? FontWeight.bold
: FontWeight.w400,
color: !isCurrentMonth
? AppColors.textTertiary.withValues(alpha: 0.5)
: isSelected
? Colors.white
: isToday
? AppColors.primary
: dayOfWeek == 0
? Colors.red.shade500
: dayOfWeek == 6
? Colors.blue.shade500
: AppColors.textPrimary,
),
),
),
// ( )
SizedBox(
height: 6,
child: !isSelected && daySchedules.isNotEmpty
? Row(
mainAxisAlignment: MainAxisAlignment.center,
children: daySchedules.map((schedule) {
return Container(
width: 4,
height: 4,
margin: const EdgeInsets.symmetric(horizontal: 1),
decoration: BoxDecoration(
color: _parseColor(schedule.categoryColor),
shape: BoxShape.circle,
),
);
}).toList(),
)
: const SizedBox.shrink(),
),
],
),
);
},
),
const SizedBox(height: 12),
//
GestureDetector(
onTap: () {
final today = DateTime.now();
controller.goToDate(today);
if (_contentScrollController.hasClients) {
_contentScrollController.jumpTo(0);
}
setState(() {
_showCalendar = false;
_showYearMonthPicker = false;
});
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: const Text(
'오늘',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
),
),
],
),
);
}
///
Widget _buildDateSelector(ScheduleState state, ScheduleController controller) {
return Container(