feat(schedule): 일정 검색 기능 구현
- 검색 API 및 컨트롤러 추가 (페이지네이션 20개씩) - 검색 모드 UI 구현 (툴바 전환 애니메이션) - 검색 결과 카드 (날짜 왼쪽, 콘텐츠 오른쪽) - 무한 스크롤 (500px 전 미리 로드) - 뒤로가기 키로 검색 모드 종료 - 카드 등장 애니메이션 (페이드+슬라이드) - 스크롤 시 이미 표시된 카드 애니메이션 스킵 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c4cbdc7d33
commit
36fb7bb310
3 changed files with 845 additions and 43 deletions
|
|
@ -192,3 +192,111 @@ class ScheduleController extends Notifier<ScheduleState> {
|
||||||
final scheduleProvider = NotifierProvider<ScheduleController, ScheduleState>(
|
final scheduleProvider = NotifierProvider<ScheduleController, ScheduleState>(
|
||||||
ScheduleController.new,
|
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,
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -31,3 +31,34 @@ Future<List<Schedule>> getUpcomingSchedules(int limit) async {
|
||||||
final List<dynamic> data = response.data;
|
final List<dynamic> data = response.data;
|
||||||
return data.map((json) => Schedule.fromJson(json)).toList();
|
return data.map((json) => Schedule.fromJson(json)).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 일정 검색 결과
|
||||||
|
class SearchResult {
|
||||||
|
final List<Schedule> schedules;
|
||||||
|
final int offset;
|
||||||
|
final bool hasMore;
|
||||||
|
|
||||||
|
const SearchResult({
|
||||||
|
required this.schedules,
|
||||||
|
required this.offset,
|
||||||
|
required this.hasMore,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 일정 검색 (Meilisearch)
|
||||||
|
Future<SearchResult> searchSchedules(String query, {int offset = 0, int limit = 20}) async {
|
||||||
|
final response = await dio.get('/schedules', queryParameters: {
|
||||||
|
'search': query,
|
||||||
|
'offset': offset.toString(),
|
||||||
|
'limit': limit.toString(),
|
||||||
|
});
|
||||||
|
// 응답: { schedules: [...], hasMore: bool, offset: int }
|
||||||
|
final Map<String, dynamic> data = response.data;
|
||||||
|
final List<dynamic> schedulesJson = data['schedules'] ?? [];
|
||||||
|
final schedules = schedulesJson.map((json) => Schedule.fromJson(json)).toList();
|
||||||
|
return SearchResult(
|
||||||
|
schedules: schedules,
|
||||||
|
offset: offset,
|
||||||
|
hasMore: data['hasMore'] ?? schedules.length >= limit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,14 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
final ScrollController _dateScrollController = ScrollController();
|
final ScrollController _dateScrollController = ScrollController();
|
||||||
final ScrollController _contentScrollController = ScrollController();
|
final ScrollController _contentScrollController = ScrollController();
|
||||||
|
final ScrollController _searchScrollController = ScrollController();
|
||||||
|
final TextEditingController _searchInputController = TextEditingController();
|
||||||
|
final FocusNode _searchFocusNode = FocusNode();
|
||||||
DateTime? _lastSelectedDate;
|
DateTime? _lastSelectedDate;
|
||||||
|
|
||||||
|
// 검색 모드 상태
|
||||||
|
bool _isSearchMode = false;
|
||||||
|
|
||||||
// 달력 팝업 상태
|
// 달력 팝업 상태
|
||||||
bool _showCalendar = false;
|
bool _showCalendar = false;
|
||||||
DateTime _calendarViewDate = DateTime.now();
|
DateTime _calendarViewDate = DateTime.now();
|
||||||
|
|
@ -63,12 +69,57 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
||||||
curve: Curves.easeOut,
|
curve: Curves.easeOut,
|
||||||
reverseCurve: Curves.easeIn,
|
reverseCurve: Curves.easeIn,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 검색 무한 스크롤 리스너
|
||||||
|
_searchScrollController.addListener(_onSearchScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 검색 스크롤 리스너 (무한 스크롤)
|
||||||
|
void _onSearchScroll() {
|
||||||
|
// 스크롤이 끝에서 500px 전에 다음 페이지 미리 로드
|
||||||
|
if (_searchScrollController.position.pixels >=
|
||||||
|
_searchScrollController.position.maxScrollExtent - 500) {
|
||||||
|
ref.read(searchProvider.notifier).loadMore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 검색 모드 진입
|
||||||
|
void _enterSearchMode() {
|
||||||
|
setState(() {
|
||||||
|
_isSearchMode = true;
|
||||||
|
});
|
||||||
|
// 검색 입력창 포커스
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_searchFocusNode.requestFocus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 검색 모드 종료
|
||||||
|
void _exitSearchMode() {
|
||||||
|
setState(() {
|
||||||
|
_isSearchMode = false;
|
||||||
|
_searchInputController.clear();
|
||||||
|
});
|
||||||
|
ref.read(searchProvider.notifier).clear();
|
||||||
|
_searchFocusNode.unfocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 검색 실행
|
||||||
|
void _onSearch(String query) {
|
||||||
|
if (query.trim().isNotEmpty) {
|
||||||
|
ref.read(searchProvider.notifier).search(query);
|
||||||
|
_searchFocusNode.unfocus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_dateScrollController.dispose();
|
_dateScrollController.dispose();
|
||||||
_contentScrollController.dispose();
|
_contentScrollController.dispose();
|
||||||
|
_searchScrollController.removeListener(_onSearchScroll);
|
||||||
|
_searchScrollController.dispose();
|
||||||
|
_searchInputController.dispose();
|
||||||
|
_searchFocusNode.dispose();
|
||||||
_calendarPageController.dispose();
|
_calendarPageController.dispose();
|
||||||
_yearPageController.dispose();
|
_yearPageController.dispose();
|
||||||
_calendarAnimController.dispose();
|
_calendarAnimController.dispose();
|
||||||
|
|
@ -163,10 +214,11 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scheduleState = ref.watch(scheduleProvider);
|
final scheduleState = ref.watch(scheduleProvider);
|
||||||
|
final searchState = ref.watch(searchProvider);
|
||||||
final controller = ref.read(scheduleProvider.notifier);
|
final controller = ref.read(scheduleProvider.notifier);
|
||||||
|
|
||||||
// 날짜가 변경되면 스크롤
|
// 날짜가 변경되면 스크롤
|
||||||
if (_lastSelectedDate != scheduleState.selectedDate) {
|
if (!_isSearchMode && _lastSelectedDate != scheduleState.selectedDate) {
|
||||||
_lastSelectedDate = scheduleState.selectedDate;
|
_lastSelectedDate = scheduleState.selectedDate;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_scrollToSelectedDate(scheduleState.selectedDate);
|
_scrollToSelectedDate(scheduleState.selectedDate);
|
||||||
|
|
@ -177,23 +229,67 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
||||||
final safeTop = MediaQuery.of(context).padding.top;
|
final safeTop = MediaQuery.of(context).padding.top;
|
||||||
final overlayTop = safeTop + 56 + 80; // toolbar(56) + date selector(80)
|
final overlayTop = safeTop + 56 + 80; // toolbar(56) + date selector(80)
|
||||||
|
|
||||||
return Stack(
|
// 뒤로가기 키 처리
|
||||||
|
return PopScope(
|
||||||
|
canPop: !_isSearchMode,
|
||||||
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
|
if (!didPop && _isSearchMode) {
|
||||||
|
_exitSearchMode();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// 메인 콘텐츠
|
// 메인 콘텐츠
|
||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
// 자체 툴바
|
// 툴바 (검색 모드 전환 애니메이션)
|
||||||
_buildToolbar(scheduleState, controller),
|
AnimatedSwitcher(
|
||||||
// 날짜 선택기
|
duration: const Duration(milliseconds: 250),
|
||||||
_buildDateSelector(scheduleState, controller),
|
switchInCurve: Curves.easeOut,
|
||||||
// 일정 목록
|
switchOutCurve: Curves.easeIn,
|
||||||
|
transitionBuilder: (child, animation) {
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: _isSearchMode
|
||||||
|
? KeyedSubtree(
|
||||||
|
key: const ValueKey('search_toolbar'),
|
||||||
|
child: _buildSearchToolbar(),
|
||||||
|
)
|
||||||
|
: KeyedSubtree(
|
||||||
|
key: const ValueKey('normal_toolbar'),
|
||||||
|
child: _buildToolbar(scheduleState, controller),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 날짜 선택기 (검색 모드 전환 애니메이션)
|
||||||
|
AnimatedSize(
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
child: _isSearchMode
|
||||||
|
? const SizedBox.shrink()
|
||||||
|
: _buildDateSelector(scheduleState, controller),
|
||||||
|
),
|
||||||
|
// 일정 목록 또는 검색 결과
|
||||||
Expanded(
|
Expanded(
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
child: _isSearchMode
|
||||||
|
? KeyedSubtree(
|
||||||
|
key: const ValueKey('search_results'),
|
||||||
|
child: _buildSearchResults(searchState),
|
||||||
|
)
|
||||||
|
: KeyedSubtree(
|
||||||
|
key: const ValueKey('schedule_list'),
|
||||||
child: _buildScheduleList(scheduleState),
|
child: _buildScheduleList(scheduleState),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// 달력 팝업 오버레이
|
// 달력 팝업 오버레이 (검색 모드가 아닐 때만)
|
||||||
if (_showCalendar) ...[
|
if (_showCalendar && !_isSearchMode) ...[
|
||||||
// 배경 오버레이 (날짜 선택기 아래부터)
|
// 배경 오버레이 (날짜 선택기 아래부터)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: overlayTop,
|
top: overlayTop,
|
||||||
|
|
@ -221,6 +317,194 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 검색 툴바 빌드
|
||||||
|
Widget _buildSearchToolbar() {
|
||||||
|
return Container(
|
||||||
|
color: Colors.white,
|
||||||
|
child: SafeArea(
|
||||||
|
bottom: false,
|
||||||
|
child: Container(
|
||||||
|
height: 56,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// 검색 입력창
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.background,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
const Icon(
|
||||||
|
Icons.search,
|
||||||
|
size: 18,
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchInputController,
|
||||||
|
focusNode: _searchFocusNode,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Pretendard',
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: '일정 검색...',
|
||||||
|
hintStyle: TextStyle(
|
||||||
|
fontFamily: 'Pretendard',
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
),
|
||||||
|
border: InputBorder.none,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.search,
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
onSubmitted: _onSearch,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 입력 내용 삭제 버튼
|
||||||
|
if (_searchInputController.text.isNotEmpty)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_searchInputController.clear();
|
||||||
|
});
|
||||||
|
ref.read(searchProvider.notifier).clear();
|
||||||
|
},
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.all(8),
|
||||||
|
child: Icon(
|
||||||
|
Icons.close,
|
||||||
|
size: 18,
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// 취소 버튼
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _exitSearchMode,
|
||||||
|
child: const Text(
|
||||||
|
'취소',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Pretendard',
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 검색 결과 빌드
|
||||||
|
Widget _buildSearchResults(SearchState searchState) {
|
||||||
|
// 검색어가 없을 때
|
||||||
|
if (searchState.searchTerm.isEmpty) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 100),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: Text(
|
||||||
|
'검색어를 입력하세요',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Pretendard',
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로딩 중
|
||||||
|
if (searchState.isLoading) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 100),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: const CircularProgressIndicator(color: AppColors.primary),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검색 결과 없음
|
||||||
|
if (searchState.results.isEmpty) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 100),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: Text(
|
||||||
|
'검색 결과가 없습니다',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Pretendard',
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검색어 변경 시 애니메이션 캐시 초기화
|
||||||
|
_AnimatedSearchScheduleCard.resetIfNewSearch(searchState.searchTerm);
|
||||||
|
|
||||||
|
// 검색 결과 목록 (가상화된 리스트)
|
||||||
|
return ListView.builder(
|
||||||
|
controller: _searchScrollController,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: searchState.results.length + (searchState.isFetchingMore ? 1 : 0),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
// 로딩 인디케이터
|
||||||
|
if (index >= searchState.results.length) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 16),
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: AppColors.primary,
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final schedule = searchState.results[index];
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: index < searchState.results.length - 1 ? 12 : 0,
|
||||||
|
),
|
||||||
|
child: _AnimatedSearchScheduleCard(
|
||||||
|
key: ValueKey('search_${schedule.id}_${searchState.searchTerm}'),
|
||||||
|
index: index,
|
||||||
|
schedule: schedule,
|
||||||
|
categoryColor: _parseColor(schedule.categoryColor),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -355,11 +639,9 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
||||||
)
|
)
|
||||||
: const SizedBox(key: ValueKey('next_empty'), width: 48),
|
: const SizedBox(key: ValueKey('next_empty'), width: 48),
|
||||||
),
|
),
|
||||||
// 검색 아이콘 (3단계에서 구현)
|
// 검색 아이콘
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () {
|
onPressed: _enterSearchMode,
|
||||||
// TODO: 검색 모드
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.search, size: 20),
|
icon: const Icon(Icons.search, size: 20),
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
),
|
),
|
||||||
|
|
@ -1245,3 +1527,384 @@ class _MemberChip extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 애니메이션이 적용된 검색 결과 카드 래퍼
|
||||||
|
class _AnimatedSearchScheduleCard extends StatefulWidget {
|
||||||
|
final int index;
|
||||||
|
final Schedule schedule;
|
||||||
|
final Color categoryColor;
|
||||||
|
|
||||||
|
// 이미 애니메이션 된 카드 추적 (검색어별)
|
||||||
|
static final Set<String> _animatedCards = {};
|
||||||
|
static String? _lastSearchTerm;
|
||||||
|
|
||||||
|
// 검색어 변경 시 애니메이션 캐시 초기화
|
||||||
|
static void resetIfNewSearch(String searchTerm) {
|
||||||
|
if (_lastSearchTerm != searchTerm) {
|
||||||
|
_animatedCards.clear();
|
||||||
|
_lastSearchTerm = searchTerm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _AnimatedSearchScheduleCard({
|
||||||
|
super.key,
|
||||||
|
required this.index,
|
||||||
|
required this.schedule,
|
||||||
|
required this.categoryColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AnimatedSearchScheduleCard> createState() =>
|
||||||
|
_AnimatedSearchScheduleCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AnimatedSearchScheduleCardState extends State<_AnimatedSearchScheduleCard>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
late Animation<double> _fadeAnimation;
|
||||||
|
late Animation<Offset> _slideAnimation;
|
||||||
|
bool _alreadyAnimated = false;
|
||||||
|
|
||||||
|
String get _cardKey => '${widget.schedule.id}';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
// 이미 애니메이션된 카드인지 확인
|
||||||
|
_alreadyAnimated = _AnimatedSearchScheduleCard._animatedCards.contains(_cardKey);
|
||||||
|
|
||||||
|
_controller = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
vsync: this,
|
||||||
|
// 이미 애니메이션 됐으면 완료 상태로 시작
|
||||||
|
value: _alreadyAnimated ? 1.0 : 0.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||||
|
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
|
||||||
|
);
|
||||||
|
|
||||||
|
_slideAnimation = Tween<Offset>(
|
||||||
|
begin: const Offset(0, 0.1),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(
|
||||||
|
CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 아직 애니메이션 안 됐으면 실행
|
||||||
|
if (!_alreadyAnimated) {
|
||||||
|
// 순차적 애니메이션 (최대 10개까지만 딜레이)
|
||||||
|
final delay = widget.index < 10 ? widget.index * 30 : 0;
|
||||||
|
Future.delayed(Duration(milliseconds: delay), () {
|
||||||
|
if (mounted) {
|
||||||
|
_controller.forward();
|
||||||
|
_AnimatedSearchScheduleCard._animatedCards.add(_cardKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SlideTransition(
|
||||||
|
position: _slideAnimation,
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: _fadeAnimation,
|
||||||
|
child: _SearchScheduleCard(
|
||||||
|
schedule: widget.schedule,
|
||||||
|
categoryColor: widget.categoryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 검색 결과 카드 (웹과 동일한 디자인 - 왼쪽에 날짜, 오른쪽에 내용)
|
||||||
|
class _SearchScheduleCard extends StatelessWidget {
|
||||||
|
final Schedule schedule;
|
||||||
|
final Color categoryColor;
|
||||||
|
|
||||||
|
const _SearchScheduleCard({
|
||||||
|
required this.schedule,
|
||||||
|
required this.categoryColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 날짜 파싱
|
||||||
|
Map<String, dynamic>? _parseDate(String? dateStr) {
|
||||||
|
if (dateStr == null) return null;
|
||||||
|
try {
|
||||||
|
final date = DateTime.parse(dateStr);
|
||||||
|
const weekdays = ['일', '월', '화', '수', '목', '금', '토'];
|
||||||
|
return {
|
||||||
|
'year': date.year,
|
||||||
|
'month': date.month,
|
||||||
|
'day': date.day,
|
||||||
|
'weekday': weekdays[date.weekday % 7],
|
||||||
|
'isSunday': date.weekday == 7,
|
||||||
|
'isSaturday': date.weekday == 6,
|
||||||
|
};
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final memberList = schedule.memberList;
|
||||||
|
final dateInfo = _parseDate(schedule.date);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.04),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.border.withValues(alpha: 0.5),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: IntrinsicHeight(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// 왼쪽 날짜 영역 (카드 높이에 맞춤)
|
||||||
|
if (dateInfo != null)
|
||||||
|
Container(
|
||||||
|
width: 72,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 6),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.background,
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(7),
|
||||||
|
bottomLeft: Radius.circular(7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// 년도
|
||||||
|
Text(
|
||||||
|
'${dateInfo['year']}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Pretendard',
|
||||||
|
fontSize: 10,
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 월.일 (줄바꿈 방지)
|
||||||
|
FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Text(
|
||||||
|
'${dateInfo['month']}.${dateInfo['day']}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Pretendard',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 요일
|
||||||
|
Text(
|
||||||
|
'${dateInfo['weekday']}요일',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Pretendard',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: dateInfo['isSunday'] == true
|
||||||
|
? Colors.red.shade500
|
||||||
|
: dateInfo['isSaturday'] == true
|
||||||
|
? Colors.blue.shade500
|
||||||
|
: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 오른쪽 콘텐츠 영역
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// 시간 및 카테고리 뱃지
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
// 시간 뱃지
|
||||||
|
if (schedule.formattedTime != null)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: categoryColor,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.access_time,
|
||||||
|
size: 10,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
schedule.formattedTime!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Pretendard',
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (schedule.formattedTime != null)
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
// 카테고리 뱃지
|
||||||
|
if (schedule.categoryName != null)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: categoryColor.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
schedule.categoryName!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Pretendard',
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: categoryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// 제목
|
||||||
|
Text(
|
||||||
|
decodeHtmlEntities(schedule.title),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Pretendard',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
// 출처 (빈 문자열이 아닌 경우에만 표시)
|
||||||
|
if (schedule.sourceName != null && schedule.sourceName!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.link,
|
||||||
|
size: 12,
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
schedule.sourceName!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Pretendard',
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
// 멤버
|
||||||
|
if (memberList.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
// divider (전체 너비)
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 1,
|
||||||
|
color: AppColors.divider,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Wrap(
|
||||||
|
spacing: 4,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: memberList.length >= 5
|
||||||
|
? [
|
||||||
|
_SearchMemberChip(name: '프로미스나인'),
|
||||||
|
]
|
||||||
|
: memberList
|
||||||
|
.map((name) => _SearchMemberChip(name: name))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 검색 결과용 멤버 칩 (작은 사이즈)
|
||||||
|
class _SearchMemberChip extends StatelessWidget {
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
const _SearchMemberChip({required this.name});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
colors: [AppColors.primary, AppColors.primaryDark],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: AppColors.primary.withValues(alpha: 0.3),
|
||||||
|
blurRadius: 3,
|
||||||
|
offset: const Offset(0, 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
name,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Pretendard',
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue