feat(schedule): 일정 검색 기능 구현
- 검색 API 및 컨트롤러 추가 (페이지네이션 20개씩) - 검색 모드 UI 구현 (툴바 전환 애니메이션) - 검색 결과 카드 (날짜 왼쪽, 콘텐츠 오른쪽) - 무한 스크롤 (500px 전 미리 로드) - 뒤로가기 키로 검색 모드 종료 - 카드 등장 애니메이션 (페이드+슬라이드) - 스크롤 시 이미 표시된 카드 애니메이션 스킵 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c4cbdc7d33
commit
36fb7bb310
3 changed files with 845 additions and 43 deletions
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue