feat(schedule): 추천 검색어 기능 및 검색 UX 개선
- 추천 검색어 API 연동 (getSuggestions) - SuggestionController로 추천 검색어 상태 관리 - 유튜브 스타일 검색 UX 구현 - X 버튼 클릭 시 추천 검색어 화면으로 전환 - 뒤로가기 시 검색 결과 화면 복원 및 검색어 유지 - 검색 결과 화면 전체 페이드 애니메이션으로 변경 - 입력 디바운스(200ms) 적용 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
36fb7bb310
commit
fbe18b6157
3 changed files with 314 additions and 143 deletions
|
|
@ -300,3 +300,66 @@ class ScheduleSearchController extends Notifier<SearchState> {
|
||||||
final searchProvider = NotifierProvider<ScheduleSearchController, SearchState>(
|
final searchProvider = NotifierProvider<ScheduleSearchController, SearchState>(
|
||||||
ScheduleSearchController.new,
|
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,
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -62,3 +62,20 @@ Future<SearchResult> searchSchedules(String query, {int offset = 0, int limit =
|
||||||
hasMore: data['hasMore'] ?? schedules.length >= limit,
|
hasMore: data['hasMore'] ?? schedules.length >= limit,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 추천 검색어 조회
|
||||||
|
Future<List<String>> getSuggestions(String query, {int limit = 10}) async {
|
||||||
|
if (query.trim().isEmpty) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await dio.get('/schedules/suggestions', queryParameters: {
|
||||||
|
'q': query,
|
||||||
|
'limit': limit.toString(),
|
||||||
|
});
|
||||||
|
final Map<String, dynamic> data = response.data;
|
||||||
|
final List<dynamic> suggestions = data['suggestions'] ?? [];
|
||||||
|
return suggestions.cast<String>();
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
/// UI 렌더링만 담당하고, 비즈니스 로직은 Controller에 위임합니다.
|
/// UI 렌더링만 담당하고, 비즈니스 로직은 Controller에 위임합니다.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:expandable_page_view/expandable_page_view.dart';
|
import 'package:expandable_page_view/expandable_page_view.dart';
|
||||||
|
|
@ -39,6 +41,12 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
||||||
|
|
||||||
// 검색 모드 상태
|
// 검색 모드 상태
|
||||||
bool _isSearchMode = false;
|
bool _isSearchMode = false;
|
||||||
|
// 추천 검색어 화면 표시 여부 (입력창 포커스 시)
|
||||||
|
bool _showSuggestions = false;
|
||||||
|
// 디바운스 타이머
|
||||||
|
Timer? _debounceTimer;
|
||||||
|
// 마지막 검색어 (뒤로가기 시 복원용)
|
||||||
|
String _lastSearchTerm = '';
|
||||||
|
|
||||||
// 달력 팝업 상태
|
// 달력 팝업 상태
|
||||||
bool _showCalendar = false;
|
bool _showCalendar = false;
|
||||||
|
|
@ -87,6 +95,7 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
||||||
void _enterSearchMode() {
|
void _enterSearchMode() {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isSearchMode = true;
|
_isSearchMode = true;
|
||||||
|
_showSuggestions = true;
|
||||||
});
|
});
|
||||||
// 검색 입력창 포커스
|
// 검색 입력창 포커스
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
|
@ -96,24 +105,70 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
||||||
|
|
||||||
/// 검색 모드 종료
|
/// 검색 모드 종료
|
||||||
void _exitSearchMode() {
|
void _exitSearchMode() {
|
||||||
|
_debounceTimer?.cancel();
|
||||||
setState(() {
|
setState(() {
|
||||||
_isSearchMode = false;
|
_isSearchMode = false;
|
||||||
|
_showSuggestions = false;
|
||||||
_searchInputController.clear();
|
_searchInputController.clear();
|
||||||
});
|
});
|
||||||
ref.read(searchProvider.notifier).clear();
|
ref.read(searchProvider.notifier).clear();
|
||||||
|
ref.read(suggestionProvider.notifier).clear();
|
||||||
_searchFocusNode.unfocus();
|
_searchFocusNode.unfocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 추천 검색어 화면 표시 (유튜브 스타일)
|
||||||
|
void _showSuggestionsScreen() {
|
||||||
|
setState(() {
|
||||||
|
_showSuggestions = true;
|
||||||
|
});
|
||||||
|
_searchFocusNode.requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 추천 검색어 화면에서 뒤로가기 (검색 결과가 있으면 결과 화면으로)
|
||||||
|
void _hideSuggestionsScreen() {
|
||||||
|
final searchState = ref.read(searchProvider);
|
||||||
|
if (searchState.results.isNotEmpty) {
|
||||||
|
// 검색 결과가 있으면 결과 화면으로 (검색어 복원)
|
||||||
|
setState(() {
|
||||||
|
_showSuggestions = false;
|
||||||
|
_searchInputController.text = _lastSearchTerm;
|
||||||
|
});
|
||||||
|
_searchFocusNode.unfocus();
|
||||||
|
} else {
|
||||||
|
// 검색 결과가 없으면 검색 모드 종료
|
||||||
|
_exitSearchMode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 검색 실행
|
/// 검색 실행
|
||||||
void _onSearch(String query) {
|
void _onSearch(String query) {
|
||||||
if (query.trim().isNotEmpty) {
|
if (query.trim().isNotEmpty) {
|
||||||
|
_lastSearchTerm = query; // 검색어 저장
|
||||||
ref.read(searchProvider.notifier).search(query);
|
ref.read(searchProvider.notifier).search(query);
|
||||||
|
setState(() {
|
||||||
|
_showSuggestions = false;
|
||||||
|
});
|
||||||
_searchFocusNode.unfocus();
|
_searchFocusNode.unfocus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 검색어 입력 변경 (디바운스로 추천 검색어 로드)
|
||||||
|
void _onSearchInputChanged(String value) {
|
||||||
|
setState(() {}); // X 버튼 표시 갱신
|
||||||
|
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
_debounceTimer = Timer(const Duration(milliseconds: 200), () {
|
||||||
|
if (value.trim().isNotEmpty) {
|
||||||
|
ref.read(suggestionProvider.notifier).loadSuggestions(value);
|
||||||
|
} else {
|
||||||
|
ref.read(suggestionProvider.notifier).clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_debounceTimer?.cancel();
|
||||||
_dateScrollController.dispose();
|
_dateScrollController.dispose();
|
||||||
_contentScrollController.dispose();
|
_contentScrollController.dispose();
|
||||||
_searchScrollController.removeListener(_onSearchScroll);
|
_searchScrollController.removeListener(_onSearchScroll);
|
||||||
|
|
@ -215,6 +270,7 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scheduleState = ref.watch(scheduleProvider);
|
final scheduleState = ref.watch(scheduleProvider);
|
||||||
final searchState = ref.watch(searchProvider);
|
final searchState = ref.watch(searchProvider);
|
||||||
|
final suggestionState = ref.watch(suggestionProvider);
|
||||||
final controller = ref.read(scheduleProvider.notifier);
|
final controller = ref.read(scheduleProvider.notifier);
|
||||||
|
|
||||||
// 날짜가 변경되면 스크롤
|
// 날짜가 변경되면 스크롤
|
||||||
|
|
@ -229,12 +285,19 @@ 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 PopScope(
|
return PopScope(
|
||||||
canPop: !_isSearchMode,
|
canPop: !_isSearchMode,
|
||||||
onPopInvokedWithResult: (didPop, result) {
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
if (!didPop && _isSearchMode) {
|
if (!didPop && _isSearchMode) {
|
||||||
_exitSearchMode();
|
if (_showSuggestions && searchState.results.isNotEmpty) {
|
||||||
|
// 추천 검색어 화면에서 검색 결과가 있으면 결과 화면으로
|
||||||
|
_hideSuggestionsScreen();
|
||||||
|
} else {
|
||||||
|
// 그 외에는 검색 모드 종료
|
||||||
|
_exitSearchMode();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Stack(
|
child: Stack(
|
||||||
|
|
@ -271,15 +334,20 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
||||||
? const SizedBox.shrink()
|
? const SizedBox.shrink()
|
||||||
: _buildDateSelector(scheduleState, controller),
|
: _buildDateSelector(scheduleState, controller),
|
||||||
),
|
),
|
||||||
// 일정 목록 또는 검색 결과
|
// 일정 목록, 추천 검색어, 또는 검색 결과
|
||||||
Expanded(
|
Expanded(
|
||||||
child: AnimatedSwitcher(
|
child: AnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
child: _isSearchMode
|
child: _isSearchMode
|
||||||
? KeyedSubtree(
|
? (_showSuggestions
|
||||||
key: const ValueKey('search_results'),
|
? KeyedSubtree(
|
||||||
child: _buildSearchResults(searchState),
|
key: const ValueKey('suggestions'),
|
||||||
)
|
child: _buildSuggestions(suggestionState),
|
||||||
|
)
|
||||||
|
: KeyedSubtree(
|
||||||
|
key: const ValueKey('search_results'),
|
||||||
|
child: _buildSearchResults(searchState),
|
||||||
|
))
|
||||||
: KeyedSubtree(
|
: KeyedSubtree(
|
||||||
key: const ValueKey('schedule_list'),
|
key: const ValueKey('schedule_list'),
|
||||||
child: _buildScheduleList(scheduleState),
|
child: _buildScheduleList(scheduleState),
|
||||||
|
|
@ -369,18 +437,26 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
||||||
isDense: true,
|
isDense: true,
|
||||||
),
|
),
|
||||||
textInputAction: TextInputAction.search,
|
textInputAction: TextInputAction.search,
|
||||||
onChanged: (_) => setState(() {}),
|
onTap: () {
|
||||||
|
// 입력창 클릭 시 추천 검색어 화면 표시
|
||||||
|
if (!_showSuggestions) {
|
||||||
|
_showSuggestionsScreen();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onChanged: _onSearchInputChanged,
|
||||||
onSubmitted: _onSearch,
|
onSubmitted: _onSearch,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 입력 내용 삭제 버튼
|
// 입력 내용 삭제 버튼 (클릭 시 추천 검색어 화면으로)
|
||||||
if (_searchInputController.text.isNotEmpty)
|
if (_searchInputController.text.isNotEmpty)
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
_searchInputController.clear();
|
_searchInputController.clear();
|
||||||
|
_showSuggestions = true;
|
||||||
});
|
});
|
||||||
ref.read(searchProvider.notifier).clear();
|
ref.read(suggestionProvider.notifier).clear();
|
||||||
|
_searchFocusNode.requestFocus();
|
||||||
},
|
},
|
||||||
child: const Padding(
|
child: const Padding(
|
||||||
padding: EdgeInsets.all(8),
|
padding: EdgeInsets.all(8),
|
||||||
|
|
@ -417,6 +493,120 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 추천 검색어 빌드 (유튜브 스타일)
|
||||||
|
Widget _buildSuggestions(SuggestionState suggestionState) {
|
||||||
|
// 입력값이 없을 때
|
||||||
|
if (_searchInputController.text.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 (suggestionState.isLoading && suggestionState.suggestions.isEmpty) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 100),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: const CircularProgressIndicator(color: AppColors.primary),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 추천 검색어 없음
|
||||||
|
if (suggestionState.suggestions.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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 추천 검색어 목록
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
itemCount: suggestionState.suggestions.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final suggestion = suggestionState.suggestions[index];
|
||||||
|
return InkWell(
|
||||||
|
onTap: () {
|
||||||
|
_searchInputController.text = suggestion;
|
||||||
|
_onSearch(suggestion);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: AppColors.divider.withValues(alpha: 0.5),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.search,
|
||||||
|
size: 18,
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
suggestion,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Pretendard',
|
||||||
|
fontSize: 15,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 화살표 아이콘 (검색어를 입력창에 채우기)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
_searchInputController.text = suggestion;
|
||||||
|
_searchInputController.selection = TextSelection.fromPosition(
|
||||||
|
TextPosition(offset: suggestion.length),
|
||||||
|
);
|
||||||
|
_onSearchInputChanged(suggestion);
|
||||||
|
},
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.all(4),
|
||||||
|
child: Icon(
|
||||||
|
Icons.north_west,
|
||||||
|
size: 16,
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 검색 결과 빌드
|
/// 검색 결과 빌드
|
||||||
Widget _buildSearchResults(SearchState searchState) {
|
Widget _buildSearchResults(SearchState searchState) {
|
||||||
// 검색어가 없을 때
|
// 검색어가 없을 때
|
||||||
|
|
@ -466,45 +656,44 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 검색어 변경 시 애니메이션 캐시 초기화
|
// 검색 결과 목록 (화면 전체 페이드 애니메이션)
|
||||||
_AnimatedSearchScheduleCard.resetIfNewSearch(searchState.searchTerm);
|
return AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
// 검색 결과 목록 (가상화된 리스트)
|
child: ListView.builder(
|
||||||
return ListView.builder(
|
key: ValueKey('search_list_${searchState.searchTerm}'),
|
||||||
controller: _searchScrollController,
|
controller: _searchScrollController,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
itemCount: searchState.results.length + (searchState.isFetchingMore ? 1 : 0),
|
itemCount: searchState.results.length + (searchState.isFetchingMore ? 1 : 0),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
// 로딩 인디케이터
|
// 로딩 인디케이터
|
||||||
if (index >= searchState.results.length) {
|
if (index >= searchState.results.length) {
|
||||||
return const Padding(
|
return const Padding(
|
||||||
padding: EdgeInsets.symmetric(vertical: 16),
|
padding: EdgeInsets.symmetric(vertical: 16),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 24,
|
width: 24,
|
||||||
height: 24,
|
height: 24,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
color: AppColors.primary,
|
color: AppColors.primary,
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final schedule = searchState.results[index];
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: index < searchState.results.length - 1 ? 12 : 0,
|
||||||
|
),
|
||||||
|
child: _SearchScheduleCard(
|
||||||
|
schedule: schedule,
|
||||||
|
categoryColor: _parseColor(schedule.categoryColor),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
),
|
||||||
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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1528,104 +1717,6 @@ 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 {
|
class _SearchScheduleCard extends StatelessWidget {
|
||||||
final Schedule schedule;
|
final Schedule schedule;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue