import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:table_calendar/table_calendar.dart'; import '../../../core/constants/app_colors.dart'; import '../../../core/constants/app_typography.dart'; import '../../../core/widgets/info_row.dart'; import '../../../domain/entities/restaurant.dart'; import '../../../domain/entities/recommendation_record.dart'; import '../../../domain/entities/visit_record.dart'; import '../../providers/recommendation_provider.dart'; import '../../providers/settings_provider.dart'; import '../../providers/restaurant_provider.dart'; import '../../providers/visit_provider.dart'; import '../../widgets/native_ad_placeholder.dart'; import '../restaurant_list/widgets/edit_restaurant_dialog.dart'; import 'widgets/visit_record_card.dart'; import 'widgets/recommendation_record_card.dart'; import 'widgets/visit_statistics.dart'; class CalendarScreen extends ConsumerStatefulWidget { const CalendarScreen({super.key}); @override ConsumerState createState() => _CalendarScreenState(); } class _CalendarScreenState extends ConsumerState with SingleTickerProviderStateMixin { late DateTime _selectedDay; late DateTime _focusedDay; late DateTime _selectedMonth; late DateTime _firstDay; late DateTime _lastDay; CalendarFormat _calendarFormat = CalendarFormat.month; late TabController _tabController; Map> _events = {}; @override void initState() { super.initState(); final now = DateTime.now(); _selectedDay = now; _focusedDay = now; _selectedMonth = DateTime(now.year, now.month, 1); _firstDay = DateTime(now.year - 1, now.month, 1); _lastDay = DateTime(now.year + 1, now.month, now.day); _tabController = TabController(length: 2, vsync: this); } @override void dispose() { _tabController.dispose(); super.dispose(); } List<_CalendarEvent> _getEventsForDay(DateTime day) { final normalizedDay = DateTime(day.year, day.month, day.day); return _events[normalizedDay] ?? []; } @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; final visitRecordsAsync = ref.watch(visitRecordsProvider); final recommendationRecordsAsync = ref.watch(recommendationRecordsProvider); final screenshotModeEnabled = ref .watch(screenshotModeEnabledProvider) .maybeWhen(data: (value) => value, orElse: () => false); final visits = visitRecordsAsync.value ?? []; final recommendations = recommendationRecordsAsync.valueOrNull ?? []; if (visitRecordsAsync.hasValue && recommendationRecordsAsync.hasValue) { _events = _buildEvents(visits, recommendations); _updateCalendarRange(visits, recommendations); } final monthOptions = _buildMonthOptions(_firstDay, _lastDay); return Scaffold( backgroundColor: isDark ? AppColors.darkBackground : AppColors.lightBackground, appBar: AppBar( title: const Text('방문 기록'), backgroundColor: isDark ? AppColors.darkPrimary : AppColors.lightPrimary, foregroundColor: Colors.white, elevation: 0, bottom: TabBar( controller: _tabController, indicatorColor: Colors.white, indicatorWeight: 3, tabs: const [ Tab(text: '캘린더'), Tab(text: '통계'), ], ), ), body: TabBarView( controller: _tabController, children: [ // 캘린더 탭 _buildCalendarTab(isDark: isDark, adsEnabled: !screenshotModeEnabled), // 통계 탭 VisitStatistics( selectedMonth: _selectedMonth, availableMonths: monthOptions, onMonthChanged: _onMonthChanged, adsEnabled: !screenshotModeEnabled, ), ], ), ); } Widget _buildCalendarTab({required bool isDark, bool adsEnabled = true}) { final visitColor = _visitMarkerColor(isDark); final recommendationColor = _recommendationMarkerColor(isDark); return LayoutBuilder( builder: (context, constraints) { return SingleChildScrollView( padding: const EdgeInsets.only(bottom: 16), child: ConstrainedBox( constraints: BoxConstraints(minHeight: constraints.maxHeight), child: Column( children: [ Card( margin: const EdgeInsets.all(16), color: isDark ? AppColors.darkSurface : AppColors.lightSurface, elevation: 2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), child: TableCalendar( firstDay: _firstDay, lastDay: _lastDay, focusedDay: _focusedDay, calendarFormat: _calendarFormat, selectedDayPredicate: (day) => isSameDay(_selectedDay, day), onDaySelected: (selectedDay, focusedDay) { setState(() { _selectedDay = selectedDay; _focusedDay = focusedDay; _selectedMonth = DateTime( focusedDay.year, focusedDay.month, 1, ); }); }, onPageChanged: (focusedDay) { setState(() { _focusedDay = focusedDay; _selectedMonth = DateTime( focusedDay.year, focusedDay.month, 1, ); _selectedDay = focusedDay; }); }, onFormatChanged: (format) { setState(() { _calendarFormat = format; }); }, eventLoader: _getEventsForDay, calendarBuilders: CalendarBuilders( markerBuilder: (context, day, events) { if (events.isEmpty) return null; final calendarEvents = events.cast<_CalendarEvent>(); final confirmedVisits = calendarEvents.where( (e) => e.visitRecord?.isConfirmed == true, ); final recommendedOnly = calendarEvents.where( (e) => e.recommendationRecord != null, ); return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ if (recommendedOnly.isNotEmpty) Container( width: 6, height: 6, margin: const EdgeInsets.symmetric( horizontal: 1, ), decoration: BoxDecoration( color: recommendationColor, shape: BoxShape.circle, ), ), if (confirmedVisits.isNotEmpty) Container( width: 6, height: 6, margin: const EdgeInsets.symmetric( horizontal: 1, ), decoration: BoxDecoration( color: visitColor, shape: BoxShape.circle, ), ), ], ); }, ), calendarStyle: CalendarStyle( outsideDaysVisible: false, selectedDecoration: const BoxDecoration( color: AppColors.lightPrimary, shape: BoxShape.circle, ), todayDecoration: BoxDecoration( color: AppColors.lightPrimary.withOpacity(0.5), shape: BoxShape.circle, ), markersMaxCount: 2, markerDecoration: BoxDecoration( color: visitColor.withOpacity(0.8), shape: BoxShape.circle, ), weekendTextStyle: const TextStyle( color: AppColors.lightError, ), ), headerStyle: HeaderStyle( formatButtonVisible: true, titleCentered: true, formatButtonShowsNext: false, formatButtonDecoration: BoxDecoration( color: AppColors.lightPrimary.withOpacity(0.1), borderRadius: BorderRadius.circular(12), ), formatButtonTextStyle: const TextStyle( color: AppColors.lightPrimary, ), ), ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ _buildLegend( '추천받음', recommendationColor, isDark, tooltip: '추천 기록이 있는 날', // icon: Icons.star, ), const SizedBox(width: 24), _buildLegend( '방문완료', visitColor, isDark, tooltip: '확정된 방문이 있는 날', // icon: Icons.check_circle, ), ], ), ), const SizedBox(height: 16), NativeAdPlaceholder( enabled: adsEnabled, margin: const EdgeInsets.fromLTRB(16, 0, 16, 16), height: 360, ), _buildDayRecords(_selectedDay, isDark), ], ), ), ); }, ); } Widget _buildLegend( String label, Color color, bool isDark, { String? tooltip, IconData? icon, }) { final content = Row( children: [ Container( width: 14, height: 14, decoration: BoxDecoration(color: color, shape: BoxShape.circle), ), const SizedBox(width: 6), if (icon != null) ...[ Icon(icon, size: 14, color: color), const SizedBox(width: 4), ], Text(label, style: AppTypography.body2(isDark)), ], ); if (tooltip == null) return content; return Tooltip(message: tooltip, child: content); } Widget _buildDayRecords(DateTime day, bool isDark) { final events = _getEventsForDay(day); events.sort((a, b) => b.sortDate.compareTo(a.sortDate)); final visitCount = events.where((e) => e.visitRecord != null).length; final recommendationCount = events .where((e) => e.recommendationRecord != null) .length; if (events.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.event_available, size: 48, color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary, ), const SizedBox(height: 16), Text('이날의 기록이 없습니다', style: AppTypography.body2(isDark)), ], ), ); } return Column( children: [ Padding( padding: const EdgeInsets.all(16), child: Row( children: [ Icon( Icons.calendar_today, size: 20, color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary, ), const SizedBox(width: 8), Text( '${day.month}월 ${day.day}일 기록', style: AppTypography.body1( isDark, ).copyWith(fontWeight: FontWeight.bold), ), const Spacer(), Text( '${events.length}건 · 방문 $visitCount/추천 $recommendationCount', style: AppTypography.body2(isDark).copyWith( color: AppColors.lightPrimary, fontWeight: FontWeight.bold, ), ), ], ), ), ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: events.length, itemBuilder: (context, index) { final event = events[index]; if (event.visitRecord != null) { return VisitRecordCard( visitRecord: event.visitRecord!, onTap: () => _showRecordActions(visitRecord: event.visitRecord!), ); } if (event.recommendationRecord != null) { return RecommendationRecordCard( recommendation: event.recommendationRecord!, onTap: () => _showRecordActions( recommendationRecord: event.recommendationRecord!, ), onConfirmVisit: () async { await ref .read(recommendationNotifierProvider.notifier) .confirmVisit(event.recommendationRecord!.id); }, onDelete: () async { await ref .read(recommendationNotifierProvider.notifier) .deleteRecommendation(event.recommendationRecord!.id); }, ); } return const SizedBox.shrink(); }, ), ], ); } Map> _buildEvents( List visits, List recommendations, ) { final Map> events = {}; for (final visit in visits) { final day = DateTime( visit.visitDate.year, visit.visitDate.month, visit.visitDate.day, ); events[day] = [ ...(events[day] ?? []), _CalendarEvent(visitRecord: visit), ]; } for (final reco in recommendations.where((r) => !r.visited)) { final day = DateTime( reco.recommendationDate.year, reco.recommendationDate.month, reco.recommendationDate.day, ); events[day] = [ ...(events[day] ?? []), _CalendarEvent(recommendationRecord: reco), ]; } return events; } void _updateCalendarRange( List visits, List recommendations, ) { final range = _calculateCalendarRange(visits, recommendations); final clampedFocused = _clampDate( _focusedDay, range.firstDay, range.lastDay, ); final clampedSelected = _clampDate( _selectedDay, range.firstDay, range.lastDay, ); final updatedMonth = DateTime(clampedFocused.year, clampedFocused.month, 1); final hasRangeChanged = !_isSameDate(_firstDay, range.firstDay) || !_isSameDate(_lastDay, range.lastDay); final hasFocusChanged = !isSameDay(_focusedDay, clampedFocused); final hasSelectedChanged = !isSameDay(_selectedDay, clampedSelected); final hasMonthChanged = !_isSameMonth(_selectedMonth, updatedMonth); if (hasRangeChanged || hasFocusChanged || hasSelectedChanged || hasMonthChanged) { setState(() { _firstDay = range.firstDay; _lastDay = range.lastDay; _focusedDay = clampedFocused; _selectedDay = clampedSelected; _selectedMonth = updatedMonth; }); } } ({DateTime firstDay, DateTime lastDay}) _calculateCalendarRange( List visits, List recommendations, ) { final now = DateTime.now(); final dates = [ ...visits.map( (visit) => DateTime( visit.visitDate.year, visit.visitDate.month, visit.visitDate.day, ), ), ...recommendations.map( (reco) => DateTime( reco.recommendationDate.year, reco.recommendationDate.month, reco.recommendationDate.day, ), ), ]; if (dates.isEmpty) { return ( firstDay: DateTime(now.year - 1, now.month, 1), lastDay: DateTime(now.year + 1, now.month, now.day), ); } final earliest = dates.reduce((a, b) => a.isBefore(b) ? a : b); final latest = dates.reduce((a, b) => a.isAfter(b) ? a : b); final baseLastDay = latest.isAfter(now) ? latest : now; return ( firstDay: DateTime(earliest.year, earliest.month, earliest.day), lastDay: DateTime( baseLastDay.year + 1, baseLastDay.month, baseLastDay.day, ), ); } DateTime _clampDate(DateTime date, DateTime min, DateTime max) { if (date.isBefore(min)) return min; if (date.isAfter(max)) return max; return date; } bool _isSameDate(DateTime a, DateTime b) => a.year == b.year && a.month == b.month && a.day == b.day; bool _isSameMonth(DateTime a, DateTime b) => a.year == b.year && a.month == b.month; List _buildMonthOptions(DateTime firstDay, DateTime lastDay) { final months = []; var cursor = DateTime(firstDay.year, firstDay.month, 1); final end = DateTime(lastDay.year, lastDay.month, 1); while (!cursor.isAfter(end)) { months.add(cursor); cursor = DateTime(cursor.year, cursor.month + 1, 1); } return months; } void _onMonthChanged(DateTime month) { final targetMonth = DateTime(month.year, month.month, 1); final clampedMonth = _clampDate(targetMonth, _firstDay, _lastDay); setState(() { _focusedDay = clampedMonth; _selectedMonth = clampedMonth; _selectedDay = clampedMonth; }); } Future _showRecordActions({ VisitRecord? visitRecord, RecommendationRecord? recommendationRecord, }) async { final restaurantId = visitRecord?.restaurantId ?? recommendationRecord?.restaurantId; if (restaurantId == null) return; final isDark = Theme.of(context).brightness == Brightness.dark; await showModalBottomSheet( context: context, showDragHandle: true, backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface, builder: (sheetContext) { return Consumer( builder: (context, ref, child) { final restaurantAsync = ref.watch(restaurantProvider(restaurantId)); return restaurantAsync.when( data: (restaurant) { if (restaurant == null) { return const SizedBox.shrink(); } final visitTime = visitRecord?.visitDate; final recoTime = recommendationRecord?.recommendationDate; final isVisitConfirmed = visitRecord?.isConfirmed == true; return SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 20), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( restaurant.name, style: AppTypography.heading2(isDark), maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), Text( restaurant.category, style: AppTypography.body2(isDark).copyWith( color: isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary, ), ), const SizedBox(height: 12), InfoRow( label: '주소', value: restaurant.roadAddress, isDark: isDark, horizontal: true, ), if (visitTime != null) InfoRow( label: isVisitConfirmed ? '방문 완료' : '방문 예정', value: _formatFullDateTime(visitTime), isDark: isDark, horizontal: true, ), if (recoTime != null) InfoRow( label: '추천 시각', value: _formatFullDateTime(recoTime), isDark: isDark, horizontal: true, ), const SizedBox(height: 12), Wrap( spacing: 8, runSpacing: 8, children: [ TextButton.icon( onPressed: () => _showRestaurantDetailDialog( context, isDark, restaurant, ), icon: const Icon(Icons.info_outline), label: const Text('식당 정보'), ), TextButton.icon( onPressed: () async { Navigator.of(sheetContext).pop(); await showDialog( context: context, builder: (dialogContext) => EditRestaurantDialog( restaurant: restaurant, ), ); }, icon: const Icon(Icons.edit_outlined), label: const Text('맛집 수정'), ), ], ), const SizedBox(height: 12), if (visitRecord != null && !isVisitConfirmed) SizedBox( width: double.infinity, child: ElevatedButton.icon( onPressed: () async { await ref .read(visitNotifierProvider.notifier) .confirmVisit(visitRecord.id); if (!sheetContext.mounted) return; Navigator.of(sheetContext).pop(); }, icon: const Icon(Icons.check_circle), label: const Text('방문 확인'), ), ), if (recommendationRecord != null) SizedBox( width: double.infinity, child: ElevatedButton.icon( onPressed: () async { await ref .read(recommendationNotifierProvider.notifier) .confirmVisit(recommendationRecord.id); if (!sheetContext.mounted) return; Navigator.of(sheetContext).pop(); }, icon: const Icon(Icons.playlist_add_check), label: const Text('추천 방문 기록으로 저장'), ), ), ], ), ), ); }, loading: () => const SizedBox( height: 160, child: Center(child: CircularProgressIndicator()), ), error: (_, __) => Padding( padding: const EdgeInsets.all(16), child: Text( '맛집 정보를 불러올 수 없습니다.', style: AppTypography.body2(isDark), ), ), ); }, ); }, ); } Future _showRestaurantDetailDialog( BuildContext context, bool isDark, Restaurant restaurant, ) async { await showDialog( context: context, builder: (dialogContext) => AlertDialog( backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface, title: Text(restaurant.name), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ InfoRow( label: '카테고리', value: '${restaurant.category} > ${restaurant.subCategory}', isDark: isDark, horizontal: true, ), if (restaurant.phoneNumber != null) InfoRow( label: '전화번호', value: restaurant.phoneNumber!, isDark: isDark, horizontal: true, ), InfoRow( label: '도로명', value: restaurant.roadAddress, isDark: isDark, horizontal: true, ), InfoRow( label: '지번', value: restaurant.jibunAddress, isDark: isDark, horizontal: true, ), if (restaurant.description != null && restaurant.description!.isNotEmpty) InfoRow( label: '메모', value: restaurant.description!, isDark: isDark, horizontal: true, ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('닫기'), ), ], ), ); } String _formatFullDateTime(DateTime dateTime) { final month = dateTime.month.toString().padLeft(2, '0'); final day = dateTime.day.toString().padLeft(2, '0'); final hour = dateTime.hour.toString().padLeft(2, '0'); final minute = dateTime.minute.toString().padLeft(2, '0'); return '${dateTime.year}-$month-$day $hour:$minute'; } Color _visitMarkerColor(bool isDark) => isDark ? AppColors.darkPrimary : AppColors.lightPrimary; Color _recommendationMarkerColor(bool isDark) => isDark ? AppColors.darkWarning : AppColors.lightWarning; } class _CalendarEvent { final VisitRecord? visitRecord; final RecommendationRecord? recommendationRecord; _CalendarEvent({this.visitRecord, this.recommendationRecord}); DateTime get sortDate => visitRecord?.visitDate ?? recommendationRecord!.recommendationDate; }