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:
caadiq 2026-01-13 18:12:22 +09:00
parent 221aaa2bb4
commit bc37abe473
2 changed files with 195 additions and 122 deletions

View 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,
);

View file

@ -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('&nbsp;', ' ');
}
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(),
),
),
],