feat(schedule): 일정 검색 기능 구현

- 검색 API 및 컨트롤러 추가 (페이지네이션 20개씩)
- 검색 모드 UI 구현 (툴바 전환 애니메이션)
- 검색 결과 카드 (날짜 왼쪽, 콘텐츠 오른쪽)
- 무한 스크롤 (500px 전 미리 로드)
- 뒤로가기 키로 검색 모드 종료
- 카드 등장 애니메이션 (페이드+슬라이드)
- 스크롤 시 이미 표시된 카드 애니메이션 스킵

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-13 21:23:22 +09:00
parent c4cbdc7d33
commit 36fb7bb310
3 changed files with 845 additions and 43 deletions

View file

@ -192,3 +192,111 @@ class ScheduleController extends Notifier<ScheduleState> {
final scheduleProvider = NotifierProvider<ScheduleController, ScheduleState>(
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,
);

View file

@ -31,3 +31,34 @@ Future<List<Schedule>> getUpcomingSchedules(int limit) async {
final List<dynamic> data = response.data;
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,
);
}

View file

@ -32,8 +32,14 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
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<ScheduleView>
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<ScheduleView>
@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,23 +229,67 @@ class _ScheduleViewState extends ConsumerState<ScheduleView>
final safeTop = MediaQuery.of(context).padding.top;
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: [
//
Column(
children: [
//
_buildToolbar(scheduleState, controller),
//
_buildDateSelector(scheduleState, controller),
//
// ( )
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) ...[
// ( )
if (_showCalendar && !_isSearchMode) ...[
// ( )
Positioned(
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),
),
// (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<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,
),
),
);
}
}