- 최근 검색기록 기능 추가 (SharedPreferences로 최대 10개 저장) - 추천 검색어 입력 시 프로그레스바 제거 - 추천 검색어 클릭 효과 제거 (InkWell → GestureDetector) - 달력 요일/날짜 그리드 상단 여백 축소 - 달력 그리드와 오늘 버튼 사이 간격 증가 - 검색 종료 시 날짜 선택 부분 중앙 스크롤 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
452 lines
12 KiB
Dart
452 lines
12 KiB
Dart
/// 일정 컨트롤러 (MVCS의 Controller 레이어)
|
|
///
|
|
/// 비즈니스 로직과 상태 관리를 담당합니다.
|
|
/// View는 이 Controller를 통해 데이터에 접근합니다.
|
|
library;
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:shared_preferences/shared_preferences.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,
|
|
);
|
|
|
|
/// 최근 검색기록 상태
|
|
class RecentSearchState {
|
|
final List<String> searches;
|
|
|
|
const RecentSearchState({this.searches = const []});
|
|
|
|
RecentSearchState copyWith({List<String>? searches}) {
|
|
return RecentSearchState(searches: searches ?? this.searches);
|
|
}
|
|
}
|
|
|
|
/// 최근 검색기록 컨트롤러
|
|
class RecentSearchController extends Notifier<RecentSearchState> {
|
|
static const int _maxHistory = 10;
|
|
|
|
@override
|
|
RecentSearchState build() {
|
|
_loadFromStorage();
|
|
return const RecentSearchState();
|
|
}
|
|
|
|
/// SharedPreferences에서 로드
|
|
Future<void> _loadFromStorage() async {
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final searches = prefs.getStringList('recent_searches');
|
|
if (searches != null) {
|
|
state = state.copyWith(searches: searches);
|
|
}
|
|
} catch (e) {
|
|
// 로드 실패 시 무시
|
|
}
|
|
}
|
|
|
|
/// 검색어 추가
|
|
Future<void> addSearch(String query) async {
|
|
if (query.trim().isEmpty) return;
|
|
|
|
final trimmed = query.trim();
|
|
final newSearches = [
|
|
trimmed,
|
|
...state.searches.where((s) => s != trimmed),
|
|
].take(_maxHistory).toList();
|
|
|
|
state = state.copyWith(searches: newSearches);
|
|
|
|
// SharedPreferences에 저장
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setStringList('recent_searches', newSearches);
|
|
} catch (e) {
|
|
// 저장 실패 시 무시
|
|
}
|
|
}
|
|
|
|
/// 특정 검색어 삭제
|
|
Future<void> removeSearch(String query) async {
|
|
final newSearches = state.searches.where((s) => s != query).toList();
|
|
state = state.copyWith(searches: newSearches);
|
|
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setStringList('recent_searches', newSearches);
|
|
} catch (e) {
|
|
// 저장 실패 시 무시
|
|
}
|
|
}
|
|
|
|
/// 전체 삭제
|
|
Future<void> clearAll() async {
|
|
state = const RecentSearchState();
|
|
|
|
try {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setStringList('recent_searches', []);
|
|
} catch (e) {
|
|
// 저장 실패 시 무시
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 최근 검색기록 Provider
|
|
final recentSearchProvider = NotifierProvider<RecentSearchController, RecentSearchState>(
|
|
RecentSearchController.new,
|
|
);
|