fix(ad): 스크린샷 모드에서 네이티브 광고 비활성화
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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')}';
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user