From bc37abe47398d08c590d6110831699b5bec0c4ea Mon Sep 17 00:00:00 2001 From: caadiq Date: Tue, 13 Jan 2026 18:12:22 +0900 Subject: [PATCH] =?UTF-8?q?Flutter=20=EC=95=B1:=20MVCS=20=EC=95=84?= =?UTF-8?q?=ED=82=A4=ED=85=8D=EC=B2=98=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81=20(=EC=9D=BC=EC=A0=95=20=ED=99=94=EB=A9=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - controllers/ 폴더 추가 - ScheduleController 생성 (Riverpod Notifier) - ScheduleState 상태 클래스 분리 - ScheduleView를 ConsumerStatefulWidget으로 변경 - View는 UI 렌더링만, 비즈니스 로직은 Controller로 분리 Co-Authored-By: Claude Opus 4.5 --- app/lib/controllers/schedule_controller.dart | 132 +++++++++++++ app/lib/views/schedule/schedule_view.dart | 185 +++++++------------ 2 files changed, 195 insertions(+), 122 deletions(-) create mode 100644 app/lib/controllers/schedule_controller.dart diff --git a/app/lib/controllers/schedule_controller.dart b/app/lib/controllers/schedule_controller.dart new file mode 100644 index 0000000..60393a0 --- /dev/null +++ b/app/lib/controllers/schedule_controller.dart @@ -0,0 +1,132 @@ +/// 일정 컨트롤러 (MVCS의 Controller 레이어) +/// +/// 비즈니스 로직과 상태 관리를 담당합니다. +/// View는 이 Controller를 통해 데이터에 접근합니다. +library; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import '../models/schedule.dart'; +import '../services/schedules_service.dart'; + +/// 일정 상태 +class ScheduleState { + final DateTime selectedDate; + final List schedules; + final bool isLoading; + final String? error; + + const ScheduleState({ + required this.selectedDate, + this.schedules = const [], + this.isLoading = false, + this.error, + }); + + /// 상태 복사 (불변성 유지) + ScheduleState copyWith({ + DateTime? selectedDate, + List? schedules, + bool? isLoading, + String? error, + }) { + return ScheduleState( + selectedDate: selectedDate ?? this.selectedDate, + schedules: schedules ?? this.schedules, + isLoading: isLoading ?? this.isLoading, + error: error, + ); + } + + /// 선택된 날짜의 일정 목록 + List get selectedDateSchedules { + final dateStr = DateFormat('yyyy-MM-dd').format(selectedDate); + return schedules.where((s) => s.date.split('T')[0] == dateStr).toList(); + } + + /// 특정 날짜의 일정 (점 표시용, 최대 3개) + List getDaySchedules(DateTime date) { + final dateStr = DateFormat('yyyy-MM-dd').format(date); + return schedules.where((s) => s.date.split('T')[0] == dateStr).take(3).toList(); + } + + /// 해당 달의 모든 날짜 배열 + List get daysInMonth { + final year = selectedDate.year; + final month = selectedDate.month; + final lastDay = DateTime(year, month + 1, 0).day; + return List.generate(lastDay, (i) => DateTime(year, month, i + 1)); + } +} + +/// 일정 컨트롤러 +class ScheduleController extends Notifier { + @override + ScheduleState build() { + // 초기 상태 + final initialState = ScheduleState(selectedDate: DateTime.now()); + // 초기 데이터 로드 + Future.microtask(() => loadSchedules()); + return initialState; + } + + /// 월별 일정 로드 + Future loadSchedules() async { + state = state.copyWith(isLoading: true, error: null); + + try { + final schedules = await getSchedules( + state.selectedDate.year, + state.selectedDate.month, + ); + state = state.copyWith(schedules: schedules, isLoading: false); + } catch (e) { + state = state.copyWith(isLoading: false, error: e.toString()); + } + } + + /// 날짜 선택 + void selectDate(DateTime date) { + state = state.copyWith(selectedDate: date); + } + + /// 월 변경 + void changeMonth(int delta) { + final newDate = DateTime( + state.selectedDate.year, + state.selectedDate.month + delta, + 1, + ); + final today = DateTime.now(); + + // 이번 달이면 오늘 날짜, 다른 달이면 1일 선택 + final selectedDay = (newDate.year == today.year && newDate.month == today.month) + ? today.day + : 1; + + state = state.copyWith( + selectedDate: DateTime(newDate.year, newDate.month, selectedDay), + ); + loadSchedules(); + } + + /// 오늘 여부 + bool isToday(DateTime date) { + final today = DateTime.now(); + return date.year == today.year && + date.month == today.month && + date.day == today.day; + } + + /// 선택된 날짜 여부 + bool isSelected(DateTime date) { + return date.year == state.selectedDate.year && + date.month == state.selectedDate.month && + date.day == state.selectedDate.day; + } +} + +/// 일정 Provider +final scheduleProvider = NotifierProvider( + ScheduleController.new, +); diff --git a/app/lib/views/schedule/schedule_view.dart b/app/lib/views/schedule/schedule_view.dart index 2e57e1f..7b21b6f 100644 --- a/app/lib/views/schedule/schedule_view.dart +++ b/app/lib/views/schedule/schedule_view.dart @@ -1,11 +1,13 @@ -/// 일정 화면 +/// 일정 화면 (MVCS의 View 레이어) +/// +/// UI 렌더링만 담당하고, 비즈니스 로직은 Controller에 위임합니다. library; import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/constants.dart'; import '../../models/schedule.dart'; -import '../../services/schedules_service.dart'; +import '../../controllers/schedule_controller.dart'; /// HTML 엔티티 디코딩 String decodeHtmlEntities(String text) { @@ -18,25 +20,17 @@ String decodeHtmlEntities(String text) { .replaceAll(' ', ' '); } -class ScheduleView extends StatefulWidget { +class ScheduleView extends ConsumerStatefulWidget { const ScheduleView({super.key}); @override - State createState() => _ScheduleViewState(); + ConsumerState createState() => _ScheduleViewState(); } -class _ScheduleViewState extends State { - DateTime _selectedDate = DateTime.now(); - List _schedules = []; - bool _isLoading = true; +class _ScheduleViewState extends ConsumerState { final ScrollController _dateScrollController = ScrollController(); final ScrollController _contentScrollController = ScrollController(); - - @override - void initState() { - super.initState(); - _loadSchedules(); - } + DateTime? _lastSelectedDate; @override void dispose() { @@ -45,35 +39,15 @@ class _ScheduleViewState extends State { super.dispose(); } - /// 월별 일정 로드 - Future _loadSchedules() async { - setState(() => _isLoading = true); - try { - final schedules = await getSchedules( - _selectedDate.year, - _selectedDate.month, - ); - setState(() { - _schedules = schedules; - _isLoading = false; - }); - // 선택된 날짜로 스크롤 - WidgetsBinding.instance.addPostFrameCallback((_) { - _scrollToSelectedDate(); - }); - } catch (e) { - setState(() => _isLoading = false); - } - } - /// 선택된 날짜로 스크롤 - void _scrollToSelectedDate() { + void _scrollToSelectedDate(DateTime selectedDate) { if (!_dateScrollController.hasClients) return; - final dayIndex = _selectedDate.day - 1; + final dayIndex = selectedDate.day - 1; const itemWidth = 52.0; // 44 + 8 (gap) final targetOffset = (dayIndex * itemWidth) - - (MediaQuery.of(context).size.width / 2) + (itemWidth / 2); + (MediaQuery.of(context).size.width / 2) + + (itemWidth / 2); _dateScrollController.animateTo( targetOffset.clamp(0, _dateScrollController.position.maxScrollExtent), duration: const Duration(milliseconds: 150), @@ -81,51 +55,16 @@ class _ScheduleViewState extends State { ); } - /// 월 변경 - void _changeMonth(int delta) { - final newDate = DateTime(_selectedDate.year, _selectedDate.month + delta, 1); - final today = DateTime.now(); - - // 이번 달이면 오늘 날짜, 다른 달이면 1일 선택 - final selectedDay = (newDate.year == today.year && newDate.month == today.month) - ? today.day - : 1; - - setState(() { - _selectedDate = DateTime(newDate.year, newDate.month, selectedDay); - }); - _loadSchedules(); - } - - /// 날짜 선택 - void _selectDate(DateTime date) { - // 일정 목록 맨 위로 즉시 이동 (애니메이션과 겹치지 않도록) + /// 날짜 선택 핸들러 + void _onDateSelected(DateTime date) { + // 일정 목록 맨 위로 즉시 이동 if (_contentScrollController.hasClients) { _contentScrollController.jumpTo(0); } - setState(() => _selectedDate = date); - // 선택된 날짜 중앙으로 스크롤 - _scrollToSelectedDate(); - } - - /// 해당 달의 모든 날짜 배열 - List get _daysInMonth { - final year = _selectedDate.year; - final month = _selectedDate.month; - final lastDay = DateTime(year, month + 1, 0).day; - return List.generate(lastDay, (i) => DateTime(year, month, i + 1)); - } - - /// 선택된 날짜의 일정 - List get _selectedDateSchedules { - final dateStr = DateFormat('yyyy-MM-dd').format(_selectedDate); - return _schedules.where((s) => s.date.split('T')[0] == dateStr).toList(); - } - - /// 특정 날짜의 일정 (점 표시용) - List _getDaySchedules(DateTime date) { - final dateStr = DateFormat('yyyy-MM-dd').format(date); - return _schedules.where((s) => s.date.split('T')[0] == dateStr).take(3).toList(); + // Controller에 날짜 선택 요청 + ref.read(scheduleProvider.notifier).selectDate(date); + // 선택된 날짜로 스크롤 + _scrollToSelectedDate(date); } /// 요일 이름 @@ -134,21 +73,6 @@ class _ScheduleViewState extends State { return days[date.weekday % 7]; } - /// 오늘 여부 - bool _isToday(DateTime date) { - final today = DateTime.now(); - return date.year == today.year && - date.month == today.month && - date.day == today.day; - } - - /// 선택된 날짜 여부 - bool _isSelected(DateTime date) { - return date.year == _selectedDate.year && - date.month == _selectedDate.month && - date.day == _selectedDate.day; - } - /// 카테고리 색상 파싱 Color _parseColor(String? colorStr) { if (colorStr == null || colorStr.isEmpty) return AppColors.textTertiary; @@ -162,22 +86,33 @@ class _ScheduleViewState extends State { @override Widget build(BuildContext context) { + final scheduleState = ref.watch(scheduleProvider); + final controller = ref.read(scheduleProvider.notifier); + + // 날짜가 변경되면 스크롤 + if (_lastSelectedDate != scheduleState.selectedDate) { + _lastSelectedDate = scheduleState.selectedDate; + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToSelectedDate(scheduleState.selectedDate); + }); + } + return Column( children: [ // 자체 툴바 - _buildToolbar(), + _buildToolbar(scheduleState, controller), // 날짜 선택기 - _buildDateSelector(), + _buildDateSelector(scheduleState, controller), // 일정 목록 Expanded( - child: _buildScheduleList(), + child: _buildScheduleList(scheduleState), ), ], ); } /// 툴바 빌드 - Widget _buildToolbar() { + Widget _buildToolbar(ScheduleState state, ScheduleController controller) { return Container( color: Colors.white, child: SafeArea( @@ -197,7 +132,7 @@ class _ScheduleViewState extends State { ), // 이전 월 IconButton( - onPressed: () => _changeMonth(-1), + onPressed: () => controller.changeMonth(-1), icon: const Icon(Icons.chevron_left, size: 24), color: AppColors.textPrimary, ), @@ -205,7 +140,7 @@ class _ScheduleViewState extends State { Expanded( child: Center( child: Text( - '${_selectedDate.year}년 ${_selectedDate.month}월', + '${state.selectedDate.year}년 ${state.selectedDate.month}월', style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, @@ -216,7 +151,7 @@ class _ScheduleViewState extends State { ), // 다음 월 IconButton( - onPressed: () => _changeMonth(1), + onPressed: () => controller.changeMonth(1), icon: const Icon(Icons.chevron_right, size: 24), color: AppColors.textPrimary, ), @@ -236,7 +171,7 @@ class _ScheduleViewState extends State { } /// 날짜 선택기 빌드 - Widget _buildDateSelector() { + Widget _buildDateSelector(ScheduleState state, ScheduleController controller) { return Container( color: Colors.white, child: Container( @@ -255,16 +190,16 @@ class _ScheduleViewState extends State { controller: _dateScrollController, scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - itemCount: _daysInMonth.length, + itemCount: state.daysInMonth.length, itemBuilder: (context, index) { - final date = _daysInMonth[index]; - final isSelected = _isSelected(date); - final isToday = _isToday(date); + final date = state.daysInMonth[index]; + final isSelected = controller.isSelected(date); + final isToday = controller.isToday(date); final dayOfWeek = date.weekday; - final daySchedules = _getDaySchedules(date); + final daySchedules = state.getDaySchedules(date); return GestureDetector( - onTap: () => _selectDate(date), + onTap: () => _onDateSelected(date), child: Container( width: 44, margin: const EdgeInsets.symmetric(horizontal: 4), @@ -315,7 +250,8 @@ class _ScheduleViewState extends State { return Container( width: 4, height: 4, - margin: const EdgeInsets.symmetric(horizontal: 1), + margin: + const EdgeInsets.symmetric(horizontal: 1), decoration: BoxDecoration( color: _parseColor(schedule.categoryColor), shape: BoxShape.circle, @@ -336,17 +272,17 @@ class _ScheduleViewState extends State { } /// 일정 목록 빌드 - Widget _buildScheduleList() { - if (_isLoading) { + Widget _buildScheduleList(ScheduleState state) { + if (state.isLoading) { return const Center( child: CircularProgressIndicator(color: AppColors.primary), ); } - if (_selectedDateSchedules.isEmpty) { + if (state.selectedDateSchedules.isEmpty) { return Center( child: Text( - '${_selectedDate.month}월 ${_selectedDate.day}일 일정이 없습니다', + '${state.selectedDate.month}월 ${state.selectedDate.day}일 일정이 없습니다', style: const TextStyle( fontSize: 14, color: AppColors.textTertiary, @@ -358,13 +294,14 @@ class _ScheduleViewState extends State { return ListView.builder( controller: _contentScrollController, padding: const EdgeInsets.all(16), - itemCount: _selectedDateSchedules.length, + itemCount: state.selectedDateSchedules.length, itemBuilder: (context, index) { - final schedule = _selectedDateSchedules[index]; + final schedule = state.selectedDateSchedules[index]; return Padding( - padding: EdgeInsets.only(bottom: index < _selectedDateSchedules.length - 1 ? 12 : 0), + padding: EdgeInsets.only( + bottom: index < state.selectedDateSchedules.length - 1 ? 12 : 0), child: _AnimatedScheduleCard( - key: ValueKey('${schedule.id}_${_selectedDate.toString()}'), + key: ValueKey('${schedule.id}_${state.selectedDate.toString()}'), index: index, schedule: schedule, categoryColor: _parseColor(schedule.categoryColor), @@ -489,7 +426,8 @@ class _ScheduleCard extends StatelessWidget { // 시간 뱃지 if (schedule.formattedTime != null) Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: categoryColor, borderRadius: BorderRadius.circular(12), @@ -518,7 +456,8 @@ class _ScheduleCard extends StatelessWidget { // 카테고리 뱃지 if (schedule.categoryName != null) Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: categoryColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), @@ -583,7 +522,9 @@ class _ScheduleCard extends StatelessWidget { ? [ _MemberChip(name: '프로미스나인'), ] - : memberList.map((name) => _MemberChip(name: name)).toList(), + : memberList + .map((name) => _MemberChip(name: name)) + .toList(), ), ), ],