feat(schedule): 추천 검색어 기능 및 검색 UX 개선

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-13 22:02:48 +09:00
parent 36fb7bb310
commit fbe18b6157
3 changed files with 314 additions and 143 deletions

View file

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

View file

@ -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 [];
}
}

View file

@ -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;