diff --git a/lib/data/repositories/settings_repository_impl.dart b/lib/data/repositories/settings_repository_impl.dart index 1d60af8..c9ff8b6 100644 --- a/lib/data/repositories/settings_repository_impl.dart +++ b/lib/data/repositories/settings_repository_impl.dart @@ -12,6 +12,7 @@ class SettingsRepositoryImpl implements SettingsRepository { static const String _keyNotificationDelayMinutes = 'notification_delay_minutes'; static const String _keyNotificationEnabled = 'notification_enabled'; + static const String _keyScreenshotModeEnabled = 'screenshot_mode_enabled'; static const String _keyDarkModeEnabled = 'dark_mode_enabled'; static const String _keyFirstRun = 'first_run'; static const String _keyCategoryWeights = 'category_weights'; @@ -22,6 +23,7 @@ class SettingsRepositoryImpl implements SettingsRepository { static const int _defaultMaxDistanceNormal = 1000; static const int _defaultNotificationDelayMinutes = 90; static const bool _defaultNotificationEnabled = true; + static const bool _defaultScreenshotModeEnabled = false; static const bool _defaultDarkModeEnabled = false; static const bool _defaultFirstRun = true; @@ -155,6 +157,21 @@ class SettingsRepositoryImpl implements SettingsRepository { await box.put(_keyNotificationEnabled, enabled); } + @override + Future isScreenshotModeEnabled() async { + final box = await _box; + return box.get( + _keyScreenshotModeEnabled, + defaultValue: _defaultScreenshotModeEnabled, + ); + } + + @override + Future setScreenshotModeEnabled(bool enabled) async { + final box = await _box; + await box.put(_keyScreenshotModeEnabled, enabled); + } + @override Future isDarkModeEnabled() async { final box = await _box; @@ -193,6 +210,7 @@ class SettingsRepositoryImpl implements SettingsRepository { _defaultNotificationDelayMinutes, ); await box.put(_keyNotificationEnabled, _defaultNotificationEnabled); + await box.put(_keyScreenshotModeEnabled, _defaultScreenshotModeEnabled); await box.put(_keyDarkModeEnabled, _defaultDarkModeEnabled); await box.put(_keyFirstRun, false); // 리셋 후에는 첫 실행이 아님 } @@ -215,6 +233,7 @@ class SettingsRepositoryImpl implements SettingsRepository { _keyMaxDistanceNormal: await getMaxDistanceNormal(), _keyNotificationDelayMinutes: await getNotificationDelayMinutes(), _keyNotificationEnabled: await isNotificationEnabled(), + _keyScreenshotModeEnabled: await isScreenshotModeEnabled(), _keyDarkModeEnabled: await isDarkModeEnabled(), _keyFirstRun: await isFirstRun(), }; diff --git a/lib/domain/repositories/settings_repository.dart b/lib/domain/repositories/settings_repository.dart index 6ae4ae0..57088d8 100644 --- a/lib/domain/repositories/settings_repository.dart +++ b/lib/domain/repositories/settings_repository.dart @@ -37,6 +37,12 @@ abstract class SettingsRepository { /// 알림 활성화 여부를 설정합니다 Future setNotificationEnabled(bool enabled); + /// 스크린샷 모드 활성화 여부를 가져옵니다 + Future isScreenshotModeEnabled(); + + /// 스크린샷 모드 활성화 여부를 설정합니다 + Future setScreenshotModeEnabled(bool enabled); + /// 다크모드 설정을 가져옵니다 Future isDarkModeEnabled(); diff --git a/lib/presentation/pages/calendar/calendar_screen.dart b/lib/presentation/pages/calendar/calendar_screen.dart index 585cd28..b0126fe 100644 --- a/lib/presentation/pages/calendar/calendar_screen.dart +++ b/lib/presentation/pages/calendar/calendar_screen.dart @@ -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 with SingleTickerProviderStateMixin { late DateTime _selectedDay; late DateTime _focusedDay; + late DateTime _selectedMonth; + late DateTime _firstDay; + late DateTime _lastDay; CalendarFormat _calendarFormat = CalendarFormat.month; late TabController _tabController; Map> _events = {}; @@ -30,8 +37,12 @@ class _CalendarScreenState extends ConsumerState @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 @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; + final visitRecordsAsync = ref.watch(visitRecordsProvider); + final recommendationRecordsAsync = ref.watch(recommendationRecordsProvider); + final screenshotModeEnabled = ref + .watch(screenshotModeEnabledProvider) + .maybeWhen(data: (value) => value, orElse: () => false); + + final visits = visitRecordsAsync.value ?? []; + final recommendations = + recommendationRecordsAsync.valueOrNull ?? []; + + if (visitRecordsAsync.hasValue && recommendationRecordsAsync.hasValue) { + _events = _buildEvents(visits, recommendations); + _updateCalendarRange(visits, recommendations); + } + + final monthOptions = _buildMonthOptions(_firstDay, _lastDay); return Scaffold( backgroundColor: isDark @@ -75,172 +102,196 @@ class _CalendarScreenState extends ConsumerState 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 ?? - []; - _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 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 ), 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 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 return events; } + + void _updateCalendarRange( + List visits, + List recommendations, + ) { + final range = _calculateCalendarRange(visits, recommendations); + final clampedFocused = _clampDate( + _focusedDay, + range.firstDay, + range.lastDay, + ); + final clampedSelected = _clampDate( + _selectedDay, + range.firstDay, + range.lastDay, + ); + final updatedMonth = DateTime(clampedFocused.year, clampedFocused.month, 1); + + final hasRangeChanged = + !_isSameDate(_firstDay, range.firstDay) || + !_isSameDate(_lastDay, range.lastDay); + final hasFocusChanged = !isSameDay(_focusedDay, clampedFocused); + final hasSelectedChanged = !isSameDay(_selectedDay, clampedSelected); + final hasMonthChanged = !_isSameMonth(_selectedMonth, updatedMonth); + + if (hasRangeChanged || + hasFocusChanged || + hasSelectedChanged || + hasMonthChanged) { + setState(() { + _firstDay = range.firstDay; + _lastDay = range.lastDay; + _focusedDay = clampedFocused; + _selectedDay = clampedSelected; + _selectedMonth = updatedMonth; + }); + } + } + + ({DateTime firstDay, DateTime lastDay}) _calculateCalendarRange( + List visits, + List recommendations, + ) { + final now = DateTime.now(); + final dates = [ + ...visits.map( + (visit) => DateTime( + visit.visitDate.year, + visit.visitDate.month, + visit.visitDate.day, + ), + ), + ...recommendations.map( + (reco) => DateTime( + reco.recommendationDate.year, + reco.recommendationDate.month, + reco.recommendationDate.day, + ), + ), + ]; + + if (dates.isEmpty) { + return ( + firstDay: DateTime(now.year - 1, now.month, 1), + lastDay: DateTime(now.year + 1, now.month, now.day), + ); + } + + final earliest = dates.reduce((a, b) => a.isBefore(b) ? a : b); + final latest = dates.reduce((a, b) => a.isAfter(b) ? a : b); + final baseLastDay = latest.isAfter(now) ? latest : now; + + return ( + firstDay: DateTime(earliest.year, earliest.month, earliest.day), + lastDay: DateTime( + baseLastDay.year + 1, + baseLastDay.month, + baseLastDay.day, + ), + ); + } + + DateTime _clampDate(DateTime date, DateTime min, DateTime max) { + if (date.isBefore(min)) return min; + if (date.isAfter(max)) return max; + return date; + } + + bool _isSameDate(DateTime a, DateTime b) => + a.year == b.year && a.month == b.month && a.day == b.day; + + bool _isSameMonth(DateTime a, DateTime b) => + a.year == b.year && a.month == b.month; + + List _buildMonthOptions(DateTime firstDay, DateTime lastDay) { + final months = []; + var cursor = DateTime(firstDay.year, firstDay.month, 1); + final end = DateTime(lastDay.year, lastDay.month, 1); + + while (!cursor.isAfter(end)) { + months.add(cursor); + cursor = DateTime(cursor.year, cursor.month + 1, 1); + } + + return months; + } + + void _onMonthChanged(DateTime month) { + final targetMonth = DateTime(month.year, month.month, 1); + final clampedMonth = _clampDate(targetMonth, _firstDay, _lastDay); + + setState(() { + _focusedDay = clampedMonth; + _selectedMonth = clampedMonth; + _selectedDay = clampedMonth; + }); + } + + Future _showRecordActions({ + VisitRecord? visitRecord, + RecommendationRecord? recommendationRecord, + }) async { + final restaurantId = + visitRecord?.restaurantId ?? recommendationRecord?.restaurantId; + if (restaurantId == null) return; + + final isDark = Theme.of(context).brightness == Brightness.dark; + + await showModalBottomSheet( + context: context, + showDragHandle: true, + backgroundColor: isDark ? AppColors.darkSurface : AppColors.lightSurface, + builder: (sheetContext) { + return Consumer( + builder: (context, ref, child) { + final restaurantAsync = ref.watch(restaurantProvider(restaurantId)); + return restaurantAsync.when( + data: (restaurant) { + if (restaurant == null) { + return const SizedBox.shrink(); + } + + final visitTime = visitRecord?.visitDate; + final recoTime = recommendationRecord?.recommendationDate; + final isVisitConfirmed = visitRecord?.isConfirmed == true; + + return 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( + 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 _showRestaurantDetailDialog( + BuildContext context, + bool isDark, + Restaurant restaurant, + ) async { + await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + backgroundColor: isDark + ? AppColors.darkSurface + : AppColors.lightSurface, + title: Text(restaurant.name), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _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 { diff --git a/lib/presentation/pages/calendar/widgets/visit_statistics.dart b/lib/presentation/pages/calendar/widgets/visit_statistics.dart index 622ca58..c01ff95 100644 --- a/lib/presentation/pages/calendar/widgets/visit_statistics.dart +++ b/lib/presentation/pages/calendar/widgets/visit_statistics.dart @@ -8,8 +8,17 @@ import 'package:lunchpick/presentation/widgets/native_ad_placeholder.dart'; class VisitStatistics extends ConsumerWidget { final DateTime selectedMonth; + final List 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> 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 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( + 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 _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')}'; } diff --git a/lib/presentation/pages/random_selection/random_selection_screen.dart b/lib/presentation/pages/random_selection/random_selection_screen.dart index 5f1cdb5..053c437 100644 --- a/lib/presentation/pages/random_selection/random_selection_screen.dart +++ b/lib/presentation/pages/random_selection/random_selection_screen.dart @@ -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 { @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 { ), 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 { // 추천받기 버튼 ElevatedButton( - onPressed: !_isProcessingRecommendation && _canRecommend() + onPressed: !_isProcessingRecommendation && readiness.canRecommend ? () => _startRecommendation() : null, style: ElevatedButton.styleFrom( @@ -397,6 +412,30 @@ class _RandomSelectionScreenState extends ConsumerState { ), ), + 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 { ); } - 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 { }).length; } - bool _canRecommend() { - final locationAsync = ref.read(currentLocationWithFallbackProvider); - final restaurantsAsync = ref.read(restaurantListProvider); - final categories = ref - .read(categoriesProvider) - .maybeWhen(data: (list) => list, orElse: () => const []); - - 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 locationAsync, + required AsyncValue> restaurantsAsync, + required AsyncValue> 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 []; 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 _startRecommendation({ @@ -845,4 +940,20 @@ class _RandomSelectionScreenState extends ConsumerState { } } +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 } diff --git a/lib/presentation/pages/restaurant_list/restaurant_list_screen.dart b/lib/presentation/pages/restaurant_list/restaurant_list_screen.dart index 73fb438..2e0b922 100644 --- a/lib/presentation/pages/restaurant_list/restaurant_list_screen.dart +++ b/lib/presentation/pages/restaurant_list/restaurant_list_screen.dart @@ -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 { 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 { 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, ), diff --git a/lib/presentation/pages/settings/settings_screen.dart b/lib/presentation/pages/settings/settings_screen.dart index 1181284..99c7368 100644 --- a/lib/presentation/pages/settings/settings_screen.dart +++ b/lib/presentation/pages/settings/settings_screen.dart @@ -54,6 +54,10 @@ class _SettingsScreenState extends ConsumerState { @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 { ], ), ), + 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), diff --git a/lib/presentation/pages/share/share_screen.dart b/lib/presentation/pages/share/share_screen.dart index 4844b57..4e8497b 100644 --- a/lib/presentation/pages/share/share_screen.dart +++ b/lib/presentation/pages/share/share_screen.dart @@ -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 { @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 { child: _buildSendSection(isDark), ), const SizedBox(height: 16), - const NativeAdPlaceholder(height: 220), + NativeAdPlaceholder( + height: 220, + enabled: !screenshotModeEnabled, + ), const SizedBox(height: 16), _ShareCard( isDark: isDark, diff --git a/lib/presentation/providers/recommendation_provider.dart b/lib/presentation/providers/recommendation_provider.dart index 417df9e..22c894e 100644 --- a/lib/presentation/providers/recommendation_provider.dart +++ b/lib/presentation/providers/recommendation_provider.dart @@ -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((ref) async { return repository.getTodayRecommendationCount(); }); +Future _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> { 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> { 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 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) { diff --git a/lib/presentation/providers/settings_provider.dart b/lib/presentation/providers/settings_provider.dart index 08634bc..813f074 100644 --- a/lib/presentation/providers/settings_provider.dart +++ b/lib/presentation/providers/settings_provider.dart @@ -33,6 +33,12 @@ final notificationEnabledProvider = FutureProvider((ref) async { return repository.isNotificationEnabled(); }); +/// 스크린샷 모드 활성화 여부 Provider +final screenshotModeEnabledProvider = FutureProvider((ref) async { + final repository = ref.watch(settingsRepositoryProvider); + return repository.isScreenshotModeEnabled(); +}); + /// 다크모드 활성화 여부 Provider final darkModeEnabledProvider = FutureProvider((ref) async { final repository = ref.watch(settingsRepositoryProvider); @@ -124,6 +130,17 @@ class SettingsNotifier extends StateNotifier> { } } + /// 스크린샷 모드 설정 + Future 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 setDarkModeEnabled(bool enabled) async { state = const AsyncValue.loading(); diff --git a/lib/presentation/widgets/native_ad_placeholder.dart b/lib/presentation/widgets/native_ad_placeholder.dart index fc436b6..b1e6295 100644 --- a/lib/presentation/widgets/native_ad_placeholder.dart +++ b/lib/presentation/widgets/native_ad_placeholder.dart @@ -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 { void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; + if (!mounted || !widget.enabled) return; _loadAd(); }); } @@ -43,6 +45,20 @@ class _NativeAdPlaceholderState extends State { @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 { @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 { 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 { 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); }