Flutter 앱: MVCS 아키텍처 리팩토링 (일정 화면)
- controllers/ 폴더 추가 - ScheduleController 생성 (Riverpod Notifier) - ScheduleState 상태 클래스 분리 - ScheduleView를 ConsumerStatefulWidget으로 변경 - View는 UI 렌더링만, 비즈니스 로직은 Controller로 분리 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
221aaa2bb4
commit
bc37abe473
2 changed files with 195 additions and 122 deletions
132
app/lib/controllers/schedule_controller.dart
Normal file
132
app/lib/controllers/schedule_controller.dart
Normal file
|
|
@ -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<Schedule> 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<Schedule>? schedules,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
}) {
|
||||
return ScheduleState(
|
||||
selectedDate: selectedDate ?? this.selectedDate,
|
||||
schedules: schedules ?? this.schedules,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
|
||||
/// 선택된 날짜의 일정 목록
|
||||
List<Schedule> get selectedDateSchedules {
|
||||
final dateStr = DateFormat('yyyy-MM-dd').format(selectedDate);
|
||||
return schedules.where((s) => s.date.split('T')[0] == dateStr).toList();
|
||||
}
|
||||
|
||||
/// 특정 날짜의 일정 (점 표시용, 최대 3개)
|
||||
List<Schedule> 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<DateTime> 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<ScheduleState> {
|
||||
@override
|
||||
ScheduleState build() {
|
||||
// 초기 상태
|
||||
final initialState = ScheduleState(selectedDate: DateTime.now());
|
||||
// 초기 데이터 로드
|
||||
Future.microtask(() => loadSchedules());
|
||||
return initialState;
|
||||
}
|
||||
|
||||
/// 월별 일정 로드
|
||||
Future<void> 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, ScheduleState>(
|
||||
ScheduleController.new,
|
||||
);
|
||||
|
|
@ -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<ScheduleView> createState() => _ScheduleViewState();
|
||||
ConsumerState<ScheduleView> createState() => _ScheduleViewState();
|
||||
}
|
||||
|
||||
class _ScheduleViewState extends State<ScheduleView> {
|
||||
DateTime _selectedDate = DateTime.now();
|
||||
List<Schedule> _schedules = [];
|
||||
bool _isLoading = true;
|
||||
class _ScheduleViewState extends ConsumerState<ScheduleView> {
|
||||
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<ScheduleView> {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
/// 월별 일정 로드
|
||||
Future<void> _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<ScheduleView> {
|
|||
);
|
||||
}
|
||||
|
||||
/// 월 변경
|
||||
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<DateTime> 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<Schedule> get _selectedDateSchedules {
|
||||
final dateStr = DateFormat('yyyy-MM-dd').format(_selectedDate);
|
||||
return _schedules.where((s) => s.date.split('T')[0] == dateStr).toList();
|
||||
}
|
||||
|
||||
/// 특정 날짜의 일정 (점 표시용)
|
||||
List<Schedule> _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<ScheduleView> {
|
|||
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<ScheduleView> {
|
|||
|
||||
@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<ScheduleView> {
|
|||
),
|
||||
// 이전 월
|
||||
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<ScheduleView> {
|
|||
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<ScheduleView> {
|
|||
),
|
||||
// 다음 월
|
||||
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<ScheduleView> {
|
|||
}
|
||||
|
||||
/// 날짜 선택기 빌드
|
||||
Widget _buildDateSelector() {
|
||||
Widget _buildDateSelector(ScheduleState state, ScheduleController controller) {
|
||||
return Container(
|
||||
color: Colors.white,
|
||||
child: Container(
|
||||
|
|
@ -255,16 +190,16 @@ class _ScheduleViewState extends State<ScheduleView> {
|
|||
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<ScheduleView> {
|
|||
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<ScheduleView> {
|
|||
}
|
||||
|
||||
/// 일정 목록 빌드
|
||||
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<ScheduleView> {
|
|||
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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue