fix(ad): 스크린샷 모드에서 네이티브 광고 비활성화
This commit is contained in:
@@ -12,6 +12,7 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
|||||||
static const String _keyNotificationDelayMinutes =
|
static const String _keyNotificationDelayMinutes =
|
||||||
'notification_delay_minutes';
|
'notification_delay_minutes';
|
||||||
static const String _keyNotificationEnabled = 'notification_enabled';
|
static const String _keyNotificationEnabled = 'notification_enabled';
|
||||||
|
static const String _keyScreenshotModeEnabled = 'screenshot_mode_enabled';
|
||||||
static const String _keyDarkModeEnabled = 'dark_mode_enabled';
|
static const String _keyDarkModeEnabled = 'dark_mode_enabled';
|
||||||
static const String _keyFirstRun = 'first_run';
|
static const String _keyFirstRun = 'first_run';
|
||||||
static const String _keyCategoryWeights = 'category_weights';
|
static const String _keyCategoryWeights = 'category_weights';
|
||||||
@@ -22,6 +23,7 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
|||||||
static const int _defaultMaxDistanceNormal = 1000;
|
static const int _defaultMaxDistanceNormal = 1000;
|
||||||
static const int _defaultNotificationDelayMinutes = 90;
|
static const int _defaultNotificationDelayMinutes = 90;
|
||||||
static const bool _defaultNotificationEnabled = true;
|
static const bool _defaultNotificationEnabled = true;
|
||||||
|
static const bool _defaultScreenshotModeEnabled = false;
|
||||||
static const bool _defaultDarkModeEnabled = false;
|
static const bool _defaultDarkModeEnabled = false;
|
||||||
static const bool _defaultFirstRun = true;
|
static const bool _defaultFirstRun = true;
|
||||||
|
|
||||||
@@ -155,6 +157,21 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
|||||||
await box.put(_keyNotificationEnabled, enabled);
|
await box.put(_keyNotificationEnabled, enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> isScreenshotModeEnabled() async {
|
||||||
|
final box = await _box;
|
||||||
|
return box.get(
|
||||||
|
_keyScreenshotModeEnabled,
|
||||||
|
defaultValue: _defaultScreenshotModeEnabled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setScreenshotModeEnabled(bool enabled) async {
|
||||||
|
final box = await _box;
|
||||||
|
await box.put(_keyScreenshotModeEnabled, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> isDarkModeEnabled() async {
|
Future<bool> isDarkModeEnabled() async {
|
||||||
final box = await _box;
|
final box = await _box;
|
||||||
@@ -193,6 +210,7 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
|||||||
_defaultNotificationDelayMinutes,
|
_defaultNotificationDelayMinutes,
|
||||||
);
|
);
|
||||||
await box.put(_keyNotificationEnabled, _defaultNotificationEnabled);
|
await box.put(_keyNotificationEnabled, _defaultNotificationEnabled);
|
||||||
|
await box.put(_keyScreenshotModeEnabled, _defaultScreenshotModeEnabled);
|
||||||
await box.put(_keyDarkModeEnabled, _defaultDarkModeEnabled);
|
await box.put(_keyDarkModeEnabled, _defaultDarkModeEnabled);
|
||||||
await box.put(_keyFirstRun, false); // 리셋 후에는 첫 실행이 아님
|
await box.put(_keyFirstRun, false); // 리셋 후에는 첫 실행이 아님
|
||||||
}
|
}
|
||||||
@@ -215,6 +233,7 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
|||||||
_keyMaxDistanceNormal: await getMaxDistanceNormal(),
|
_keyMaxDistanceNormal: await getMaxDistanceNormal(),
|
||||||
_keyNotificationDelayMinutes: await getNotificationDelayMinutes(),
|
_keyNotificationDelayMinutes: await getNotificationDelayMinutes(),
|
||||||
_keyNotificationEnabled: await isNotificationEnabled(),
|
_keyNotificationEnabled: await isNotificationEnabled(),
|
||||||
|
_keyScreenshotModeEnabled: await isScreenshotModeEnabled(),
|
||||||
_keyDarkModeEnabled: await isDarkModeEnabled(),
|
_keyDarkModeEnabled: await isDarkModeEnabled(),
|
||||||
_keyFirstRun: await isFirstRun(),
|
_keyFirstRun: await isFirstRun(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ abstract class SettingsRepository {
|
|||||||
/// 알림 활성화 여부를 설정합니다
|
/// 알림 활성화 여부를 설정합니다
|
||||||
Future<void> setNotificationEnabled(bool enabled);
|
Future<void> setNotificationEnabled(bool enabled);
|
||||||
|
|
||||||
|
/// 스크린샷 모드 활성화 여부를 가져옵니다
|
||||||
|
Future<bool> isScreenshotModeEnabled();
|
||||||
|
|
||||||
|
/// 스크린샷 모드 활성화 여부를 설정합니다
|
||||||
|
Future<void> setScreenshotModeEnabled(bool enabled);
|
||||||
|
|
||||||
/// 다크모드 설정을 가져옵니다
|
/// 다크모드 설정을 가져옵니다
|
||||||
Future<bool> isDarkModeEnabled();
|
Future<bool> isDarkModeEnabled();
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:table_calendar/table_calendar.dart';
|
import 'package:table_calendar/table_calendar.dart';
|
||||||
import '../../../core/constants/app_colors.dart';
|
import '../../../core/constants/app_colors.dart';
|
||||||
import '../../../core/constants/app_typography.dart';
|
import '../../../core/constants/app_typography.dart';
|
||||||
|
import '../../../domain/entities/restaurant.dart';
|
||||||
import '../../../domain/entities/recommendation_record.dart';
|
import '../../../domain/entities/recommendation_record.dart';
|
||||||
import '../../../domain/entities/visit_record.dart';
|
import '../../../domain/entities/visit_record.dart';
|
||||||
import '../../providers/recommendation_provider.dart';
|
import '../../providers/recommendation_provider.dart';
|
||||||
|
import '../../providers/settings_provider.dart';
|
||||||
|
import '../../providers/restaurant_provider.dart';
|
||||||
import '../../providers/visit_provider.dart';
|
import '../../providers/visit_provider.dart';
|
||||||
import '../../widgets/native_ad_placeholder.dart';
|
import '../../widgets/native_ad_placeholder.dart';
|
||||||
|
import '../restaurant_list/widgets/edit_restaurant_dialog.dart';
|
||||||
import 'widgets/visit_record_card.dart';
|
import 'widgets/visit_record_card.dart';
|
||||||
import 'widgets/recommendation_record_card.dart';
|
import 'widgets/recommendation_record_card.dart';
|
||||||
import 'widgets/visit_statistics.dart';
|
import 'widgets/visit_statistics.dart';
|
||||||
@@ -23,6 +27,9 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
|||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
late DateTime _selectedDay;
|
late DateTime _selectedDay;
|
||||||
late DateTime _focusedDay;
|
late DateTime _focusedDay;
|
||||||
|
late DateTime _selectedMonth;
|
||||||
|
late DateTime _firstDay;
|
||||||
|
late DateTime _lastDay;
|
||||||
CalendarFormat _calendarFormat = CalendarFormat.month;
|
CalendarFormat _calendarFormat = CalendarFormat.month;
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
Map<DateTime, List<_CalendarEvent>> _events = {};
|
Map<DateTime, List<_CalendarEvent>> _events = {};
|
||||||
@@ -30,8 +37,12 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_selectedDay = DateTime.now();
|
final now = DateTime.now();
|
||||||
_focusedDay = 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);
|
_tabController = TabController(length: 2, vsync: this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +60,22 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
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(
|
return Scaffold(
|
||||||
backgroundColor: isDark
|
backgroundColor: isDark
|
||||||
@@ -75,172 +102,196 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
|||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
children: [
|
children: [
|
||||||
// 캘린더 탭
|
// 캘린더 탭
|
||||||
_buildCalendarTab(isDark),
|
_buildCalendarTab(isDark: isDark, adsEnabled: !screenshotModeEnabled),
|
||||||
// 통계 탭
|
// 통계 탭
|
||||||
VisitStatistics(selectedMonth: _focusedDay),
|
VisitStatistics(
|
||||||
|
selectedMonth: _selectedMonth,
|
||||||
|
availableMonths: monthOptions,
|
||||||
|
onMonthChanged: _onMonthChanged,
|
||||||
|
adsEnabled: !screenshotModeEnabled,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCalendarTab(bool isDark) {
|
Widget _buildCalendarTab({required bool isDark, bool adsEnabled = true}) {
|
||||||
return Consumer(
|
final visitColor = _visitMarkerColor(isDark);
|
||||||
builder: (context, ref, child) {
|
final recommendationColor = _recommendationMarkerColor(isDark);
|
||||||
final visitRecordsAsync = ref.watch(visitRecordsProvider);
|
|
||||||
final recommendationRecordsAsync = ref.watch(
|
|
||||||
recommendationRecordsProvider,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (visitRecordsAsync.hasValue && recommendationRecordsAsync.hasValue) {
|
return LayoutBuilder(
|
||||||
final visits = visitRecordsAsync.value ?? [];
|
builder: (context, constraints) {
|
||||||
final recommendations =
|
return SingleChildScrollView(
|
||||||
recommendationRecordsAsync.valueOrNull ??
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
<RecommendationRecord>[];
|
child: ConstrainedBox(
|
||||||
_events = _buildEvents(visits, recommendations);
|
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(
|
final calendarEvents = events.cast<_CalendarEvent>();
|
||||||
builder: (context, constraints) {
|
final confirmedVisits = calendarEvents.where(
|
||||||
return SingleChildScrollView(
|
(e) => e.visitRecord?.isConfirmed == true,
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
);
|
||||||
child: ConstrainedBox(
|
final recommendedOnly = calendarEvents.where(
|
||||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
(e) => e.recommendationRecord != null,
|
||||||
child: Column(
|
);
|
||||||
children: [
|
|
||||||
Card(
|
return Row(
|
||||||
margin: const EdgeInsets.all(16),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
color: isDark
|
children: [
|
||||||
? AppColors.darkSurface
|
if (recommendedOnly.isNotEmpty)
|
||||||
: AppColors.lightSurface,
|
Container(
|
||||||
elevation: 2,
|
width: 6,
|
||||||
shape: RoundedRectangleBorder(
|
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),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: TableCalendar(
|
formatButtonTextStyle: const TextStyle(
|
||||||
firstDay: DateTime.utc(2025, 1, 1),
|
color: AppColors.lightPrimary,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
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) {
|
Widget _buildLegend(
|
||||||
return Row(
|
String label,
|
||||||
|
Color color,
|
||||||
|
bool isDark, {
|
||||||
|
String? tooltip,
|
||||||
|
IconData? icon,
|
||||||
|
}) {
|
||||||
|
final content = Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 14,
|
width: 14,
|
||||||
@@ -248,14 +299,25 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
|||||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
|
if (icon != null) ...[
|
||||||
|
Icon(icon, size: 14, color: color),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
],
|
||||||
Text(label, style: AppTypography.body2(isDark)),
|
Text(label, style: AppTypography.body2(isDark)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (tooltip == null) return content;
|
||||||
|
return Tooltip(message: tooltip, child: content);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDayRecords(DateTime day, bool isDark) {
|
Widget _buildDayRecords(DateTime day, bool isDark) {
|
||||||
final events = _getEventsForDay(day);
|
final events = _getEventsForDay(day);
|
||||||
events.sort((a, b) => b.sortDate.compareTo(a.sortDate));
|
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) {
|
if (events.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
@@ -291,14 +353,14 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'${day.month}월 ${day.day}일 방문 기록',
|
'${day.month}월 ${day.day}일 기록',
|
||||||
style: AppTypography.body1(
|
style: AppTypography.body1(
|
||||||
isDark,
|
isDark,
|
||||||
).copyWith(fontWeight: FontWeight.bold),
|
).copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Text(
|
Text(
|
||||||
'${events.length}건',
|
'${events.length}건 · 방문 $visitCount/추천 $recommendationCount',
|
||||||
style: AppTypography.body2(isDark).copyWith(
|
style: AppTypography.body2(isDark).copyWith(
|
||||||
color: AppColors.lightPrimary,
|
color: AppColors.lightPrimary,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -316,12 +378,16 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
|||||||
if (event.visitRecord != null) {
|
if (event.visitRecord != null) {
|
||||||
return VisitRecordCard(
|
return VisitRecordCard(
|
||||||
visitRecord: event.visitRecord!,
|
visitRecord: event.visitRecord!,
|
||||||
onTap: () {},
|
onTap: () =>
|
||||||
|
_showRecordActions(visitRecord: event.visitRecord!),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (event.recommendationRecord != null) {
|
if (event.recommendationRecord != null) {
|
||||||
return RecommendationRecordCard(
|
return RecommendationRecordCard(
|
||||||
recommendation: event.recommendationRecord!,
|
recommendation: event.recommendationRecord!,
|
||||||
|
onTap: () => _showRecordActions(
|
||||||
|
recommendationRecord: event.recommendationRecord!,
|
||||||
|
),
|
||||||
onConfirmVisit: () async {
|
onConfirmVisit: () async {
|
||||||
await ref
|
await ref
|
||||||
.read(recommendationNotifierProvider.notifier)
|
.read(recommendationNotifierProvider.notifier)
|
||||||
@@ -373,6 +439,342 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
|||||||
|
|
||||||
return events;
|
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 {
|
class _CalendarEvent {
|
||||||
|
|||||||
@@ -8,8 +8,17 @@ import 'package:lunchpick/presentation/widgets/native_ad_placeholder.dart';
|
|||||||
|
|
||||||
class VisitStatistics extends ConsumerWidget {
|
class VisitStatistics extends ConsumerWidget {
|
||||||
final DateTime selectedMonth;
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -47,7 +56,7 @@ class VisitStatistics extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
const NativeAdPlaceholder(height: 360),
|
NativeAdPlaceholder(height: 360, enabled: adsEnabled),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// 주간 통계 차트
|
// 주간 통계 차트
|
||||||
@@ -66,6 +75,8 @@ class VisitStatistics extends ConsumerWidget {
|
|||||||
AsyncValue<Map<String, int>> categoryStatsAsync,
|
AsyncValue<Map<String, int>> categoryStatsAsync,
|
||||||
bool isDark,
|
bool isDark,
|
||||||
) {
|
) {
|
||||||
|
final monthList = _normalizeMonths();
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
@@ -75,10 +86,7 @@ class VisitStatistics extends ConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
_buildMonthSelector(monthList, isDark),
|
||||||
'${selectedMonth.month}월 방문 통계',
|
|
||||||
style: AppTypography.heading2(isDark),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
statsAsync.when(
|
statsAsync.when(
|
||||||
data: (stats) {
|
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/location_provider.dart';
|
||||||
import '../../providers/recommendation_provider.dart';
|
import '../../providers/recommendation_provider.dart';
|
||||||
import '../../providers/restaurant_provider.dart';
|
import '../../providers/restaurant_provider.dart';
|
||||||
|
import '../../providers/settings_provider.dart';
|
||||||
import '../../providers/weather_provider.dart';
|
import '../../providers/weather_provider.dart';
|
||||||
import 'widgets/recommendation_result_dialog.dart';
|
import 'widgets/recommendation_result_dialog.dart';
|
||||||
|
|
||||||
@@ -33,6 +34,18 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
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(
|
return Scaffold(
|
||||||
backgroundColor: isDark
|
backgroundColor: isDark
|
||||||
@@ -165,29 +178,31 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
|||||||
),
|
),
|
||||||
error: (_, __) => SizedBox(
|
error: (_, __) => SizedBox(
|
||||||
height: sectionHeight,
|
height: sectionHeight,
|
||||||
child: Row(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
_buildWeatherInfo(
|
const Icon(
|
||||||
'지금',
|
Icons.cloud_off,
|
||||||
Icons.wb_sunny,
|
color: AppColors.lightWarning,
|
||||||
'맑음',
|
size: 28,
|
||||||
20,
|
|
||||||
isDark,
|
|
||||||
),
|
),
|
||||||
Container(
|
const SizedBox(height: 8),
|
||||||
width: 1,
|
Text(
|
||||||
height: 50,
|
'날씨 정보를 불러오지 못했습니다.',
|
||||||
color: isDark
|
style: AppTypography.body2(isDark),
|
||||||
? AppColors.darkDivider
|
textAlign: TextAlign.center,
|
||||||
: AppColors.lightDivider,
|
|
||||||
),
|
),
|
||||||
_buildWeatherInfo(
|
const SizedBox(height: 4),
|
||||||
'1시간 후',
|
Text(
|
||||||
Icons.wb_sunny,
|
'네트워크/위치 권한을 확인한 뒤 다시 시도해 주세요.',
|
||||||
'맑음',
|
style: AppTypography.caption(isDark),
|
||||||
22,
|
textAlign: TextAlign.center,
|
||||||
isDark,
|
),
|
||||||
|
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(
|
ElevatedButton(
|
||||||
onPressed: !_isProcessingRecommendation && _canRecommend()
|
onPressed: !_isProcessingRecommendation && readiness.canRecommend
|
||||||
? () => _startRecommendation()
|
? () => _startRecommendation()
|
||||||
: null,
|
: null,
|
||||||
style: ElevatedButton.styleFrom(
|
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 SizedBox(height: 16),
|
||||||
// const NativeAdPlaceholder(
|
// const NativeAdPlaceholder(
|
||||||
// margin: EdgeInsets.symmetric(vertical: 8),
|
// 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(
|
Widget _buildAllCategoryChip(
|
||||||
bool isDark,
|
bool isDark,
|
||||||
bool isSelected,
|
bool isSelected,
|
||||||
@@ -609,33 +624,113 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
|||||||
}).length;
|
}).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _canRecommend() {
|
_RecommendationReadiness _evaluateRecommendationReadiness({
|
||||||
final locationAsync = ref.read(currentLocationWithFallbackProvider);
|
required AsyncValue<Position> locationAsync,
|
||||||
final restaurantsAsync = ref.read(restaurantListProvider);
|
required AsyncValue<List<Restaurant>> restaurantsAsync,
|
||||||
final categories = ref
|
required AsyncValue<List<String>> categoriesAsync,
|
||||||
.read(categoriesProvider)
|
bool screenshotModeEnabled = false,
|
||||||
.maybeWhen(data: (list) => list, orElse: () => const <String>[]);
|
}) {
|
||||||
|
if (screenshotModeEnabled) {
|
||||||
final location = locationAsync.maybeWhen(
|
return const _RecommendationReadiness(canRecommend: true);
|
||||||
data: (pos) => pos,
|
|
||||||
orElse: () => null,
|
|
||||||
);
|
|
||||||
final restaurants = restaurantsAsync.maybeWhen(
|
|
||||||
data: (list) => list,
|
|
||||||
orElse: () => null,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (location == null || restaurants == null || restaurants.isEmpty) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
final count = _getRestaurantCountInRange(
|
||||||
restaurants,
|
restaurants,
|
||||||
location,
|
location,
|
||||||
_distanceValue,
|
_distanceValue,
|
||||||
_effectiveSelectedCategories(categories),
|
_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({
|
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 }
|
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/category_mapper.dart';
|
||||||
import '../../../core/utils/app_logger.dart';
|
import '../../../core/utils/app_logger.dart';
|
||||||
import '../../providers/restaurant_provider.dart';
|
import '../../providers/restaurant_provider.dart';
|
||||||
|
import '../../providers/settings_provider.dart';
|
||||||
import '../../widgets/category_selector.dart';
|
import '../../widgets/category_selector.dart';
|
||||||
import '../../widgets/native_ad_placeholder.dart';
|
import '../../widgets/native_ad_placeholder.dart';
|
||||||
import 'manual_restaurant_input_screen.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 isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
final searchQuery = ref.watch(searchQueryProvider);
|
final searchQuery = ref.watch(searchQueryProvider);
|
||||||
final selectedCategory = ref.watch(selectedCategoryProvider);
|
final selectedCategory = ref.watch(selectedCategoryProvider);
|
||||||
|
final screenshotModeEnabled = ref
|
||||||
|
.watch(screenshotModeEnabledProvider)
|
||||||
|
.maybeWhen(data: (value) => value, orElse: () => false);
|
||||||
final isFiltered = searchQuery.isNotEmpty || selectedCategory != null;
|
final isFiltered = searchQuery.isNotEmpty || selectedCategory != null;
|
||||||
final restaurantsAsync = ref.watch(sortedRestaurantsByDistanceProvider);
|
final restaurantsAsync = ref.watch(sortedRestaurantsByDistanceProvider);
|
||||||
|
|
||||||
@@ -157,8 +161,9 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
|||||||
index >= adOffset &&
|
index >= adOffset &&
|
||||||
(index - adOffset) % adInterval == 0;
|
(index - adOffset) % adInterval == 0;
|
||||||
if (isAdIndex) {
|
if (isAdIndex) {
|
||||||
return const NativeAdPlaceholder(
|
return NativeAdPlaceholder(
|
||||||
margin: EdgeInsets.symmetric(
|
enabled: !screenshotModeEnabled,
|
||||||
|
margin: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
vertical: 8,
|
vertical: 8,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
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(
|
return Scaffold(
|
||||||
backgroundColor: isDark
|
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),
|
if (kDebugMode) _buildDebugToolsCard(isDark),
|
||||||
], 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/bluetooth_provider.dart';
|
||||||
import 'package:lunchpick/presentation/providers/debug_share_preview_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/restaurant_provider.dart';
|
||||||
|
import 'package:lunchpick/presentation/providers/settings_provider.dart';
|
||||||
import 'package:lunchpick/presentation/widgets/native_ad_placeholder.dart';
|
import 'package:lunchpick/presentation/widgets/native_ad_placeholder.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
@@ -125,6 +126,9 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
final screenshotModeEnabled = ref
|
||||||
|
.watch(screenshotModeEnabledProvider)
|
||||||
|
.maybeWhen(data: (value) => value, orElse: () => false);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: isDark
|
backgroundColor: isDark
|
||||||
@@ -157,7 +161,10 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
|||||||
child: _buildSendSection(isDark),
|
child: _buildSendSection(isDark),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const NativeAdPlaceholder(height: 220),
|
NativeAdPlaceholder(
|
||||||
|
height: 220,
|
||||||
|
enabled: !screenshotModeEnabled,
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_ShareCard(
|
_ShareCard(
|
||||||
isDark: isDark,
|
isDark: isDark,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:lunchpick/domain/entities/recommendation_record.dart';
|
import 'package:lunchpick/domain/entities/recommendation_record.dart';
|
||||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||||
@@ -25,6 +27,76 @@ final todayRecommendationCountProvider = FutureProvider<int>((ref) async {
|
|||||||
return repository.getTodayRecommendationCount();
|
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 {
|
class RecommendationSettings {
|
||||||
final int daysToExclude;
|
final int daysToExclude;
|
||||||
@@ -59,6 +131,13 @@ class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
|
|||||||
state = const AsyncValue.loading();
|
state = const AsyncValue.loading();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final screenshotModeEnabled = await _isScreenshotModeEnabled(_ref);
|
||||||
|
if (screenshotModeEnabled) {
|
||||||
|
final mock = _buildScreenshotRestaurant();
|
||||||
|
state = AsyncValue.data(mock);
|
||||||
|
return mock;
|
||||||
|
}
|
||||||
|
|
||||||
final selectedRestaurant = await _generateCandidate(
|
final selectedRestaurant = await _generateCandidate(
|
||||||
maxDistance: maxDistance,
|
maxDistance: maxDistance,
|
||||||
selectedCategories: selectedCategories,
|
selectedCategories: selectedCategories,
|
||||||
@@ -142,8 +221,19 @@ class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
|
|||||||
DateTime? recommendationTime,
|
DateTime? recommendationTime,
|
||||||
bool visited = false,
|
bool visited = false,
|
||||||
}) async {
|
}) async {
|
||||||
|
final screenshotModeEnabled = await _isScreenshotModeEnabled(_ref);
|
||||||
final now = DateTime.now();
|
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(
|
final record = RecommendationRecord(
|
||||||
id: const Uuid().v4(),
|
id: const Uuid().v4(),
|
||||||
restaurantId: restaurant.id,
|
restaurantId: restaurant.id,
|
||||||
@@ -258,6 +348,11 @@ class EnhancedRecommendationNotifier
|
|||||||
Future<void> rerollRecommendation() async {
|
Future<void> rerollRecommendation() async {
|
||||||
if (state.currentRecommendation == null) return;
|
if (state.currentRecommendation == null) return;
|
||||||
|
|
||||||
|
if (await _isScreenshotModeEnabled(_ref)) {
|
||||||
|
await generateRecommendation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 현재 추천을 제외 목록에 추가
|
// 현재 추천을 제외 목록에 추가
|
||||||
final excluded = [
|
final excluded = [
|
||||||
...state.excludedRestaurants,
|
...state.excludedRestaurants,
|
||||||
@@ -276,6 +371,17 @@ class EnhancedRecommendationNotifier
|
|||||||
state = state.copyWith(isLoading: true);
|
state = state.copyWith(isLoading: true);
|
||||||
|
|
||||||
try {
|
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);
|
final location = await _ref.read(currentLocationProvider.future);
|
||||||
if (location == null) {
|
if (location == null) {
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ final notificationEnabledProvider = FutureProvider<bool>((ref) async {
|
|||||||
return repository.isNotificationEnabled();
|
return repository.isNotificationEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// 스크린샷 모드 활성화 여부 Provider
|
||||||
|
final screenshotModeEnabledProvider = FutureProvider<bool>((ref) async {
|
||||||
|
final repository = ref.watch(settingsRepositoryProvider);
|
||||||
|
return repository.isScreenshotModeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
/// 다크모드 활성화 여부 Provider
|
/// 다크모드 활성화 여부 Provider
|
||||||
final darkModeEnabledProvider = FutureProvider<bool>((ref) async {
|
final darkModeEnabledProvider = FutureProvider<bool>((ref) async {
|
||||||
final repository = ref.watch(settingsRepositoryProvider);
|
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 {
|
Future<void> setDarkModeEnabled(bool enabled) async {
|
||||||
state = const AsyncValue.loading();
|
state = const AsyncValue.loading();
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ class NativeAdPlaceholder extends StatefulWidget {
|
|||||||
final EdgeInsetsGeometry? margin;
|
final EdgeInsetsGeometry? margin;
|
||||||
final double height;
|
final double height;
|
||||||
final Duration refreshInterval;
|
final Duration refreshInterval;
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
const NativeAdPlaceholder({
|
const NativeAdPlaceholder({
|
||||||
super.key,
|
super.key,
|
||||||
this.margin,
|
this.margin,
|
||||||
this.height = 200,
|
this.height = 200,
|
||||||
this.refreshInterval = const Duration(minutes: 2),
|
this.refreshInterval = const Duration(minutes: 2),
|
||||||
|
this.enabled = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -35,7 +37,7 @@ class _NativeAdPlaceholderState extends State<NativeAdPlaceholder> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted || !widget.enabled) return;
|
||||||
_loadAd();
|
_loadAd();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -43,6 +45,20 @@ class _NativeAdPlaceholderState extends State<NativeAdPlaceholder> {
|
|||||||
@override
|
@override
|
||||||
void didUpdateWidget(covariant NativeAdPlaceholder oldWidget) {
|
void didUpdateWidget(covariant NativeAdPlaceholder oldWidget) {
|
||||||
super.didUpdateWidget(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) {
|
if (widget.refreshInterval != oldWidget.refreshInterval && _isLoaded) {
|
||||||
_scheduleRefresh();
|
_scheduleRefresh();
|
||||||
}
|
}
|
||||||
@@ -50,12 +66,19 @@ class _NativeAdPlaceholderState extends State<NativeAdPlaceholder> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_refreshTimer?.cancel();
|
_disposeAd();
|
||||||
_nativeAd?.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _disposeAd() {
|
||||||
|
_refreshTimer?.cancel();
|
||||||
|
_refreshTimer = null;
|
||||||
|
_nativeAd?.dispose();
|
||||||
|
_nativeAd = null;
|
||||||
|
}
|
||||||
|
|
||||||
void _loadAd() {
|
void _loadAd() {
|
||||||
|
if (!widget.enabled) return;
|
||||||
if (!AdHelper.isMobilePlatform) return;
|
if (!AdHelper.isMobilePlatform) return;
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
@@ -102,6 +125,7 @@ class _NativeAdPlaceholderState extends State<NativeAdPlaceholder> {
|
|||||||
void _scheduleRefresh({bool retry = false}) {
|
void _scheduleRefresh({bool retry = false}) {
|
||||||
_refreshTimer?.cancel();
|
_refreshTimer?.cancel();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
if (!widget.enabled) return;
|
||||||
final delay = retry ? const Duration(seconds: 30) : widget.refreshInterval;
|
final delay = retry ? const Duration(seconds: 30) : widget.refreshInterval;
|
||||||
_refreshTimer = Timer(delay, _loadAd);
|
_refreshTimer = Timer(delay, _loadAd);
|
||||||
}
|
}
|
||||||
@@ -132,7 +156,7 @@ class _NativeAdPlaceholderState extends State<NativeAdPlaceholder> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
if (!AdHelper.isMobilePlatform) {
|
if (!AdHelper.isMobilePlatform || !widget.enabled) {
|
||||||
return _buildPlaceholder(isDark, isLoading: false);
|
return _buildPlaceholder(isDark, isLoading: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user