## 성능 최적화 ### main.dart - 앱 초기화 병렬 처리 (Future.wait 활용) - 광고 SDK, Hive 초기화 동시 실행 - Hive Box 오픈 병렬 처리 - 코드 구조화 (_initializeHive, _initializeNotifications) ### visit_provider.dart - allLastVisitDatesProvider 추가 - 리스트 화면에서 N+1 쿼리 방지 - 모든 맛집의 마지막 방문일 일괄 조회 ## UI 개선 ### 각 화면 리팩토링 - AppDimensions 상수 적용 - 스켈레톤 로더 적용 - 코드 정리 및 일관성 개선
801 lines
28 KiB
Dart
801 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 '../../../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<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),
|
|
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<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),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
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: [
|
|
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;
|
|
}
|