feat(schedule): 달력 UI 개선 및 검색 준비

- ExpandablePageView로 달력 높이 동적 조절 (월별 주 수에 따라)
- 데이트픽커 년도 변경 시 스와이프 애니메이션 추가
- 달력 월 변경 시 일정 점 비동기 업데이트 (캐시 기반)
- 모든 달력/데이트픽커 텍스트에 Pretendard 폰트 적용
- 데이트픽커 화살표 터치 영역 확대 및 ripple effect 추가
- 데이트픽커 펼침 시 툴바 좌우 화살표 숨김 (페이드 애니메이션)
- expandable_page_view 패키지 추가

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-13 20:37:00 +09:00
parent 895d9c26a3
commit c4cbdc7d33
4 changed files with 557 additions and 298 deletions

View file

@ -15,12 +15,15 @@ class ScheduleState {
final List<Schedule> schedules; final List<Schedule> schedules;
final bool isLoading; final bool isLoading;
final String? error; final String? error;
// (key: "yyyy-MM")
final Map<String, List<Schedule>> calendarCache;
const ScheduleState({ const ScheduleState({
required this.selectedDate, required this.selectedDate,
this.schedules = const [], this.schedules = const [],
this.isLoading = false, this.isLoading = false,
this.error, this.error,
this.calendarCache = const {},
}); });
/// ( ) /// ( )
@ -29,12 +32,14 @@ class ScheduleState {
List<Schedule>? schedules, List<Schedule>? schedules,
bool? isLoading, bool? isLoading,
String? error, String? error,
Map<String, List<Schedule>>? calendarCache,
}) { }) {
return ScheduleState( return ScheduleState(
selectedDate: selectedDate ?? this.selectedDate, selectedDate: selectedDate ?? this.selectedDate,
schedules: schedules ?? this.schedules, schedules: schedules ?? this.schedules,
isLoading: isLoading ?? this.isLoading, isLoading: isLoading ?? this.isLoading,
error: error, error: error,
calendarCache: calendarCache ?? this.calendarCache,
); );
} }
@ -45,11 +50,29 @@ class ScheduleState {
} }
/// ( , 3) /// ( , 3)
/// , schedules에서
List<Schedule> getDaySchedules(DateTime date) { List<Schedule> getDaySchedules(DateTime date) {
final dateStr = DateFormat('yyyy-MM-dd').format(date); final dateStr = DateFormat('yyyy-MM-dd').format(date);
final cacheKey = DateFormat('yyyy-MM').format(date);
//
if (calendarCache.containsKey(cacheKey)) {
return calendarCache[cacheKey]!
.where((s) => s.date.split('T')[0] == dateStr)
.take(3)
.toList();
}
// schedules에서
return schedules.where((s) => s.date.split('T')[0] == dateStr).take(3).toList(); return schedules.where((s) => s.date.split('T')[0] == dateStr).take(3).toList();
} }
///
bool hasMonthCache(int year, int month) {
final cacheKey = '$year-${month.toString().padLeft(2, '0')}';
return calendarCache.containsKey(cacheKey);
}
/// ///
List<DateTime> get daysInMonth { List<DateTime> get daysInMonth {
final year = selectedDate.year; final year = selectedDate.year;
@ -79,12 +102,38 @@ class ScheduleController extends Notifier<ScheduleState> {
state.selectedDate.year, state.selectedDate.year,
state.selectedDate.month, state.selectedDate.month,
); );
state = state.copyWith(schedules: schedules, isLoading: false); //
final cacheKey = '${state.selectedDate.year}-${state.selectedDate.month.toString().padLeft(2, '0')}';
final newCache = Map<String, List<Schedule>>.from(state.calendarCache);
newCache[cacheKey] = schedules;
state = state.copyWith(
schedules: schedules,
isLoading: false,
calendarCache: newCache,
);
} catch (e) { } catch (e) {
state = state.copyWith(isLoading: false, error: e.toString()); state = state.copyWith(isLoading: false, error: e.toString());
} }
} }
/// (UI )
Future<void> loadCalendarMonth(int year, int month) async {
final cacheKey = '$year-${month.toString().padLeft(2, '0')}';
//
if (state.calendarCache.containsKey(cacheKey)) return;
try {
final schedules = await getSchedules(year, month);
//
final newCache = Map<String, List<Schedule>>.from(state.calendarCache);
newCache[cacheKey] = schedules;
state = state.copyWith(calendarCache: newCache);
} catch (e) {
// ( )
}
}
/// ///
void selectDate(DateTime date) { void selectDate(DateTime date) {
state = state.copyWith(selectedDate: date); state = state.copyWith(selectedDate: date);

View file

@ -5,6 +5,7 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:expandable_page_view/expandable_page_view.dart';
import '../../core/constants.dart'; import '../../core/constants.dart';
import '../../models/schedule.dart'; import '../../models/schedule.dart';
import '../../controllers/schedule_controller.dart'; import '../../controllers/schedule_controller.dart';
@ -27,7 +28,8 @@ class ScheduleView extends ConsumerStatefulWidget {
ConsumerState<ScheduleView> createState() => _ScheduleViewState(); ConsumerState<ScheduleView> createState() => _ScheduleViewState();
} }
class _ScheduleViewState extends ConsumerState<ScheduleView> { class _ScheduleViewState extends ConsumerState<ScheduleView>
with SingleTickerProviderStateMixin {
final ScrollController _dateScrollController = ScrollController(); final ScrollController _dateScrollController = ScrollController();
final ScrollController _contentScrollController = ScrollController(); final ScrollController _contentScrollController = ScrollController();
DateTime? _lastSelectedDate; DateTime? _lastSelectedDate;
@ -38,13 +40,81 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
bool _showYearMonthPicker = false; bool _showYearMonthPicker = false;
int _yearRangeStart = (DateTime.now().year ~/ 12) * 12; int _yearRangeStart = (DateTime.now().year ~/ 12) * 12;
// PageView
late PageController _calendarPageController;
late PageController _yearPageController;
static const int _initialPage = 1000;
//
late AnimationController _calendarAnimController;
late Animation<double> _calendarAnimation;
@override
void initState() {
super.initState();
_calendarPageController = PageController(initialPage: _initialPage);
_yearPageController = PageController(initialPage: _initialPage);
_calendarAnimController = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_calendarAnimation = CurvedAnimation(
parent: _calendarAnimController,
curve: Curves.easeOut,
reverseCurve: Curves.easeIn,
);
}
@override @override
void dispose() { void dispose() {
_dateScrollController.dispose(); _dateScrollController.dispose();
_contentScrollController.dispose(); _contentScrollController.dispose();
_calendarPageController.dispose();
_yearPageController.dispose();
_calendarAnimController.dispose();
super.dispose(); super.dispose();
} }
///
void _openCalendar(DateTime initialDate) {
final today = DateTime.now();
final monthDelta = (initialDate.year - today.year) * 12 + (initialDate.month - today.month);
_yearRangeStart = (initialDate.year ~/ 12) * 12;
// PageView
final baseYearRange = (today.year ~/ 12) * 12;
final yearPageDelta = (_yearRangeStart - baseYearRange) ~/ 12;
setState(() {
_calendarViewDate = initialDate;
_showCalendar = true;
});
// PageView
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _calendarPageController.hasClients) {
_calendarPageController.jumpToPage(_initialPage + monthDelta);
}
if (mounted && _yearPageController.hasClients) {
_yearPageController.jumpToPage(_initialPage + yearPageDelta);
}
});
_calendarAnimController.forward();
}
///
void _closeCalendar() {
_calendarAnimController.reverse().then((_) {
if (mounted) {
setState(() {
_showCalendar = false;
_showYearMonthPicker = false;
});
}
});
}
/// ///
void _scrollToSelectedDate(DateTime selectedDate) { void _scrollToSelectedDate(DateTime selectedDate) {
if (!_dateScrollController.hasClients) return; if (!_dateScrollController.hasClients) return;
@ -103,6 +173,10 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
}); });
} }
// +
final safeTop = MediaQuery.of(context).padding.top;
final overlayTop = safeTop + 56 + 80; // toolbar(56) + date selector(80)
return Stack( return Stack(
children: [ children: [
// //
@ -118,29 +192,29 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
), ),
], ],
), ),
// ( ) //
if (_showCalendar) ...[ if (_showCalendar) ...[
// ( ) // ( )
Positioned( Positioned(
top: MediaQuery.of(context).padding.top + 56, top: overlayTop,
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
child: GestureDetector( child: AnimatedBuilder(
onTap: () { animation: _calendarAnimation,
setState(() { builder: (context, child) {
_showCalendar = false; return GestureDetector(
_showYearMonthPicker = false; onTap: _closeCalendar,
});
},
child: Container( child: Container(
color: Colors.black.withValues(alpha: 0.4), color: Colors.black.withValues(alpha: 0.4 * _calendarAnimation.value),
), ),
);
},
), ),
), ),
// () // ()
Positioned( Positioned(
top: MediaQuery.of(context).padding.top + 56, top: safeTop + 56,
left: 0, left: 0,
right: 0, right: 0,
child: _buildCalendarPopup(scheduleState, controller), child: _buildCalendarPopup(scheduleState, controller),
@ -154,6 +228,7 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
Widget _buildToolbar(ScheduleState state, ScheduleController controller) { Widget _buildToolbar(ScheduleState state, ScheduleController controller) {
// , // ,
final displayDate = _showCalendar ? _calendarViewDate : state.selectedDate; final displayDate = _showCalendar ? _calendarViewDate : state.selectedDate;
final yearMonthText = '${displayDate.year}${displayDate.month}';
return Container( return Container(
color: Colors.white, color: Colors.white,
@ -167,44 +242,40 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
// //
IconButton( IconButton(
onPressed: () { onPressed: () {
setState(() {
if (_showCalendar) { if (_showCalendar) {
_showCalendar = false; _closeCalendar();
_showYearMonthPicker = false;
} else { } else {
_calendarViewDate = state.selectedDate; _openCalendar(state.selectedDate);
_showCalendar = true;
} }
});
}, },
icon: const Icon(Icons.calendar_today_outlined, size: 20), icon: const Icon(Icons.calendar_today_outlined, size: 20),
color: _showCalendar ? AppColors.primary : AppColors.textSecondary, color: _showCalendar ? AppColors.primary : AppColors.textSecondary,
), ),
// // ( , )
IconButton( AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: !_showYearMonthPicker
? IconButton(
key: const ValueKey('prev_button'),
onPressed: () { onPressed: () {
if (_showCalendar) { if (_showCalendar) {
setState(() { _calendarPageController.previousPage(
_calendarViewDate = DateTime( duration: const Duration(milliseconds: 300),
_calendarViewDate.year, curve: Curves.easeInOut,
_calendarViewDate.month - 1,
1,
); );
});
} else { } else {
controller.changeMonth(-1); controller.changeMonth(-1);
} }
}, },
icon: const Icon(Icons.chevron_left, size: 24), icon: const Icon(Icons.chevron_left, size: 24),
color: AppColors.textPrimary, color: AppColors.textPrimary,
)
: const SizedBox(key: ValueKey('prev_empty'), width: 48),
), ),
// //
Expanded( Expanded(
child: Stack( child: GestureDetector(
alignment: Alignment.center, behavior: HitTestBehavior.opaque,
children: [
// ( )
GestureDetector(
onTap: _showCalendar onTap: _showCalendar
? () { ? () {
setState(() { setState(() {
@ -213,9 +284,22 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
}); });
} }
: null, : null,
child: Text( child: Center(
'${displayDate.year}${displayDate.month}', child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// ( , )
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: _showCalendar
? const SizedBox(key: ValueKey('arrow_space'), width: 20)
: const SizedBox(key: ValueKey('no_space'), width: 0),
),
// ( )
Text(
yearMonthText,
style: TextStyle( style: TextStyle(
fontFamily: 'Pretendard',
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: _showYearMonthPicker color: _showYearMonthPicker
@ -223,20 +307,16 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
: AppColors.textPrimary, : AppColors.textPrimary,
), ),
), ),
), // ( )
// ( ) AnimatedSwitcher(
if (_showCalendar) duration: const Duration(milliseconds: 200),
Positioned( child: _showCalendar
// + ? Row(
left: MediaQuery.of(context).size.width / 2 - 48 + 52, key: ValueKey('dropdown_$_showYearMonthPicker'),
child: GestureDetector( mainAxisSize: MainAxisSize.min,
onTap: () { children: [
setState(() { const SizedBox(width: 2),
_showYearMonthPicker = !_showYearMonthPicker; Icon(
_yearRangeStart = (_calendarViewDate.year ~/ 12) * 12;
});
},
child: Icon(
_showYearMonthPicker _showYearMonthPicker
? Icons.keyboard_arrow_up ? Icons.keyboard_arrow_up
: Icons.keyboard_arrow_down, : Icons.keyboard_arrow_down,
@ -245,28 +325,35 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
? AppColors.primary ? AppColors.primary
: AppColors.textPrimary, : AppColors.textPrimary,
), ),
), ],
)
: const SizedBox(key: ValueKey('no_dropdown'), width: 0),
), ),
], ],
), ),
), ),
// ),
IconButton( ),
// ( , )
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: !_showYearMonthPicker
? IconButton(
key: const ValueKey('next_button'),
onPressed: () { onPressed: () {
if (_showCalendar) { if (_showCalendar) {
setState(() { _calendarPageController.nextPage(
_calendarViewDate = DateTime( duration: const Duration(milliseconds: 300),
_calendarViewDate.year, curve: Curves.easeInOut,
_calendarViewDate.month + 1,
1,
); );
});
} else { } else {
controller.changeMonth(1); controller.changeMonth(1);
} }
}, },
icon: const Icon(Icons.chevron_right, size: 24), icon: const Icon(Icons.chevron_right, size: 24),
color: AppColors.textPrimary, color: AppColors.textPrimary,
)
: const SizedBox(key: ValueKey('next_empty'), width: 48),
), ),
// (3 ) // (3 )
IconButton( IconButton(
@ -285,17 +372,15 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
/// ///
Widget _buildCalendarPopup(ScheduleState state, ScheduleController controller) { Widget _buildCalendarPopup(ScheduleState state, ScheduleController controller) {
return TweenAnimationBuilder<double>( return AnimatedBuilder(
duration: const Duration(milliseconds: 200), animation: _calendarAnimation,
curve: Curves.easeOut, builder: (context, child) {
tween: Tween(begin: 0.0, end: 1.0),
builder: (context, value, child) {
return ClipRect( return ClipRect(
child: Align( child: Align(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
heightFactor: value, heightFactor: _calendarAnimation.value,
child: Opacity( child: Opacity(
opacity: value, opacity: _calendarAnimation.value,
child: child, child: child,
), ),
), ),
@ -303,14 +388,52 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
}, },
child: Material( child: Material(
color: Colors.white, color: Colors.white,
elevation: 8, elevation: 0,
child: AnimatedCrossFade( child: AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
alignment: Alignment.topCenter,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
crossFadeState: _showYearMonthPicker switchInCurve: Curves.easeOut,
? CrossFadeState.showSecond switchOutCurve: Curves.easeIn,
: CrossFadeState.showFirst, layoutBuilder: (currentChild, previousChildren) {
firstChild: _buildCalendarGrid(state, controller), //
secondChild: _buildYearMonthPicker(), return Stack(
alignment: Alignment.topCenter,
children: [
...previousChildren,
if (currentChild != null) currentChild,
],
);
},
transitionBuilder: (child, animation) {
//
final isYearMonthPicker = child.key == const ValueKey('yearMonth');
if (isYearMonthPicker) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, -0.05),
end: Offset.zero,
).animate(animation),
child: child,
),
);
}
return FadeTransition(opacity: animation, child: child);
},
child: _showYearMonthPicker
? KeyedSubtree(
key: const ValueKey('yearMonth'),
child: _buildYearMonthPicker(),
)
: KeyedSubtree(
key: const ValueKey('calendar'),
child: _buildCalendarGrid(state, controller),
),
),
), ),
), ),
); );
@ -318,11 +441,12 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
/// ///
Widget _buildYearMonthPicker() { Widget _buildYearMonthPicker() {
final yearRange = List.generate(12, (i) => _yearRangeStart + i); final today = DateTime.now();
return Padding( return Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
// //
Row( Row(
@ -330,26 +454,37 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
children: [ children: [
IconButton( IconButton(
onPressed: () { onPressed: () {
setState(() { _yearPageController.previousPage(
_yearRangeStart -= 12; duration: const Duration(milliseconds: 300),
}); curve: Curves.easeInOut,
);
}, },
icon: const Icon(Icons.chevron_left, size: 18), icon: const Icon(Icons.chevron_left, size: 20),
color: AppColors.textPrimary,
splashRadius: 20,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
), ),
Text( Text(
'$_yearRangeStart - ${_yearRangeStart + 11}', '$_yearRangeStart - ${_yearRangeStart + 11}',
style: const TextStyle( style: const TextStyle(
fontFamily: 'Pretendard',
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
IconButton( IconButton(
onPressed: () { onPressed: () {
setState(() { _yearPageController.nextPage(
_yearRangeStart += 12; duration: const Duration(milliseconds: 300),
}); curve: Curves.easeInOut,
);
}, },
icon: const Icon(Icons.chevron_right, size: 18), icon: const Icon(Icons.chevron_right, size: 20),
color: AppColors.textPrimary,
splashRadius: 20,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
), ),
], ],
), ),
@ -357,68 +492,51 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
// //
const Text( const Text(
'년도', '년도',
style: TextStyle(fontSize: 12, color: AppColors.textTertiary), style: TextStyle(fontFamily: 'Pretendard', fontSize: 12, color: AppColors.textTertiary),
), ),
const SizedBox(height: 8), const SizedBox(height: 4),
// // (ExpandablePageView로 )
GridView.builder( ExpandablePageView.builder(
shrinkWrap: true, controller: _yearPageController,
physics: const NeverScrollableScrollPhysics(), itemCount: _initialPage * 2,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( onPageChanged: (page) {
crossAxisCount: 4, final delta = page - _initialPage;
mainAxisSpacing: 8, final baseYearRange = (today.year ~/ 12) * 12;
crossAxisSpacing: 8,
childAspectRatio: 2,
),
itemCount: 12,
itemBuilder: (context, index) {
final year = yearRange[index];
final isSelected = year == _calendarViewDate.year;
return GestureDetector(
onTap: () {
setState(() { setState(() {
_calendarViewDate = DateTime(year, _calendarViewDate.month, 1); _yearRangeStart = baseYearRange + (delta * 12);
}); });
}, },
child: Container( itemBuilder: (context, page) {
decoration: BoxDecoration( final delta = page - _initialPage;
color: isSelected ? AppColors.primary : Colors.transparent, final baseYearRange = (today.year ~/ 12) * 12;
borderRadius: BorderRadius.circular(8), final rangeStart = baseYearRange + (delta * 12);
), final yearRange = List.generate(12, (i) => rangeStart + i);
alignment: Alignment.center, return _buildYearGrid(yearRange: yearRange, today: today);
child: Text(
'$year',
style: TextStyle(
fontSize: 14,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
color: isSelected ? Colors.white : AppColors.textPrimary,
),
),
),
);
}, },
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// //
const Text( const Text(
'', '',
style: TextStyle(fontSize: 12, color: AppColors.textTertiary), style: TextStyle(fontFamily: 'Pretendard', fontSize: 12, color: AppColors.textTertiary),
), ),
const SizedBox(height: 8), const SizedBox(height: 4),
// //
GridView.builder( GridView.builder(
shrinkWrap: true, shrinkWrap: true,
padding: EdgeInsets.zero,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4, crossAxisCount: 4,
mainAxisSpacing: 8, mainAxisSpacing: 6,
crossAxisSpacing: 8, crossAxisSpacing: 8,
childAspectRatio: 2, childAspectRatio: 2.0,
), ),
itemCount: 12, itemCount: 12,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final month = index + 1; final month = index + 1;
final isSelected = month == _calendarViewDate.month; final isSelected = month == _calendarViewDate.month;
final isCurrentMonth = month == today.month && _calendarViewDate.year == today.year;
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
setState(() { setState(() {
@ -426,19 +544,27 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
_showYearMonthPicker = false; _showYearMonthPicker = false;
}); });
}, },
child: Container( child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? AppColors.primary : Colors.transparent, color: isSelected ? AppColors.primary : Colors.transparent,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
alignment: Alignment.center, alignment: Alignment.center,
child: Text( child: AnimatedDefaultTextStyle(
'$month월', duration: const Duration(milliseconds: 150),
style: TextStyle( style: TextStyle(
fontFamily: 'Pretendard',
fontSize: 14, fontSize: 14,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, fontWeight: isSelected || isCurrentMonth ? FontWeight.w600 : FontWeight.w400,
color: isSelected ? Colors.white : AppColors.textPrimary, color: isSelected
? Colors.white
: isCurrentMonth
? AppColors.primary
: AppColors.textPrimary,
), ),
child: Text('$month월'),
), ),
), ),
); );
@ -449,31 +575,24 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
); );
} }
/// ///
Widget _buildCalendarGrid(ScheduleState state, ScheduleController controller) { List<DateTime> _getMonthDays(int year, int month) {
final year = _calendarViewDate.year;
final month = _calendarViewDate.month;
//
final firstDay = DateTime(year, month, 1); final firstDay = DateTime(year, month, 1);
final lastDay = DateTime(year, month + 1, 0); final lastDay = DateTime(year, month + 1, 0);
final startWeekday = firstDay.weekday % 7; // 0 = final startWeekday = firstDay.weekday % 7;
final daysInMonth = lastDay.day; final daysInMonth = lastDay.day;
//
final prevMonth = DateTime(year, month, 0); final prevMonth = DateTime(year, month, 0);
final prevMonthDays = List.generate( final prevMonthDays = List.generate(
startWeekday, startWeekday,
(i) => DateTime(year, month - 1, prevMonth.day - startWeekday + 1 + i), (i) => DateTime(year, month - 1, prevMonth.day - startWeekday + 1 + i),
); );
//
final currentMonthDays = List.generate( final currentMonthDays = List.generate(
daysInMonth, daysInMonth,
(i) => DateTime(year, month, i + 1), (i) => DateTime(year, month, i + 1),
); );
// ( )
final totalDays = prevMonthDays.length + currentMonthDays.length; final totalDays = prevMonthDays.length + currentMonthDays.length;
final remaining = (7 - (totalDays % 7)) % 7; final remaining = (7 - (totalDays % 7)) % 7;
final nextMonthDays = List.generate( final nextMonthDays = List.generate(
@ -481,11 +600,69 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
(i) => DateTime(year, month + 1, i + 1), (i) => DateTime(year, month + 1, i + 1),
); );
final allDays = [...prevMonthDays, ...currentMonthDays, ...nextMonthDays]; return [...prevMonthDays, ...currentMonthDays, ...nextMonthDays];
}
///
Widget _buildYearGrid({
required List<int> yearRange,
required DateTime today,
}) {
return GridView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 6,
crossAxisSpacing: 8,
childAspectRatio: 2.0,
),
itemCount: 12,
itemBuilder: (context, index) {
final year = yearRange[index];
final isSelected = year == _calendarViewDate.year;
final isCurrentYear = year == today.year;
return GestureDetector(
onTap: () {
setState(() {
_calendarViewDate = DateTime(year, _calendarViewDate.month, 1);
});
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
decoration: BoxDecoration(
color: isSelected ? AppColors.primary : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 150),
style: TextStyle(
fontFamily: 'Pretendard',
fontSize: 14,
fontWeight: isSelected || isCurrentYear ? FontWeight.w600 : FontWeight.w400,
color: isSelected
? Colors.white
: isCurrentYear
? AppColors.primary
: AppColors.textPrimary,
),
child: Text('$year'),
),
),
);
},
);
}
///
Widget _buildCalendarGrid(ScheduleState state, ScheduleController controller) {
return Padding( return Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
// //
Row( Row(
@ -497,6 +674,7 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
child: Text( child: Text(
day, day,
style: TextStyle( style: TextStyle(
fontFamily: 'Pretendard',
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: index == 0 color: index == 0
@ -510,16 +688,92 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
); );
}).toList(), }).toList(),
), ),
const SizedBox(height: 8), const SizedBox(height: 4),
// // (ExpandablePageView로 )
GridView.builder( ExpandablePageView.builder(
controller: _calendarPageController,
itemCount: _initialPage * 2, //
onPageChanged: (page) {
final delta = page - _initialPage;
final newDate = DateTime(
DateTime.now().year,
DateTime.now().month + delta,
1,
);
setState(() {
_calendarViewDate = newDate;
});
// ( )
controller.loadCalendarMonth(newDate.year, newDate.month);
},
itemBuilder: (context, page) {
final delta = page - _initialPage;
final targetDate = DateTime(
DateTime.now().year,
DateTime.now().month + delta,
1,
);
//
if (!state.hasMonthCache(targetDate.year, targetDate.month)) {
controller.loadCalendarMonth(targetDate.year, targetDate.month);
}
final allDays = _getMonthDays(targetDate.year, targetDate.month);
return _buildCalendarDaysGrid(
allDays: allDays,
month: targetDate.month,
state: state,
controller: controller,
);
},
),
const SizedBox(height: 12),
//
GestureDetector(
onTap: () {
final today = DateTime.now();
controller.goToDate(today);
if (_contentScrollController.hasClients) {
_contentScrollController.jumpTo(0);
}
_calendarPageController.jumpToPage(_initialPage);
_closeCalendar();
},
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 _buildCalendarDaysGrid({
required List<DateTime> allDays,
required int month,
required ScheduleState state,
required ScheduleController controller,
}) {
return GridView.builder(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 7, crossAxisCount: 7,
mainAxisSpacing: 4, mainAxisSpacing: 4,
crossAxisSpacing: 4, crossAxisSpacing: 4,
childAspectRatio: 1, mainAxisExtent: 46, // Container(36) + SizedBox(6) + (4)
), ),
itemCount: allDays.length, itemCount: allDays.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
@ -532,17 +786,11 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
//
controller.goToDate(date); controller.goToDate(date);
//
if (_contentScrollController.hasClients) { if (_contentScrollController.hasClients) {
_contentScrollController.jumpTo(0); _contentScrollController.jumpTo(0);
} }
// _closeCalendar();
setState(() {
_showCalendar = false;
_showYearMonthPicker = false;
});
}, },
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@ -567,10 +815,9 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
child: Text( child: Text(
'${date.day}', '${date.day}',
style: TextStyle( style: TextStyle(
fontFamily: 'Pretendard',
fontSize: 14, fontSize: 14,
fontWeight: isSelected || isToday fontWeight: isSelected || isToday ? FontWeight.bold : FontWeight.w400,
? FontWeight.bold
: FontWeight.w400,
color: !isCurrentMonth color: !isCurrentMonth
? AppColors.textTertiary.withValues(alpha: 0.5) ? AppColors.textTertiary.withValues(alpha: 0.5)
: isSelected : isSelected
@ -585,7 +832,6 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
), ),
), ),
), ),
// ( )
SizedBox( SizedBox(
height: 6, height: 6,
child: !isSelected && daySchedules.isNotEmpty child: !isSelected && daySchedules.isNotEmpty
@ -609,58 +855,14 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
), ),
); );
}, },
),
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) { Widget _buildDateSelector(ScheduleState state, ScheduleController controller) {
return Container( return Container(
color: Colors.white,
child: Container(
height: 80, height: 80,
decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 4,
offset: const Offset(0, 1),
),
],
),
child: ListView.builder( child: ListView.builder(
controller: _dateScrollController, controller: _dateScrollController,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
@ -742,7 +944,6 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
); );
}, },
), ),
),
); );
} }

View file

@ -169,6 +169,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.1" version: "2.1.1"
expandable_page_view:
dependency: "direct main"
description:
name: expandable_page_view
sha256: "2d2c9e6fbbaa153f761054200c199eb69dc45948c8018b98a871212c67b60608"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:

View file

@ -49,6 +49,7 @@ dependencies:
modal_bottom_sheet: ^3.0.0 modal_bottom_sheet: ^3.0.0
video_player: ^2.9.2 video_player: ^2.9.2
chewie: ^1.8.5 chewie: ^1.8.5
expandable_page_view: ^1.0.17
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: