fromis_9/app/lib/controllers/schedule_controller.dart
caadiq fbe18b6157 feat(schedule): 추천 검색어 기능 및 검색 UX 개선
- 추천 검색어 API 연동 (getSuggestions)
- SuggestionController로 추천 검색어 상태 관리
- 유튜브 스타일 검색 UX 구현
  - X 버튼 클릭 시 추천 검색어 화면으로 전환
  - 뒤로가기 시 검색 결과 화면 복원 및 검색어 유지
- 검색 결과 화면 전체 페이드 애니메이션으로 변경
- 입력 디바운스(200ms) 적용

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 22:02:48 +09:00

365 lines
9.9 KiB
Dart

/// 일정 컨트롤러 (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;
// 달력용 월별 일정 캐시 (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 {},
});
/// 상태 복사 (불변성 유지)
ScheduleState copyWith({
DateTime? selectedDate,
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,
);
}
/// 선택된 날짜의 일정 목록
List<Schedule> get selectedDateSchedules {
final dateStr = DateFormat('yyyy-MM-dd').format(selectedDate);
return schedules.where((s) => s.date.split('T')[0] == dateStr).toList();
}
/// 특정 날짜의 일정 (점 표시용, 최대 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;
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,
);
// 현재 월 일정을 캐시에도 저장
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);
}
/// 월 변경
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();
}
/// 특정 날짜로 이동 (달력에서 선택 시)
void goToDate(DateTime date) {
final currentMonth = state.selectedDate.month;
final currentYear = state.selectedDate.year;
state = state.copyWith(selectedDate: date);
// 월이 변경되면 일정 다시 로드
if (date.month != currentMonth || date.year != currentYear) {
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,
);
/// 검색 상태
class SearchState {
final String searchTerm;
final List<Schedule> results;
final bool isLoading;
final bool isFetchingMore;
final bool hasMore;
final int offset;
final String? error;
const SearchState({
this.searchTerm = '',
this.results = const [],
this.isLoading = false,
this.isFetchingMore = false,
this.hasMore = true,
this.offset = 0,
this.error,
});
SearchState copyWith({
String? searchTerm,
List<Schedule>? results,
bool? isLoading,
bool? isFetchingMore,
bool? hasMore,
int? offset,
String? error,
}) {
return SearchState(
searchTerm: searchTerm ?? this.searchTerm,
results: results ?? this.results,
isLoading: isLoading ?? this.isLoading,
isFetchingMore: isFetchingMore ?? this.isFetchingMore,
hasMore: hasMore ?? this.hasMore,
offset: offset ?? this.offset,
error: error,
);
}
}
/// 검색 컨트롤러
class ScheduleSearchController extends Notifier<SearchState> {
static const int _pageSize = 20;
@override
SearchState build() {
return const SearchState();
}
/// 검색 실행
Future<void> search(String query) async {
if (query.trim().isEmpty) {
state = const SearchState();
return;
}
state = SearchState(searchTerm: query, isLoading: true);
try {
final result = await searchSchedules(query, offset: 0, limit: _pageSize);
state = state.copyWith(
results: result.schedules,
isLoading: false,
hasMore: result.hasMore,
offset: result.schedules.length,
);
} catch (e) {
state = state.copyWith(isLoading: false, error: e.toString());
}
}
/// 다음 페이지 로드
Future<void> loadMore() async {
if (state.isFetchingMore || !state.hasMore || state.searchTerm.isEmpty) {
return;
}
state = state.copyWith(isFetchingMore: true);
try {
final result = await searchSchedules(
state.searchTerm,
offset: state.offset,
limit: _pageSize,
);
state = state.copyWith(
results: [...state.results, ...result.schedules],
isFetchingMore: false,
hasMore: result.hasMore,
offset: state.offset + result.schedules.length,
);
} catch (e) {
state = state.copyWith(isFetchingMore: false, error: e.toString());
}
}
/// 검색 초기화
void clear() {
state = const SearchState();
}
}
/// 검색 Provider
final searchProvider = NotifierProvider<ScheduleSearchController, SearchState>(
ScheduleSearchController.new,
);
/// 추천 검색어 상태
class SuggestionState {
final String query;
final List<String> suggestions;
final bool isLoading;
const SuggestionState({
this.query = '',
this.suggestions = const [],
this.isLoading = false,
});
SuggestionState copyWith({
String? query,
List<String>? suggestions,
bool? isLoading,
}) {
return SuggestionState(
query: query ?? this.query,
suggestions: suggestions ?? this.suggestions,
isLoading: isLoading ?? this.isLoading,
);
}
}
/// 추천 검색어 컨트롤러
class SuggestionController extends Notifier<SuggestionState> {
@override
SuggestionState build() {
return const SuggestionState();
}
/// 추천 검색어 로드
Future<void> loadSuggestions(String query) async {
if (query.trim().isEmpty) {
state = const SuggestionState();
return;
}
// 같은 쿼리면 스킵
if (state.query == query && state.suggestions.isNotEmpty) return;
state = state.copyWith(query: query, isLoading: true);
try {
final suggestions = await getSuggestions(query, limit: 10);
state = state.copyWith(suggestions: suggestions, isLoading: false);
} catch (e) {
state = state.copyWith(suggestions: [], isLoading: false);
}
}
/// 추천 검색어 초기화
void clear() {
state = const SuggestionState();
}
}
/// 추천 검색어 Provider
final suggestionProvider = NotifierProvider<SuggestionController, SuggestionState>(
SuggestionController.new,
);