Files
lunchpick/lib/presentation/pages/calendar/calendar_screen.dart

789 lines
28 KiB
Dart

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 '../../../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<CalendarScreen> createState() => _CalendarScreenState();
}
class _CalendarScreenState extends ConsumerState<CalendarScreen>
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<DateTime, List<_CalendarEvent>> _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 ?? <VisitRecord>[];
final recommendations =
recommendationRecordsAsync.valueOrNull ?? <RecommendationRecord>[];
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<DateTime, List<_CalendarEvent>> _buildEvents(
List<VisitRecord> visits,
List<RecommendationRecord> recommendations,
) {
final Map<DateTime, List<_CalendarEvent>> 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<VisitRecord> visits,
List<RecommendationRecord> 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<VisitRecord> visits,
List<RecommendationRecord> recommendations,
) {
final now = DateTime.now();
final dates = <DateTime>[
...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<DateTime> _buildMonthOptions(DateTime firstDay, DateTime lastDay) {
final months = <DateTime>[];
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<void> _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<void>(
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 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),
_buildInfoRow('주소', restaurant.roadAddress, isDark),
if (visitTime != null)
_buildInfoRow(
isVisitConfirmed ? '방문 완료' : '방문 예정',
_formatFullDateTime(visitTime),
isDark,
),
if (recoTime != null)
_buildInfoRow(
'추천 시각',
_formatFullDateTime(recoTime),
isDark,
),
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<bool>(
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),
),
),
);
},
);
},
);
}
Widget _buildInfoRow(String label, String value, bool isDark) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 80,
child: Text(label, style: AppTypography.caption(isDark)),
),
const SizedBox(width: 8),
Expanded(child: Text(value, style: AppTypography.body2(isDark))),
],
),
);
}
Future<void> _showRestaurantDetailDialog(
BuildContext context,
bool isDark,
Restaurant restaurant,
) async {
await showDialog<void>(
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: [
_buildInfoRow(
'카테고리',
'${restaurant.category} > ${restaurant.subCategory}',
isDark,
),
if (restaurant.phoneNumber != null)
_buildInfoRow('전화번호', restaurant.phoneNumber!, isDark),
_buildInfoRow('도로명', restaurant.roadAddress, isDark),
_buildInfoRow('지번', restaurant.jibunAddress, isDark),
if (restaurant.description != null &&
restaurant.description!.isNotEmpty)
_buildInfoRow('메모', restaurant.description!, isDark),
],
),
),
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;
}