diff --git a/app/lib/controllers/schedule_controller.dart b/app/lib/controllers/schedule_controller.dart index 293d5cf..e01dd3d 100644 --- a/app/lib/controllers/schedule_controller.dart +++ b/app/lib/controllers/schedule_controller.dart @@ -192,3 +192,111 @@ class ScheduleController extends Notifier { final scheduleProvider = NotifierProvider( ScheduleController.new, ); + +/// 검색 상태 +class SearchState { + final String searchTerm; + final List 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? 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 { + static const int _pageSize = 20; + + @override + SearchState build() { + return const SearchState(); + } + + /// 검색 실행 + Future 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 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.new, +); diff --git a/app/lib/services/schedules_service.dart b/app/lib/services/schedules_service.dart index f603c6b..c4dbcc2 100644 --- a/app/lib/services/schedules_service.dart +++ b/app/lib/services/schedules_service.dart @@ -31,3 +31,34 @@ Future> getUpcomingSchedules(int limit) async { final List data = response.data; return data.map((json) => Schedule.fromJson(json)).toList(); } + +/// 일정 검색 결과 +class SearchResult { + final List schedules; + final int offset; + final bool hasMore; + + const SearchResult({ + required this.schedules, + required this.offset, + required this.hasMore, + }); +} + +/// 일정 검색 (Meilisearch) +Future 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 data = response.data; + final List schedulesJson = data['schedules'] ?? []; + final schedules = schedulesJson.map((json) => Schedule.fromJson(json)).toList(); + return SearchResult( + schedules: schedules, + offset: offset, + hasMore: data['hasMore'] ?? schedules.length >= limit, + ); +} diff --git a/app/lib/views/schedule/schedule_view.dart b/app/lib/views/schedule/schedule_view.dart index c813abd..c9191e6 100644 --- a/app/lib/views/schedule/schedule_view.dart +++ b/app/lib/views/schedule/schedule_view.dart @@ -32,8 +32,14 @@ class _ScheduleViewState extends ConsumerState with SingleTickerProviderStateMixin { final ScrollController _dateScrollController = ScrollController(); final ScrollController _contentScrollController = ScrollController(); + final ScrollController _searchScrollController = ScrollController(); + final TextEditingController _searchInputController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(); DateTime? _lastSelectedDate; + // 검색 모드 상태 + bool _isSearchMode = false; + // 달력 팝업 상태 bool _showCalendar = false; DateTime _calendarViewDate = DateTime.now(); @@ -63,12 +69,57 @@ class _ScheduleViewState extends ConsumerState curve: Curves.easeOut, 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 void dispose() { _dateScrollController.dispose(); _contentScrollController.dispose(); + _searchScrollController.removeListener(_onSearchScroll); + _searchScrollController.dispose(); + _searchInputController.dispose(); + _searchFocusNode.dispose(); _calendarPageController.dispose(); _yearPageController.dispose(); _calendarAnimController.dispose(); @@ -163,10 +214,11 @@ class _ScheduleViewState extends ConsumerState @override Widget build(BuildContext context) { final scheduleState = ref.watch(scheduleProvider); + final searchState = ref.watch(searchProvider); final controller = ref.read(scheduleProvider.notifier); // 날짜가 변경되면 스크롤 - if (_lastSelectedDate != scheduleState.selectedDate) { + if (!_isSearchMode && _lastSelectedDate != scheduleState.selectedDate) { _lastSelectedDate = scheduleState.selectedDate; WidgetsBinding.instance.addPostFrameCallback((_) { _scrollToSelectedDate(scheduleState.selectedDate); @@ -177,50 +229,282 @@ class _ScheduleViewState extends ConsumerState final safeTop = MediaQuery.of(context).padding.top; final overlayTop = safeTop + 56 + 80; // toolbar(56) + date selector(80) - return Stack( - children: [ - // 메인 콘텐츠 - Column( - children: [ - // 자체 툴바 - _buildToolbar(scheduleState, controller), - // 날짜 선택기 - _buildDateSelector(scheduleState, controller), - // 일정 목록 - Expanded( - child: _buildScheduleList(scheduleState), + // 뒤로가기 키 처리 + return PopScope( + canPop: !_isSearchMode, + onPopInvokedWithResult: (didPop, result) { + if (!didPop && _isSearchMode) { + _exitSearchMode(); + } + }, + child: Stack( + children: [ + // 메인 콘텐츠 + Column( + children: [ + // 툴바 (검색 모드 전환 애니메이션) + AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + 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( + 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), + ), + ), + ), + ], + ), + // 달력 팝업 오버레이 (검색 모드가 아닐 때만) + if (_showCalendar && !_isSearchMode) ...[ + // 배경 오버레이 (날짜 선택기 아래부터) + Positioned( + top: overlayTop, + left: 0, + right: 0, + bottom: 0, + child: AnimatedBuilder( + animation: _calendarAnimation, + builder: (context, child) { + return GestureDetector( + onTap: _closeCalendar, + child: Container( + color: Colors.black.withValues(alpha: 0.4 * _calendarAnimation.value), + ), + ); + }, + ), + ), + // 달력 팝업 (애니메이션) + Positioned( + top: safeTop + 56, + left: 0, + right: 0, + child: _buildCalendarPopup(scheduleState, controller), ), ], - ), - // 달력 팝업 오버레이 - if (_showCalendar) ...[ - // 배경 오버레이 (날짜 선택기 아래부터) - Positioned( - top: overlayTop, - left: 0, - right: 0, - bottom: 0, - child: AnimatedBuilder( - animation: _calendarAnimation, - builder: (context, child) { - return GestureDetector( - onTap: _closeCalendar, - child: Container( - color: Colors.black.withValues(alpha: 0.4 * _calendarAnimation.value), + ], + ), + ); + } + + /// 검색 툴바 빌드 + 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, ), ), - // 달력 팝업 (애니메이션) - Positioned( - top: safeTop + 56, - left: 0, - right: 0, - child: _buildCalendarPopup(scheduleState, controller), + ), + ); + } + + // 로딩 중 + 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 ) : const SizedBox(key: ValueKey('next_empty'), width: 48), ), - // 검색 아이콘 (3단계에서 구현) + // 검색 아이콘 IconButton( - onPressed: () { - // TODO: 검색 모드 - }, + onPressed: _enterSearchMode, icon: const Icon(Icons.search, size: 20), 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 _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; + final Color categoryColor; + + const _SearchScheduleCard({ + required this.schedule, + required this.categoryColor, + }); + + /// 날짜 파싱 + Map? _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, + ), + ), + ); + } +}