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 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);
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue