feat(schedule): 달력 UI 개선 및 검색 준비
- ExpandablePageView로 달력 높이 동적 조절 (월별 주 수에 따라) - 데이트픽커 년도 변경 시 스와이프 애니메이션 추가 - 달력 월 변경 시 일정 점 비동기 업데이트 (캐시 기반) - 모든 달력/데이트픽커 텍스트에 Pretendard 폰트 적용 - 데이트픽커 화살표 터치 영역 확대 및 ripple effect 추가 - 데이트픽커 펼침 시 툴바 좌우 화살표 숨김 (페이드 애니메이션) - expandable_page_view 패키지 추가 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
895d9c26a3
commit
c4cbdc7d33
4 changed files with 557 additions and 298 deletions
|
|
@ -15,12 +15,15 @@ class ScheduleState {
|
|||
final List<Schedule> schedules;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
// 달력용 월별 일정 캐시 (key: "yyyy-MM")
|
||||
final Map<String, List<Schedule>> calendarCache;
|
||||
|
||||
const ScheduleState({
|
||||
required this.selectedDate,
|
||||
this.schedules = const [],
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
this.calendarCache = const {},
|
||||
});
|
||||
|
||||
/// 상태 복사 (불변성 유지)
|
||||
|
|
@ -29,12 +32,14 @@ class ScheduleState {
|
|||
List<Schedule>? schedules,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
Map<String, List<Schedule>>? calendarCache,
|
||||
}) {
|
||||
return ScheduleState(
|
||||
selectedDate: selectedDate ?? this.selectedDate,
|
||||
schedules: schedules ?? this.schedules,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
calendarCache: calendarCache ?? this.calendarCache,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -45,11 +50,29 @@ class ScheduleState {
|
|||
}
|
||||
|
||||
/// 특정 날짜의 일정 (점 표시용, 최대 3개)
|
||||
/// 캐시에서 먼저 찾고, 없으면 현재 schedules에서 찾음
|
||||
List<Schedule> getDaySchedules(DateTime 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();
|
||||
}
|
||||
|
||||
/// 특정 월의 일정이 캐시에 있는지 확인
|
||||
bool hasMonthCache(int year, int month) {
|
||||
final cacheKey = '$year-${month.toString().padLeft(2, '0')}';
|
||||
return calendarCache.containsKey(cacheKey);
|
||||
}
|
||||
|
||||
/// 해당 달의 모든 날짜 배열
|
||||
List<DateTime> get daysInMonth {
|
||||
final year = selectedDate.year;
|
||||
|
|
@ -79,12 +102,38 @@ class ScheduleController extends Notifier<ScheduleState> {
|
|||
state.selectedDate.year,
|
||||
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) {
|
||||
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) {
|
||||
state = state.copyWith(selectedDate: date);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ library;
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:expandable_page_view/expandable_page_view.dart';
|
||||
import '../../core/constants.dart';
|
||||
import '../../models/schedule.dart';
|
||||
import '../../controllers/schedule_controller.dart';
|
||||
|
|
@ -27,7 +28,8 @@ class ScheduleView extends ConsumerStatefulWidget {
|
|||
ConsumerState<ScheduleView> createState() => _ScheduleViewState();
|
||||
}
|
||||
|
||||
class _ScheduleViewState extends ConsumerState<ScheduleView> {
|
||||
class _ScheduleViewState extends ConsumerState<ScheduleView>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final ScrollController _dateScrollController = ScrollController();
|
||||
final ScrollController _contentScrollController = ScrollController();
|
||||
DateTime? _lastSelectedDate;
|
||||
|
|
@ -38,13 +40,81 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
|
|||
bool _showYearMonthPicker = false;
|
||||
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
|
||||
void dispose() {
|
||||
_dateScrollController.dispose();
|
||||
_contentScrollController.dispose();
|
||||
_calendarPageController.dispose();
|
||||
_yearPageController.dispose();
|
||||
_calendarAnimController.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) {
|
||||
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(
|
||||
children: [
|
||||
// 메인 콘텐츠
|
||||
|
|
@ -118,29 +192,29 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
|
|||
),
|
||||
],
|
||||
),
|
||||
// 달력 팝업 오버레이 (툴바 아래부터)
|
||||
// 달력 팝업 오버레이
|
||||
if (_showCalendar) ...[
|
||||
// 배경 오버레이 (툴바 제외)
|
||||
// 배경 오버레이 (날짜 선택기 아래부터)
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).padding.top + 56,
|
||||
top: overlayTop,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_showCalendar = false;
|
||||
_showYearMonthPicker = false;
|
||||
});
|
||||
},
|
||||
child: AnimatedBuilder(
|
||||
animation: _calendarAnimation,
|
||||
builder: (context, child) {
|
||||
return GestureDetector(
|
||||
onTap: _closeCalendar,
|
||||
child: Container(
|
||||
color: Colors.black.withValues(alpha: 0.4),
|
||||
color: Colors.black.withValues(alpha: 0.4 * _calendarAnimation.value),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
// 달력 팝업 (애니메이션)
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).padding.top + 56,
|
||||
top: safeTop + 56,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: _buildCalendarPopup(scheduleState, controller),
|
||||
|
|
@ -154,6 +228,7 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
|
|||
Widget _buildToolbar(ScheduleState state, ScheduleController controller) {
|
||||
// 달력이 열려있을 때는 달력 뷰 날짜 기준, 아니면 선택된 날짜 기준
|
||||
final displayDate = _showCalendar ? _calendarViewDate : state.selectedDate;
|
||||
final yearMonthText = '${displayDate.year}년 ${displayDate.month}월';
|
||||
|
||||
return Container(
|
||||
color: Colors.white,
|
||||
|
|
@ -167,44 +242,40 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
|
|||
// 달력 아이콘
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (_showCalendar) {
|
||||
_showCalendar = false;
|
||||
_showYearMonthPicker = false;
|
||||
_closeCalendar();
|
||||
} else {
|
||||
_calendarViewDate = state.selectedDate;
|
||||
_showCalendar = true;
|
||||
_openCalendar(state.selectedDate);
|
||||
}
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.calendar_today_outlined, size: 20),
|
||||
color: _showCalendar ? AppColors.primary : AppColors.textSecondary,
|
||||
),
|
||||
// 이전 월
|
||||
IconButton(
|
||||
// 이전 월 (데이트픽커가 펼쳐지면 숨김, 페이드 애니메이션)
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: !_showYearMonthPicker
|
||||
? IconButton(
|
||||
key: const ValueKey('prev_button'),
|
||||
onPressed: () {
|
||||
if (_showCalendar) {
|
||||
setState(() {
|
||||
_calendarViewDate = DateTime(
|
||||
_calendarViewDate.year,
|
||||
_calendarViewDate.month - 1,
|
||||
1,
|
||||
_calendarPageController.previousPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
controller.changeMonth(-1);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.chevron_left, size: 24),
|
||||
color: AppColors.textPrimary,
|
||||
)
|
||||
: const SizedBox(key: ValueKey('prev_empty'), width: 48),
|
||||
),
|
||||
// 년월 표시
|
||||
Expanded(
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// 년월 텍스트 (가운데 고정)
|
||||
GestureDetector(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _showCalendar
|
||||
? () {
|
||||
setState(() {
|
||||
|
|
@ -213,9 +284,22 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
|
|||
});
|
||||
}
|
||||
: null,
|
||||
child: Text(
|
||||
'${displayDate.year}년 ${displayDate.month}월',
|
||||
child: Center(
|
||||
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(
|
||||
fontFamily: 'Pretendard',
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _showYearMonthPicker
|
||||
|
|
@ -223,20 +307,16 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
|
|||
: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 화살표 (년월 옆에 위치)
|
||||
if (_showCalendar)
|
||||
Positioned(
|
||||
// 년월 텍스트 너비의 절반 + 여백
|
||||
left: MediaQuery.of(context).size.width / 2 - 48 + 52,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_showYearMonthPicker = !_showYearMonthPicker;
|
||||
_yearRangeStart = (_calendarViewDate.year ~/ 12) * 12;
|
||||
});
|
||||
},
|
||||
child: Icon(
|
||||
// 드롭다운 화살표 (페이드 애니메이션)
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: _showCalendar
|
||||
? Row(
|
||||
key: ValueKey('dropdown_$_showYearMonthPicker'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(width: 2),
|
||||
Icon(
|
||||
_showYearMonthPicker
|
||||
? Icons.keyboard_arrow_up
|
||||
: Icons.keyboard_arrow_down,
|
||||
|
|
@ -245,28 +325,35 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
|
|||
? AppColors.primary
|
||||
: 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: () {
|
||||
if (_showCalendar) {
|
||||
setState(() {
|
||||
_calendarViewDate = DateTime(
|
||||
_calendarViewDate.year,
|
||||
_calendarViewDate.month + 1,
|
||||
1,
|
||||
_calendarPageController.nextPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
controller.changeMonth(1);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.chevron_right, size: 24),
|
||||
color: AppColors.textPrimary,
|
||||
)
|
||||
: const SizedBox(key: ValueKey('next_empty'), width: 48),
|
||||
),
|
||||
// 검색 아이콘 (3단계에서 구현)
|
||||
IconButton(
|
||||
|
|
@ -285,17 +372,15 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
|
|||
|
||||
/// 달력 팝업 빌드
|
||||
Widget _buildCalendarPopup(ScheduleState state, ScheduleController controller) {
|
||||
return TweenAnimationBuilder<double>(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
tween: Tween(begin: 0.0, end: 1.0),
|
||||
builder: (context, value, child) {
|
||||
return AnimatedBuilder(
|
||||
animation: _calendarAnimation,
|
||||
builder: (context, child) {
|
||||
return ClipRect(
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
heightFactor: value,
|
||||
heightFactor: _calendarAnimation.value,
|
||||
child: Opacity(
|
||||
opacity: value,
|
||||
opacity: _calendarAnimation.value,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
|
|
@ -303,14 +388,52 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
|
|||
},
|
||||
child: Material(
|
||||
color: Colors.white,
|
||||
elevation: 8,
|
||||
child: AnimatedCrossFade(
|
||||
elevation: 0,
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
alignment: Alignment.topCenter,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
crossFadeState: _showYearMonthPicker
|
||||
? CrossFadeState.showSecond
|
||||
: CrossFadeState.showFirst,
|
||||
firstChild: _buildCalendarGrid(state, controller),
|
||||
secondChild: _buildYearMonthPicker(),
|
||||
switchInCurve: Curves.easeOut,
|
||||
switchOutCurve: Curves.easeIn,
|
||||
layoutBuilder: (currentChild, previousChildren) {
|
||||
// 콘텐츠를 항상 상단에 고정
|
||||
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() {
|
||||
final yearRange = List.generate(12, (i) => _yearRangeStart + i);
|
||||
final today = DateTime.now();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 년도 범위 헤더
|
||||
Row(
|
||||
|
|
@ -330,26 +454,37 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
|
|||
children: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_yearRangeStart -= 12;
|
||||
});
|
||||
_yearPageController.previousPage(
|
||||
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(
|
||||
'$_yearRangeStart - ${_yearRangeStart + 11}',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Pretendard',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_yearRangeStart += 12;
|
||||
});
|
||||
_yearPageController.nextPage(
|
||||
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(
|
||||
'년도',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.textTertiary),
|
||||
style: TextStyle(fontFamily: 'Pretendard', 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: () {
|
||||
const SizedBox(height: 4),
|
||||
// 년도 그리드 (ExpandablePageView로 스와이프 애니메이션)
|
||||
ExpandablePageView.builder(
|
||||
controller: _yearPageController,
|
||||
itemCount: _initialPage * 2,
|
||||
onPageChanged: (page) {
|
||||
final delta = page - _initialPage;
|
||||
final baseYearRange = (today.year ~/ 12) * 12;
|
||||
setState(() {
|
||||
_calendarViewDate = DateTime(year, _calendarViewDate.month, 1);
|
||||
_yearRangeStart = baseYearRange + (delta * 12);
|
||||
});
|
||||
},
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
itemBuilder: (context, page) {
|
||||
final delta = page - _initialPage;
|
||||
final baseYearRange = (today.year ~/ 12) * 12;
|
||||
final rangeStart = baseYearRange + (delta * 12);
|
||||
final yearRange = List.generate(12, (i) => rangeStart + i);
|
||||
return _buildYearGrid(yearRange: yearRange, today: today);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 월 라벨
|
||||
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(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
mainAxisSpacing: 8,
|
||||
mainAxisSpacing: 6,
|
||||
crossAxisSpacing: 8,
|
||||
childAspectRatio: 2,
|
||||
childAspectRatio: 2.0,
|
||||
),
|
||||
itemCount: 12,
|
||||
itemBuilder: (context, index) {
|
||||
final month = index + 1;
|
||||
final isSelected = month == _calendarViewDate.month;
|
||||
final isCurrentMonth = month == today.month && _calendarViewDate.year == today.year;
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
|
|
@ -426,19 +544,27 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
|
|||
_showYearMonthPicker = false;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
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: Text(
|
||||
'$month월',
|
||||
child: AnimatedDefaultTextStyle(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
style: TextStyle(
|
||||
fontFamily: 'Pretendard',
|
||||
fontSize: 14,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
|
||||
color: isSelected ? Colors.white : AppColors.textPrimary,
|
||||
fontWeight: isSelected || isCurrentMonth ? FontWeight.w600 : FontWeight.w400,
|
||||
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) {
|
||||
final year = _calendarViewDate.year;
|
||||
final month = _calendarViewDate.month;
|
||||
|
||||
// 달력 데이터 생성
|
||||
/// 특정 월의 달력 데이터 생성
|
||||
List<DateTime> _getMonthDays(int year, int month) {
|
||||
final firstDay = DateTime(year, month, 1);
|
||||
final lastDay = DateTime(year, month + 1, 0);
|
||||
final startWeekday = firstDay.weekday % 7; // 0 = 일요일
|
||||
final startWeekday = firstDay.weekday % 7;
|
||||
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(
|
||||
|
|
@ -481,11 +600,69 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
|
|||
(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(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 요일 헤더
|
||||
Row(
|
||||
|
|
@ -497,6 +674,7 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
|
|||
child: Text(
|
||||
day,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Pretendard',
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: index == 0
|
||||
|
|
@ -510,16 +688,92 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
|
|||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 날짜 그리드
|
||||
GridView.builder(
|
||||
const SizedBox(height: 4),
|
||||
// 날짜 그리드 (ExpandablePageView로 높이 자동 조절)
|
||||
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,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 7,
|
||||
mainAxisSpacing: 4,
|
||||
crossAxisSpacing: 4,
|
||||
childAspectRatio: 1,
|
||||
mainAxisExtent: 46, // Container(36) + SizedBox(6) + 여백(4)
|
||||
),
|
||||
itemCount: allDays.length,
|
||||
itemBuilder: (context, index) {
|
||||
|
|
@ -532,17 +786,11 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
|
|||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
// 날짜 선택
|
||||
controller.goToDate(date);
|
||||
// 일정 목록 맨 위로
|
||||
if (_contentScrollController.hasClients) {
|
||||
_contentScrollController.jumpTo(0);
|
||||
}
|
||||
// 달력 닫기
|
||||
setState(() {
|
||||
_showCalendar = false;
|
||||
_showYearMonthPicker = false;
|
||||
});
|
||||
_closeCalendar();
|
||||
},
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
|
|
@ -567,10 +815,9 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
|
|||
child: Text(
|
||||
'${date.day}',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Pretendard',
|
||||
fontSize: 14,
|
||||
fontWeight: isSelected || isToday
|
||||
? FontWeight.bold
|
||||
: FontWeight.w400,
|
||||
fontWeight: isSelected || isToday ? FontWeight.bold : FontWeight.w400,
|
||||
color: !isCurrentMonth
|
||||
? AppColors.textTertiary.withValues(alpha: 0.5)
|
||||
: isSelected
|
||||
|
|
@ -585,7 +832,6 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
|
|||
),
|
||||
),
|
||||
),
|
||||
// 일정 점 (선택되지 않은 현재 달 날짜만)
|
||||
SizedBox(
|
||||
height: 6,
|
||||
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) {
|
||||
return Container(
|
||||
color: Colors.white,
|
||||
child: Container(
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ListView.builder(
|
||||
controller: _dateScrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
|
|
@ -742,7 +944,6 @@ class _ScheduleViewState extends ConsumerState<ScheduleView> {
|
|||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -169,6 +169,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ dependencies:
|
|||
modal_bottom_sheet: ^3.0.0
|
||||
video_player: ^2.9.2
|
||||
chewie: ^1.8.5
|
||||
expandable_page_view: ^1.0.17
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue