From fbe18b61575913b10166a96013136367b8f52945 Mon Sep 17 00:00:00 2001 From: caadiq Date: Tue, 13 Jan 2026 22:02:48 +0900 Subject: [PATCH] =?UTF-8?q?feat(schedule):=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=EC=96=B4=20=EA=B8=B0=EB=8A=A5=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20UX=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 추천 검색어 API 연동 (getSuggestions) - SuggestionController로 추천 검색어 상태 관리 - 유튜브 스타일 검색 UX 구현 - X 버튼 클릭 시 추천 검색어 화면으로 전환 - 뒤로가기 시 검색 결과 화면 복원 및 검색어 유지 - 검색 결과 화면 전체 페이드 애니메이션으로 변경 - 입력 디바운스(200ms) 적용 Co-Authored-By: Claude Opus 4.5 --- app/lib/controllers/schedule_controller.dart | 63 ++++ app/lib/services/schedules_service.dart | 17 + app/lib/views/schedule/schedule_view.dart | 377 ++++++++++++------- 3 files changed, 314 insertions(+), 143 deletions(-) diff --git a/app/lib/controllers/schedule_controller.dart b/app/lib/controllers/schedule_controller.dart index e01dd3d..acf2e48 100644 --- a/app/lib/controllers/schedule_controller.dart +++ b/app/lib/controllers/schedule_controller.dart @@ -300,3 +300,66 @@ class ScheduleSearchController extends Notifier { final searchProvider = NotifierProvider( ScheduleSearchController.new, ); + +/// 추천 검색어 상태 +class SuggestionState { + final String query; + final List suggestions; + final bool isLoading; + + const SuggestionState({ + this.query = '', + this.suggestions = const [], + this.isLoading = false, + }); + + SuggestionState copyWith({ + String? query, + List? suggestions, + bool? isLoading, + }) { + return SuggestionState( + query: query ?? this.query, + suggestions: suggestions ?? this.suggestions, + isLoading: isLoading ?? this.isLoading, + ); + } +} + +/// 추천 검색어 컨트롤러 +class SuggestionController extends Notifier { + @override + SuggestionState build() { + return const SuggestionState(); + } + + /// 추천 검색어 로드 + Future 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.new, +); diff --git a/app/lib/services/schedules_service.dart b/app/lib/services/schedules_service.dart index c4dbcc2..6ba5503 100644 --- a/app/lib/services/schedules_service.dart +++ b/app/lib/services/schedules_service.dart @@ -62,3 +62,20 @@ Future searchSchedules(String query, {int offset = 0, int limit = hasMore: data['hasMore'] ?? schedules.length >= limit, ); } + +/// 추천 검색어 조회 +Future> 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 data = response.data; + final List suggestions = data['suggestions'] ?? []; + return suggestions.cast(); + } catch (e) { + return []; + } +} diff --git a/app/lib/views/schedule/schedule_view.dart b/app/lib/views/schedule/schedule_view.dart index c9191e6..bee6d97 100644 --- a/app/lib/views/schedule/schedule_view.dart +++ b/app/lib/views/schedule/schedule_view.dart @@ -3,6 +3,8 @@ /// UI 렌더링만 담당하고, 비즈니스 로직은 Controller에 위임합니다. library; +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:expandable_page_view/expandable_page_view.dart'; @@ -39,6 +41,12 @@ class _ScheduleViewState extends ConsumerState // 검색 모드 상태 bool _isSearchMode = false; + // 추천 검색어 화면 표시 여부 (입력창 포커스 시) + bool _showSuggestions = false; + // 디바운스 타이머 + Timer? _debounceTimer; + // 마지막 검색어 (뒤로가기 시 복원용) + String _lastSearchTerm = ''; // 달력 팝업 상태 bool _showCalendar = false; @@ -87,6 +95,7 @@ class _ScheduleViewState extends ConsumerState void _enterSearchMode() { setState(() { _isSearchMode = true; + _showSuggestions = true; }); // 검색 입력창 포커스 WidgetsBinding.instance.addPostFrameCallback((_) { @@ -96,24 +105,70 @@ class _ScheduleViewState extends ConsumerState /// 검색 모드 종료 void _exitSearchMode() { + _debounceTimer?.cancel(); setState(() { _isSearchMode = false; + _showSuggestions = false; _searchInputController.clear(); }); ref.read(searchProvider.notifier).clear(); + ref.read(suggestionProvider.notifier).clear(); _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) { if (query.trim().isNotEmpty) { + _lastSearchTerm = query; // 검색어 저장 ref.read(searchProvider.notifier).search(query); + setState(() { + _showSuggestions = false; + }); _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 void dispose() { + _debounceTimer?.cancel(); _dateScrollController.dispose(); _contentScrollController.dispose(); _searchScrollController.removeListener(_onSearchScroll); @@ -215,6 +270,7 @@ class _ScheduleViewState extends ConsumerState Widget build(BuildContext context) { final scheduleState = ref.watch(scheduleProvider); final searchState = ref.watch(searchProvider); + final suggestionState = ref.watch(suggestionProvider); final controller = ref.read(scheduleProvider.notifier); // 날짜가 변경되면 스크롤 @@ -229,12 +285,19 @@ class _ScheduleViewState extends ConsumerState final safeTop = MediaQuery.of(context).padding.top; final overlayTop = safeTop + 56 + 80; // toolbar(56) + date selector(80) - // 뒤로가기 키 처리 + // 뒤로가기 키 처리 (유튜브 스타일) + // 추천 검색어 화면 → 검색 결과 화면 (결과 있으면) → 일정 화면 return PopScope( canPop: !_isSearchMode, onPopInvokedWithResult: (didPop, result) { if (!didPop && _isSearchMode) { - _exitSearchMode(); + if (_showSuggestions && searchState.results.isNotEmpty) { + // 추천 검색어 화면에서 검색 결과가 있으면 결과 화면으로 + _hideSuggestionsScreen(); + } else { + // 그 외에는 검색 모드 종료 + _exitSearchMode(); + } } }, child: Stack( @@ -271,15 +334,20 @@ class _ScheduleViewState extends ConsumerState ? const SizedBox.shrink() : _buildDateSelector(scheduleState, controller), ), - // 일정 목록 또는 검색 결과 + // 일정 목록, 추천 검색어, 또는 검색 결과 Expanded( child: AnimatedSwitcher( duration: const Duration(milliseconds: 200), child: _isSearchMode - ? KeyedSubtree( - key: const ValueKey('search_results'), - child: _buildSearchResults(searchState), - ) + ? (_showSuggestions + ? KeyedSubtree( + key: const ValueKey('suggestions'), + child: _buildSuggestions(suggestionState), + ) + : KeyedSubtree( + key: const ValueKey('search_results'), + child: _buildSearchResults(searchState), + )) : KeyedSubtree( key: const ValueKey('schedule_list'), child: _buildScheduleList(scheduleState), @@ -369,18 +437,26 @@ class _ScheduleViewState extends ConsumerState isDense: true, ), textInputAction: TextInputAction.search, - onChanged: (_) => setState(() {}), + onTap: () { + // 입력창 클릭 시 추천 검색어 화면 표시 + if (!_showSuggestions) { + _showSuggestionsScreen(); + } + }, + onChanged: _onSearchInputChanged, onSubmitted: _onSearch, ), ), - // 입력 내용 삭제 버튼 + // 입력 내용 삭제 버튼 (클릭 시 추천 검색어 화면으로) if (_searchInputController.text.isNotEmpty) GestureDetector( onTap: () { setState(() { _searchInputController.clear(); + _showSuggestions = true; }); - ref.read(searchProvider.notifier).clear(); + ref.read(suggestionProvider.notifier).clear(); + _searchFocusNode.requestFocus(); }, child: const Padding( padding: EdgeInsets.all(8), @@ -417,6 +493,120 @@ class _ScheduleViewState extends ConsumerState ); } + /// 추천 검색어 빌드 (유튜브 스타일) + 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) { // 검색어가 없을 때 @@ -466,45 +656,44 @@ class _ScheduleViewState extends ConsumerState ); } - // 검색어 변경 시 애니메이션 캐시 초기화 - _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, + // 검색 결과 목록 (화면 전체 페이드 애니메이션) + return AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: ListView.builder( + key: ValueKey('search_list_${searchState.searchTerm}'), + 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: _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 _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 _fadeAnimation; - late Animation _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(begin: 0.0, end: 1.0).animate( - CurvedAnimation(parent: _controller, curve: Curves.easeOut), - ); - - _slideAnimation = Tween( - 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;