fix(ad): 스크린샷 모드에서 네이티브 광고 비활성화

This commit is contained in:
JiWoong Sul
2025-12-04 16:29:32 +09:00
parent 04b1c3e987
commit bcc26f5e79
11 changed files with 1037 additions and 230 deletions

View File

@@ -3,11 +3,15 @@ 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';
@@ -23,6 +27,9 @@ 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 = {};
@@ -30,8 +37,12 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
@override
void initState() {
super.initState();
_selectedDay = DateTime.now();
_focusedDay = DateTime.now();
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);
}
@@ -49,6 +60,22 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
@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
@@ -75,172 +102,196 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
controller: _tabController,
children: [
// 캘린더 탭
_buildCalendarTab(isDark),
_buildCalendarTab(isDark: isDark, adsEnabled: !screenshotModeEnabled),
// 통계 탭
VisitStatistics(selectedMonth: _focusedDay),
VisitStatistics(
selectedMonth: _selectedMonth,
availableMonths: monthOptions,
onMonthChanged: _onMonthChanged,
adsEnabled: !screenshotModeEnabled,
),
],
),
);
}
Widget _buildCalendarTab(bool isDark) {
return Consumer(
builder: (context, ref, child) {
final visitRecordsAsync = ref.watch(visitRecordsProvider);
final recommendationRecordsAsync = ref.watch(
recommendationRecordsProvider,
);
Widget _buildCalendarTab({required bool isDark, bool adsEnabled = true}) {
final visitColor = _visitMarkerColor(isDark);
final recommendationColor = _recommendationMarkerColor(isDark);
if (visitRecordsAsync.hasValue && recommendationRecordsAsync.hasValue) {
final visits = visitRecordsAsync.value ?? [];
final recommendations =
recommendationRecordsAsync.valueOrNull ??
<RecommendationRecord>[];
_events = _buildEvents(visits, recommendations);
}
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;
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(
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),
),
child: TableCalendar(
firstDay: DateTime.utc(2025, 1, 1),
lastDay: DateTime.utc(2030, 12, 31),
focusedDay: _focusedDay,
calendarFormat: _calendarFormat,
selectedDayPredicate: (day) =>
isSameDay(_selectedDay, day),
onDaySelected: (selectedDay, focusedDay) {
setState(() {
_selectedDay = selectedDay;
_focusedDay = 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 (confirmedVisits.isNotEmpty)
Container(
width: 6,
height: 6,
margin: const EdgeInsets.symmetric(
horizontal: 1,
),
decoration: const BoxDecoration(
color: AppColors.lightPrimary,
shape: BoxShape.circle,
),
),
if (recommendedOnly.isNotEmpty)
Container(
width: 6,
height: 6,
margin: const EdgeInsets.symmetric(
horizontal: 1,
),
decoration: const BoxDecoration(
color: Colors.orange,
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: const BoxDecoration(
color: AppColors.lightSecondary,
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,
),
),
formatButtonTextStyle: const TextStyle(
color: AppColors.lightPrimary,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildLegend('추천받음', Colors.orange, isDark),
const SizedBox(width: 24),
_buildLegend('방문완료', Colors.green, isDark),
],
),
),
const SizedBox(height: 16),
const NativeAdPlaceholder(
margin: EdgeInsets.fromLTRB(16, 0, 16, 16),
height: 360,
),
_buildDayRecords(_selectedDay, isDark),
],
),
),
),
);
},
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) {
return Row(
Widget _buildLegend(
String label,
Color color,
bool isDark, {
String? tooltip,
IconData? icon,
}) {
final content = Row(
children: [
Container(
width: 14,
@@ -248,14 +299,25 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
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(
@@ -291,14 +353,14 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
),
const SizedBox(width: 8),
Text(
'${day.month}${day.day} 방문 기록',
'${day.month}${day.day}일 기록',
style: AppTypography.body1(
isDark,
).copyWith(fontWeight: FontWeight.bold),
),
const Spacer(),
Text(
'${events.length}',
'${events.length} · 방문 $visitCount/추천 $recommendationCount',
style: AppTypography.body2(isDark).copyWith(
color: AppColors.lightPrimary,
fontWeight: FontWeight.bold,
@@ -316,12 +378,16 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
if (event.visitRecord != null) {
return VisitRecordCard(
visitRecord: event.visitRecord!,
onTap: () {},
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)
@@ -373,6 +439,342 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
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 {

View File

@@ -8,8 +8,17 @@ import 'package:lunchpick/presentation/widgets/native_ad_placeholder.dart';
class VisitStatistics extends ConsumerWidget {
final DateTime selectedMonth;
final List<DateTime> availableMonths;
final void Function(DateTime month) onMonthChanged;
final bool adsEnabled;
const VisitStatistics({super.key, required this.selectedMonth});
const VisitStatistics({
super.key,
required this.selectedMonth,
required this.availableMonths,
required this.onMonthChanged,
this.adsEnabled = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -47,7 +56,7 @@ class VisitStatistics extends ConsumerWidget {
),
const SizedBox(height: 16),
const NativeAdPlaceholder(height: 360),
NativeAdPlaceholder(height: 360, enabled: adsEnabled),
const SizedBox(height: 16),
// 주간 통계 차트
@@ -66,6 +75,8 @@ class VisitStatistics extends ConsumerWidget {
AsyncValue<Map<String, int>> categoryStatsAsync,
bool isDark,
) {
final monthList = _normalizeMonths();
return Card(
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
elevation: 2,
@@ -75,10 +86,7 @@ class VisitStatistics extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${selectedMonth.month}월 방문 통계',
style: AppTypography.heading2(isDark),
),
_buildMonthSelector(monthList, isDark),
const SizedBox(height: 16),
statsAsync.when(
data: (stats) {
@@ -357,4 +365,79 @@ class VisitStatistics extends ConsumerWidget {
],
);
}
Widget _buildMonthSelector(List<DateTime> months, bool isDark) {
final currentMonth = DateTime(selectedMonth.year, selectedMonth.month, 1);
final monthIndex = months.indexWhere(
(month) => _isSameMonth(month, currentMonth),
);
final resolvedIndex = monthIndex == -1 ? 0 : monthIndex;
final hasPrevious = resolvedIndex < months.length - 1;
final hasNext = resolvedIndex > 0;
return Row(
children: [
Expanded(
child: Text(
'${_formatMonth(currentMonth)} 방문 통계',
style: AppTypography.heading2(isDark),
overflow: TextOverflow.ellipsis,
),
),
IconButton(
onPressed: hasPrevious
? () => onMonthChanged(months[resolvedIndex + 1])
: null,
icon: const Icon(Icons.chevron_left),
),
DropdownButtonHideUnderline(
child: DropdownButton<DateTime>(
value: months[resolvedIndex],
onChanged: (month) {
if (month != null) {
onMonthChanged(month);
}
},
items: months
.map(
(month) => DropdownMenuItem(
value: month,
child: Text(_formatMonth(month)),
),
)
.toList(),
),
),
IconButton(
onPressed: hasNext
? () => onMonthChanged(months[resolvedIndex - 1])
: null,
icon: const Icon(Icons.chevron_right),
),
],
);
}
List<DateTime> _normalizeMonths() {
final normalized =
availableMonths
.map((month) => DateTime(month.year, month.month, 1))
.toSet()
.toList()
..sort((a, b) => b.compareTo(a));
final currentMonth = DateTime(selectedMonth.year, selectedMonth.month, 1);
final exists = normalized.any((month) => _isSameMonth(month, currentMonth));
if (!exists) {
normalized.insert(0, currentMonth);
}
return normalized;
}
bool _isSameMonth(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month;
String _formatMonth(DateTime month) =>
'${month.year}.${month.month.toString().padLeft(2, '0')}';
}

View File

@@ -12,6 +12,7 @@ import '../../providers/ad_provider.dart';
import '../../providers/location_provider.dart';
import '../../providers/recommendation_provider.dart';
import '../../providers/restaurant_provider.dart';
import '../../providers/settings_provider.dart';
import '../../providers/weather_provider.dart';
import 'widgets/recommendation_result_dialog.dart';
@@ -33,6 +34,18 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final locationAsync = ref.watch(currentLocationWithFallbackProvider);
final restaurantsAsync = ref.watch(restaurantListProvider);
final categoriesAsync = ref.watch(categoriesProvider);
final screenshotModeEnabled = ref
.watch(screenshotModeEnabledProvider)
.maybeWhen(data: (value) => value, orElse: () => false);
final readiness = _evaluateRecommendationReadiness(
locationAsync: locationAsync,
restaurantsAsync: restaurantsAsync,
categoriesAsync: categoriesAsync,
screenshotModeEnabled: screenshotModeEnabled,
);
return Scaffold(
backgroundColor: isDark
@@ -165,29 +178,31 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
),
error: (_, __) => SizedBox(
height: sectionHeight,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildWeatherInfo(
'지금',
Icons.wb_sunny,
'맑음',
20,
isDark,
const Icon(
Icons.cloud_off,
color: AppColors.lightWarning,
size: 28,
),
Container(
width: 1,
height: 50,
color: isDark
? AppColors.darkDivider
: AppColors.lightDivider,
const SizedBox(height: 8),
Text(
'날씨 정보를 불러오지 못했습니다.',
style: AppTypography.body2(isDark),
textAlign: TextAlign.center,
),
_buildWeatherInfo(
'1시간 후',
Icons.wb_sunny,
'맑음',
22,
isDark,
const SizedBox(height: 4),
Text(
'네트워크/위치 권한을 확인한 뒤 다시 시도해 주세요.',
style: AppTypography.caption(isDark),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: () => ref.refresh(weatherProvider),
icon: const Icon(Icons.refresh),
label: const Text('다시 시도'),
),
],
),
@@ -360,7 +375,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
// 추천받기 버튼
ElevatedButton(
onPressed: !_isProcessingRecommendation && _canRecommend()
onPressed: !_isProcessingRecommendation && readiness.canRecommend
? () => _startRecommendation()
: null,
style: ElevatedButton.styleFrom(
@@ -397,6 +412,30 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
),
),
if (readiness.message != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
readiness.icon,
size: 18,
color: _readinessColor(readiness, isDark),
),
const SizedBox(width: 8),
Expanded(
child: Text(
readiness.message!,
style: AppTypography.caption(
isDark,
).copyWith(color: _readinessColor(readiness, isDark)),
),
),
],
),
),
const SizedBox(height: 16),
// const NativeAdPlaceholder(
// margin: EdgeInsets.symmetric(vertical: 8),
@@ -429,30 +468,6 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
);
}
Widget _buildWeatherInfo(
String label,
IconData icon,
String description,
int temperature,
bool isDark,
) {
return Column(
children: [
Text(label, style: AppTypography.caption(isDark)),
const SizedBox(height: 8),
Icon(icon, color: Colors.orange, size: 32),
const SizedBox(height: 4),
Text(
'$temperature°C',
style: AppTypography.body1(
isDark,
).copyWith(fontWeight: FontWeight.bold),
),
Text(description, style: AppTypography.caption(isDark)),
],
);
}
Widget _buildAllCategoryChip(
bool isDark,
bool isSelected,
@@ -609,33 +624,113 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
}).length;
}
bool _canRecommend() {
final locationAsync = ref.read(currentLocationWithFallbackProvider);
final restaurantsAsync = ref.read(restaurantListProvider);
final categories = ref
.read(categoriesProvider)
.maybeWhen(data: (list) => list, orElse: () => const <String>[]);
final location = locationAsync.maybeWhen(
data: (pos) => pos,
orElse: () => null,
);
final restaurants = restaurantsAsync.maybeWhen(
data: (list) => list,
orElse: () => null,
);
if (location == null || restaurants == null || restaurants.isEmpty) {
return false;
_RecommendationReadiness _evaluateRecommendationReadiness({
required AsyncValue<Position> locationAsync,
required AsyncValue<List<Restaurant>> restaurantsAsync,
required AsyncValue<List<String>> categoriesAsync,
bool screenshotModeEnabled = false,
}) {
if (screenshotModeEnabled) {
return const _RecommendationReadiness(canRecommend: true);
}
if (locationAsync.isLoading) {
return const _RecommendationReadiness(
canRecommend: false,
message: '위치 정보를 불러오는 중입니다. 잠시만 기다려 주세요.',
icon: Icons.location_searching,
isWarning: true,
);
}
if (locationAsync.hasError) {
return const _RecommendationReadiness(
canRecommend: false,
message: '위치 권한 또는 설정을 확인해 주세요.',
icon: Icons.gps_off,
isError: true,
);
}
final location = locationAsync.value;
if (location == null) {
return const _RecommendationReadiness(
canRecommend: false,
message: '위치 정보를 확인할 수 없습니다.',
icon: Icons.location_disabled,
isError: true,
);
}
if (restaurantsAsync.isLoading) {
return const _RecommendationReadiness(
canRecommend: false,
message: '맛집 목록을 불러오는 중입니다.',
icon: Icons.restaurant_menu,
isWarning: true,
);
}
final restaurants = restaurantsAsync.value;
if (restaurants == null || restaurants.isEmpty) {
return const _RecommendationReadiness(
canRecommend: false,
message: '등록된 맛집이 없습니다. 맛집을 추가해 주세요.',
icon: Icons.playlist_remove,
isError: true,
);
}
if (categoriesAsync.isLoading) {
return const _RecommendationReadiness(
canRecommend: false,
message: '카테고리를 불러오는 중입니다.',
icon: Icons.category_outlined,
isWarning: true,
);
}
final categories = categoriesAsync.value ?? const <String>[];
final count = _getRestaurantCountInRange(
restaurants,
location,
_distanceValue,
_effectiveSelectedCategories(categories),
);
return count > 0;
if (count == 0) {
return const _RecommendationReadiness(
canRecommend: false,
message: '선택한 거리/카테고리에 해당하는 맛집이 없습니다. 범위를 넓혀 보세요.',
icon: Icons.travel_explore,
isWarning: true,
);
}
final usesFallback = _isFallbackPosition(location);
return _RecommendationReadiness(
canRecommend: true,
message: usesFallback
? '기본 위치(서울 시청) 기준으로 추천 중입니다. 위치 권한을 허용하면 더 정확해요.'
: null,
icon: usesFallback ? Icons.gps_not_fixed : Icons.check_circle,
isWarning: usesFallback,
);
}
bool _isFallbackPosition(Position position) {
final fallback = defaultPosition();
return position.latitude == fallback.latitude &&
position.longitude == fallback.longitude;
}
Color _readinessColor(_RecommendationReadiness readiness, bool isDark) {
if (readiness.isError) {
return isDark ? AppColors.darkError : AppColors.lightError;
}
if (readiness.isWarning) {
return isDark ? AppColors.darkWarning : AppColors.lightWarning;
}
return isDark ? AppColors.darkTextSecondary : AppColors.lightTextSecondary;
}
Future<void> _startRecommendation({
@@ -845,4 +940,20 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
}
}
class _RecommendationReadiness {
final bool canRecommend;
final String? message;
final IconData icon;
final bool isWarning;
final bool isError;
const _RecommendationReadiness({
required this.canRecommend,
this.message,
this.icon = Icons.info_outline,
this.isWarning = false,
this.isError = false,
});
}
enum _SnackType { info, warning, error, success }

View File

@@ -5,6 +5,7 @@ import '../../../core/constants/app_typography.dart';
import '../../../core/utils/category_mapper.dart';
import '../../../core/utils/app_logger.dart';
import '../../providers/restaurant_provider.dart';
import '../../providers/settings_provider.dart';
import '../../widgets/category_selector.dart';
import '../../widgets/native_ad_placeholder.dart';
import 'manual_restaurant_input_screen.dart';
@@ -34,6 +35,9 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
final isDark = Theme.of(context).brightness == Brightness.dark;
final searchQuery = ref.watch(searchQueryProvider);
final selectedCategory = ref.watch(selectedCategoryProvider);
final screenshotModeEnabled = ref
.watch(screenshotModeEnabledProvider)
.maybeWhen(data: (value) => value, orElse: () => false);
final isFiltered = searchQuery.isNotEmpty || selectedCategory != null;
final restaurantsAsync = ref.watch(sortedRestaurantsByDistanceProvider);
@@ -157,8 +161,9 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
index >= adOffset &&
(index - adOffset) % adInterval == 0;
if (isAdIndex) {
return const NativeAdPlaceholder(
margin: EdgeInsets.symmetric(
return NativeAdPlaceholder(
enabled: !screenshotModeEnabled,
margin: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),

View File

@@ -54,6 +54,10 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final screenshotModeAsync = ref.watch(screenshotModeEnabledProvider);
final screenshotModeEnabled = screenshotModeAsync.valueOrNull ?? false;
final isUpdatingSettings = ref.watch(settingsNotifierProvider).isLoading;
final showScreenshotTools = !kReleaseMode;
return Scaffold(
backgroundColor: isDark
@@ -350,6 +354,29 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
],
),
),
if (showScreenshotTools)
Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: SwitchListTile.adaptive(
title: const Text('스크린샷 모드'),
subtitle: const Text('네이티브 광고를 숨기고 가상의 추천 결과를 표시합니다'),
value: screenshotModeEnabled,
onChanged:
(isUpdatingSettings || screenshotModeAsync.isLoading)
? null
: (value) async {
await ref
.read(settingsNotifierProvider.notifier)
.setScreenshotModeEnabled(value);
ref.invalidate(screenshotModeEnabledProvider);
},
activeColor: AppColors.lightPrimary,
),
),
if (kDebugMode) _buildDebugToolsCard(isDark),
], isDark),

View File

@@ -14,6 +14,7 @@ import 'package:lunchpick/presentation/providers/ad_provider.dart';
import 'package:lunchpick/presentation/providers/bluetooth_provider.dart';
import 'package:lunchpick/presentation/providers/debug_share_preview_provider.dart';
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
import 'package:lunchpick/presentation/providers/settings_provider.dart';
import 'package:lunchpick/presentation/widgets/native_ad_placeholder.dart';
import 'package:uuid/uuid.dart';
@@ -125,6 +126,9 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final screenshotModeEnabled = ref
.watch(screenshotModeEnabledProvider)
.maybeWhen(data: (value) => value, orElse: () => false);
return Scaffold(
backgroundColor: isDark
@@ -157,7 +161,10 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
child: _buildSendSection(isDark),
),
const SizedBox(height: 16),
const NativeAdPlaceholder(height: 220),
NativeAdPlaceholder(
height: 220,
enabled: !screenshotModeEnabled,
),
const SizedBox(height: 16),
_ShareCard(
isDark: isDark,

View File

@@ -1,3 +1,5 @@
import 'dart:math';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lunchpick/domain/entities/recommendation_record.dart';
import 'package:lunchpick/domain/entities/restaurant.dart';
@@ -25,6 +27,76 @@ final todayRecommendationCountProvider = FutureProvider<int>((ref) async {
return repository.getTodayRecommendationCount();
});
Future<bool> _isScreenshotModeEnabled(Ref ref) async {
try {
return await ref.read(screenshotModeEnabledProvider.future);
} catch (_) {
return false;
}
}
Restaurant _buildScreenshotRestaurant() {
final now = DateTime.now();
final random = Random();
final templates = [
(
id: 'screenshot-basil-bistro',
name: 'Basil Breeze Bistro',
category: '양식',
subCategory: '파스타 · 그릴',
description: '바질 향이 가득한 파스타와 스테이크를 즐길 수 있는 다이닝.',
phoneNumber: '02-1234-5678',
roadAddress: '서울 중구 세종대로 110',
jibunAddress: '서울 중구 태평로1가 31',
latitude: 37.5665,
longitude: 126.9780,
),
(
id: 'screenshot-komorebi-sushi',
name: 'Komorebi Sushi',
category: '일식',
subCategory: '스시 · 사시미',
description: '제철 재료로 선보이는 오마카세 콘셉트 스시 바.',
phoneNumber: '02-2468-1357',
roadAddress: '서울 강남구 테헤란로 311',
jibunAddress: '서울 강남구 역삼동 647-9',
latitude: 37.5009,
longitude: 127.0365,
),
(
id: 'screenshot-brunch-lab',
name: 'Sunny Brunch Lab',
category: '카페/디저트',
subCategory: '브런치 · 디저트',
description: '스크램블 에그와 시그니처 라떼가 인기인 브런치 카페.',
phoneNumber: '02-9753-8642',
roadAddress: '서울 마포구 독막로 12길 5',
jibunAddress: '서울 마포구 합정동 374-6',
latitude: 37.5509,
longitude: 126.9143,
),
];
final template = templates[random.nextInt(templates.length)];
return Restaurant(
id: '${template.id}-${now.millisecondsSinceEpoch}',
name: template.name,
category: template.category,
subCategory: template.subCategory,
description: template.description,
phoneNumber: template.phoneNumber,
roadAddress: template.roadAddress,
jibunAddress: template.jibunAddress,
latitude: template.latitude,
longitude: template.longitude,
source: DataSource.PRESET,
createdAt: now,
updatedAt: now,
needsAddressVerification: false,
);
}
/// 추천 설정 모델
class RecommendationSettings {
final int daysToExclude;
@@ -59,6 +131,13 @@ class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
state = const AsyncValue.loading();
try {
final screenshotModeEnabled = await _isScreenshotModeEnabled(_ref);
if (screenshotModeEnabled) {
final mock = _buildScreenshotRestaurant();
state = AsyncValue.data(mock);
return mock;
}
final selectedRestaurant = await _generateCandidate(
maxDistance: maxDistance,
selectedCategories: selectedCategories,
@@ -142,8 +221,19 @@ class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
DateTime? recommendationTime,
bool visited = false,
}) async {
final screenshotModeEnabled = await _isScreenshotModeEnabled(_ref);
final now = DateTime.now();
if (screenshotModeEnabled) {
return RecommendationRecord(
id: 'screenshot-${now.millisecondsSinceEpoch}',
restaurantId: restaurant.id,
recommendationDate: recommendationTime ?? now,
visited: visited,
createdAt: now,
);
}
final record = RecommendationRecord(
id: const Uuid().v4(),
restaurantId: restaurant.id,
@@ -258,6 +348,11 @@ class EnhancedRecommendationNotifier
Future<void> rerollRecommendation() async {
if (state.currentRecommendation == null) return;
if (await _isScreenshotModeEnabled(_ref)) {
await generateRecommendation();
return;
}
// 현재 추천을 제외 목록에 추가
final excluded = [
...state.excludedRestaurants,
@@ -276,6 +371,17 @@ class EnhancedRecommendationNotifier
state = state.copyWith(isLoading: true);
try {
if (await _isScreenshotModeEnabled(_ref)) {
final mock = _buildScreenshotRestaurant();
state = state.copyWith(
currentRecommendation: mock,
excludedRestaurants: const [],
isLoading: false,
error: null,
);
return;
}
// 현재 위치 가져오기
final location = await _ref.read(currentLocationProvider.future);
if (location == null) {

View File

@@ -33,6 +33,12 @@ final notificationEnabledProvider = FutureProvider<bool>((ref) async {
return repository.isNotificationEnabled();
});
/// 스크린샷 모드 활성화 여부 Provider
final screenshotModeEnabledProvider = FutureProvider<bool>((ref) async {
final repository = ref.watch(settingsRepositoryProvider);
return repository.isScreenshotModeEnabled();
});
/// 다크모드 활성화 여부 Provider
final darkModeEnabledProvider = FutureProvider<bool>((ref) async {
final repository = ref.watch(settingsRepositoryProvider);
@@ -124,6 +130,17 @@ class SettingsNotifier extends StateNotifier<AsyncValue<void>> {
}
}
/// 스크린샷 모드 설정
Future<void> setScreenshotModeEnabled(bool enabled) async {
state = const AsyncValue.loading();
try {
await _repository.setScreenshotModeEnabled(enabled);
state = const AsyncValue.data(null);
} catch (e, stack) {
state = AsyncValue.error(e, stack);
}
}
/// 다크모드 설정
Future<void> setDarkModeEnabled(bool enabled) async {
state = const AsyncValue.loading();

View File

@@ -13,12 +13,14 @@ class NativeAdPlaceholder extends StatefulWidget {
final EdgeInsetsGeometry? margin;
final double height;
final Duration refreshInterval;
final bool enabled;
const NativeAdPlaceholder({
super.key,
this.margin,
this.height = 200,
this.refreshInterval = const Duration(minutes: 2),
this.enabled = true,
});
@override
@@ -35,7 +37,7 @@ class _NativeAdPlaceholderState extends State<NativeAdPlaceholder> {
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (!mounted || !widget.enabled) return;
_loadAd();
});
}
@@ -43,6 +45,20 @@ class _NativeAdPlaceholderState extends State<NativeAdPlaceholder> {
@override
void didUpdateWidget(covariant NativeAdPlaceholder oldWidget) {
super.didUpdateWidget(oldWidget);
if (!widget.enabled) {
_disposeAd();
setState(() {
_isLoading = false;
_isLoaded = false;
});
return;
}
if (!oldWidget.enabled && widget.enabled) {
_loadAd();
return;
}
if (widget.refreshInterval != oldWidget.refreshInterval && _isLoaded) {
_scheduleRefresh();
}
@@ -50,12 +66,19 @@ class _NativeAdPlaceholderState extends State<NativeAdPlaceholder> {
@override
void dispose() {
_refreshTimer?.cancel();
_nativeAd?.dispose();
_disposeAd();
super.dispose();
}
void _disposeAd() {
_refreshTimer?.cancel();
_refreshTimer = null;
_nativeAd?.dispose();
_nativeAd = null;
}
void _loadAd() {
if (!widget.enabled) return;
if (!AdHelper.isMobilePlatform) return;
if (!mounted) return;
@@ -102,6 +125,7 @@ class _NativeAdPlaceholderState extends State<NativeAdPlaceholder> {
void _scheduleRefresh({bool retry = false}) {
_refreshTimer?.cancel();
if (!mounted) return;
if (!widget.enabled) return;
final delay = retry ? const Duration(seconds: 30) : widget.refreshInterval;
_refreshTimer = Timer(delay, _loadAd);
}
@@ -132,7 +156,7 @@ class _NativeAdPlaceholderState extends State<NativeAdPlaceholder> {
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
if (!AdHelper.isMobilePlatform) {
if (!AdHelper.isMobilePlatform || !widget.enabled) {
return _buildPlaceholder(isDark, isLoading: false);
}