feat(app): finalize ad gated flows and weather
- add AppLogger and replace scattered print logging\n- implement ad-gated recommendation flow with reminder handling and calendar link\n- complete Bluetooth share pipeline with ad gate and merge\n- integrate KMA weather API with caching and dart-define decoding\n- add NaverUrlProcessor refactor and restore restaurant repository tests
This commit is contained in:
@@ -3,9 +3,12 @@ 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/recommendation_record.dart';
|
||||
import '../../../domain/entities/visit_record.dart';
|
||||
import '../../providers/recommendation_provider.dart';
|
||||
import '../../providers/visit_provider.dart';
|
||||
import 'widgets/visit_record_card.dart';
|
||||
import 'widgets/recommendation_record_card.dart';
|
||||
import 'widgets/visit_statistics.dart';
|
||||
|
||||
class CalendarScreen extends ConsumerStatefulWidget {
|
||||
@@ -21,7 +24,7 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
||||
late DateTime _focusedDay;
|
||||
CalendarFormat _calendarFormat = CalendarFormat.month;
|
||||
late TabController _tabController;
|
||||
Map<DateTime, List<VisitRecord>> _visitRecordEvents = {};
|
||||
Map<DateTime, List<_CalendarEvent>> _events = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -37,9 +40,9 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<VisitRecord> _getEventsForDay(DateTime day) {
|
||||
List<_CalendarEvent> _getEventsForDay(DateTime day) {
|
||||
final normalizedDay = DateTime(day.year, day.month, day.day);
|
||||
return _visitRecordEvents[normalizedDay] ?? [];
|
||||
return _events[normalizedDay] ?? [];
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -83,22 +86,17 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
||||
return Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final visitRecordsAsync = ref.watch(visitRecordsProvider);
|
||||
final recommendationRecordsAsync = ref.watch(
|
||||
recommendationRecordsProvider,
|
||||
);
|
||||
|
||||
// 방문 기록을 날짜별로 그룹화
|
||||
visitRecordsAsync.whenData((records) {
|
||||
_visitRecordEvents = {};
|
||||
for (final record in records) {
|
||||
final normalizedDate = DateTime(
|
||||
record.visitDate.year,
|
||||
record.visitDate.month,
|
||||
record.visitDate.day,
|
||||
);
|
||||
_visitRecordEvents[normalizedDate] = [
|
||||
...(_visitRecordEvents[normalizedDate] ?? []),
|
||||
record,
|
||||
];
|
||||
}
|
||||
});
|
||||
if (visitRecordsAsync.hasValue && recommendationRecordsAsync.hasValue) {
|
||||
final visits = visitRecordsAsync.value ?? [];
|
||||
final recommendations =
|
||||
recommendationRecordsAsync.valueOrNull ??
|
||||
<RecommendationRecord>[];
|
||||
_events = _buildEvents(visits, recommendations);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@@ -132,17 +130,18 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
||||
markerBuilder: (context, day, events) {
|
||||
if (events.isEmpty) return null;
|
||||
|
||||
final visitRecords = events.cast<VisitRecord>();
|
||||
final confirmedCount = visitRecords
|
||||
.where((r) => r.isConfirmed)
|
||||
.length;
|
||||
final unconfirmedCount =
|
||||
visitRecords.length - confirmedCount;
|
||||
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 (confirmedCount > 0)
|
||||
if (confirmedVisits.isNotEmpty)
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
@@ -152,7 +151,7 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
if (unconfirmedCount > 0)
|
||||
if (recommendedOnly.isNotEmpty)
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
@@ -239,6 +238,7 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
||||
|
||||
Widget _buildDayRecords(DateTime day, bool isDark) {
|
||||
final events = _getEventsForDay(day);
|
||||
events.sort((a, b) => b.sortDate.compareTo(a.sortDate));
|
||||
|
||||
if (events.isEmpty) {
|
||||
return Center(
|
||||
@@ -294,18 +294,71 @@ class _CalendarScreenState extends ConsumerState<CalendarScreen>
|
||||
child: ListView.builder(
|
||||
itemCount: events.length,
|
||||
itemBuilder: (context, index) {
|
||||
final sortedEvents = events
|
||||
..sort((a, b) => b.visitDate.compareTo(a.visitDate));
|
||||
return VisitRecordCard(
|
||||
visitRecord: sortedEvents[index],
|
||||
onTap: () {
|
||||
// TODO: 맛집 상세 페이지로 이동
|
||||
},
|
||||
);
|
||||
final event = events[index];
|
||||
if (event.visitRecord != null) {
|
||||
return VisitRecordCard(
|
||||
visitRecord: event.visitRecord!,
|
||||
onTap: () {},
|
||||
);
|
||||
}
|
||||
if (event.recommendationRecord != null) {
|
||||
return RecommendationRecordCard(
|
||||
recommendation: event.recommendationRecord!,
|
||||
onConfirmVisit: () async {
|
||||
await ref
|
||||
.read(recommendationNotifierProvider.notifier)
|
||||
.confirmVisit(event.recommendationRecord!.id);
|
||||
},
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Map<DateTime, List<_CalendarEvent>> _buildEvents(
|
||||
List<VisitRecord> visits,
|
||||
List<RecommendationRecord> recommendations,
|
||||
) {
|
||||
final Map<DateTime, List<_CalendarEvent>> events = {};
|
||||
|
||||
for (final visit in visits) {
|
||||
final day = DateTime(
|
||||
visit.visitDate.year,
|
||||
visit.visitDate.month,
|
||||
visit.visitDate.day,
|
||||
);
|
||||
events[day] = [
|
||||
...(events[day] ?? []),
|
||||
_CalendarEvent(visitRecord: visit),
|
||||
];
|
||||
}
|
||||
|
||||
for (final reco in recommendations.where((r) => !r.visited)) {
|
||||
final day = DateTime(
|
||||
reco.recommendationDate.year,
|
||||
reco.recommendationDate.month,
|
||||
reco.recommendationDate.day,
|
||||
);
|
||||
events[day] = [
|
||||
...(events[day] ?? []),
|
||||
_CalendarEvent(recommendationRecord: reco),
|
||||
];
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
}
|
||||
|
||||
class _CalendarEvent {
|
||||
final VisitRecord? visitRecord;
|
||||
final RecommendationRecord? recommendationRecord;
|
||||
|
||||
_CalendarEvent({this.visitRecord, this.recommendationRecord});
|
||||
|
||||
DateTime get sortDate =>
|
||||
visitRecord?.visitDate ?? recommendationRecord!.recommendationDate;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lunchpick/core/constants/app_colors.dart';
|
||||
import 'package:lunchpick/core/constants/app_typography.dart';
|
||||
import 'package:lunchpick/domain/entities/recommendation_record.dart';
|
||||
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
||||
|
||||
class RecommendationRecordCard extends ConsumerWidget {
|
||||
final RecommendationRecord recommendation;
|
||||
final VoidCallback onConfirmVisit;
|
||||
|
||||
const RecommendationRecordCard({
|
||||
super.key,
|
||||
required this.recommendation,
|
||||
required this.onConfirmVisit,
|
||||
});
|
||||
|
||||
String _formatTime(DateTime dateTime) {
|
||||
final hour = dateTime.hour.toString().padLeft(2, '0');
|
||||
final minute = dateTime.minute.toString().padLeft(2, '0');
|
||||
return '$hour:$minute';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final restaurantAsync = ref.watch(
|
||||
restaurantProvider(recommendation.restaurantId),
|
||||
);
|
||||
|
||||
return restaurantAsync.when(
|
||||
data: (restaurant) {
|
||||
if (restaurant == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.whatshot,
|
||||
color: Colors.orange,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
restaurant.name,
|
||||
style: AppTypography.body1(
|
||||
isDark,
|
||||
).copyWith(fontWeight: FontWeight.bold),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.category_outlined,
|
||||
size: 14,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
restaurant.category,
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 14,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatTime(recommendation.recommendationDate),
|
||||
style: AppTypography.caption(isDark),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: Colors.orange,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'추천만 받은 상태입니다. 방문 후 확인을 눌러 주세요.',
|
||||
style: AppTypography.caption(isDark).copyWith(
|
||||
color: Colors.orange,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
ElevatedButton(
|
||||
onPressed: onConfirmVisit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
minimumSize: const Size(0, 40),
|
||||
),
|
||||
child: const Text('방문 확인'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Card(
|
||||
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
error: (_, __) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
@@ -28,6 +29,7 @@ class RandomSelectionScreen extends ConsumerStatefulWidget {
|
||||
class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
double _distanceValue = 500;
|
||||
final List<String> _selectedCategories = [];
|
||||
final List<String> _excludedRestaurantIds = [];
|
||||
bool _isProcessingRecommendation = false;
|
||||
|
||||
@override
|
||||
@@ -459,18 +461,28 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
Future<void> _startRecommendation({bool skipAd = false}) async {
|
||||
Future<void> _startRecommendation({
|
||||
bool skipAd = false,
|
||||
bool isReroll = false,
|
||||
}) async {
|
||||
if (_isProcessingRecommendation) return;
|
||||
|
||||
if (!isReroll) {
|
||||
_excludedRestaurantIds.clear();
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isProcessingRecommendation = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final candidate = await _generateRecommendationCandidate();
|
||||
final candidate = await _generateRecommendationCandidate(
|
||||
excludedRestaurantIds: _excludedRestaurantIds,
|
||||
);
|
||||
if (candidate == null) {
|
||||
return;
|
||||
}
|
||||
final recommendedAt = DateTime.now();
|
||||
|
||||
if (!skipAd) {
|
||||
final adService = ref.read(adServiceProvider);
|
||||
@@ -488,7 +500,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
_showRecommendationDialog(candidate);
|
||||
await _showRecommendationDialog(candidate, recommendedAt: recommendedAt);
|
||||
} catch (_) {
|
||||
_showSnack(
|
||||
'추천을 준비하는 중 문제가 발생했습니다.',
|
||||
@@ -503,12 +515,16 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Restaurant?> _generateRecommendationCandidate() async {
|
||||
Future<Restaurant?> _generateRecommendationCandidate({
|
||||
List<String> excludedRestaurantIds = const [],
|
||||
}) async {
|
||||
final notifier = ref.read(recommendationNotifierProvider.notifier);
|
||||
|
||||
await notifier.getRandomRecommendation(
|
||||
final recommendation = await notifier.getRandomRecommendation(
|
||||
maxDistance: _distanceValue,
|
||||
selectedCategories: _selectedCategories,
|
||||
excludedRestaurantIds: excludedRestaurantIds,
|
||||
shouldSaveRecord: false,
|
||||
);
|
||||
|
||||
final result = ref.read(recommendationNotifierProvider);
|
||||
@@ -522,49 +538,82 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
return null;
|
||||
}
|
||||
|
||||
final restaurant = result.asData?.value;
|
||||
if (restaurant == null) {
|
||||
_showSnack('조건에 맞는 식당이 존재하지 않습니다', backgroundColor: AppColors.lightError);
|
||||
if (recommendation == null) {
|
||||
_showSnack(
|
||||
'조건에 맞는 식당이 존재하지 않습니다. 광고는 재생되지 않았습니다.',
|
||||
backgroundColor: AppColors.lightError,
|
||||
);
|
||||
}
|
||||
return restaurant;
|
||||
return recommendation;
|
||||
}
|
||||
|
||||
void _showRecommendationDialog(Restaurant restaurant) {
|
||||
showDialog(
|
||||
Future<void> _showRecommendationDialog(
|
||||
Restaurant restaurant, {
|
||||
DateTime? recommendedAt,
|
||||
}) async {
|
||||
final result = await showDialog<RecommendationDialogResult>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) => RecommendationResultDialog(
|
||||
restaurant: restaurant,
|
||||
onReroll: () async {
|
||||
Navigator.pop(dialogContext);
|
||||
await _startRecommendation(skipAd: true);
|
||||
},
|
||||
onClose: () async {
|
||||
Navigator.pop(dialogContext);
|
||||
await _handleRecommendationAccepted(restaurant);
|
||||
},
|
||||
),
|
||||
builder: (dialogContext) =>
|
||||
RecommendationResultDialog(restaurant: restaurant),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
switch (result) {
|
||||
case RecommendationDialogResult.reroll:
|
||||
setState(() {
|
||||
_excludedRestaurantIds.add(restaurant.id);
|
||||
});
|
||||
await _startRecommendation(skipAd: true, isReroll: true);
|
||||
break;
|
||||
case RecommendationDialogResult.confirm:
|
||||
case RecommendationDialogResult.autoConfirm:
|
||||
default:
|
||||
await _handleRecommendationAccepted(
|
||||
restaurant,
|
||||
recommendedAt ?? DateTime.now(),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleRecommendationAccepted(Restaurant restaurant) async {
|
||||
final recommendationTime = DateTime.now();
|
||||
|
||||
Future<void> _handleRecommendationAccepted(
|
||||
Restaurant restaurant,
|
||||
DateTime recommendationTime,
|
||||
) async {
|
||||
try {
|
||||
final recommendationNotifier = ref.read(
|
||||
recommendationNotifierProvider.notifier,
|
||||
);
|
||||
await recommendationNotifier.saveRecommendationRecord(
|
||||
restaurant,
|
||||
recommendationTime: recommendationTime,
|
||||
);
|
||||
|
||||
final notificationEnabled = await ref.read(
|
||||
notificationEnabledProvider.future,
|
||||
);
|
||||
if (notificationEnabled) {
|
||||
final delayMinutes = await ref.read(
|
||||
notificationDelayMinutesProvider.future,
|
||||
);
|
||||
bool notificationScheduled = false;
|
||||
if (notificationEnabled && !kIsWeb) {
|
||||
final notificationService = ref.read(notificationServiceProvider);
|
||||
await notificationService.scheduleVisitReminder(
|
||||
restaurantId: restaurant.id,
|
||||
restaurantName: restaurant.name,
|
||||
recommendationTime: recommendationTime,
|
||||
delayMinutes: delayMinutes,
|
||||
final notificationReady = await notificationService.ensureInitialized(
|
||||
requestPermission: true,
|
||||
);
|
||||
|
||||
if (notificationReady) {
|
||||
final delayMinutes = await ref.read(
|
||||
notificationDelayMinutesProvider.future,
|
||||
);
|
||||
await notificationService.scheduleVisitReminder(
|
||||
restaurantId: restaurant.id,
|
||||
restaurantName: restaurant.name,
|
||||
recommendationTime: recommendationTime,
|
||||
delayMinutes: delayMinutes,
|
||||
);
|
||||
notificationScheduled = await notificationService
|
||||
.hasVisitReminderScheduled();
|
||||
}
|
||||
}
|
||||
|
||||
await ref
|
||||
@@ -574,7 +623,19 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
recommendationTime: recommendationTime,
|
||||
);
|
||||
|
||||
_showSnack('맛있게 드세요! 🍴');
|
||||
if (notificationEnabled && !notificationScheduled && !kIsWeb) {
|
||||
_showSnack(
|
||||
'방문 기록은 저장됐지만 알림 권한이나 설정을 확인해 주세요. 방문 알림을 예약하지 못했습니다.',
|
||||
backgroundColor: AppColors.lightError,
|
||||
);
|
||||
} else {
|
||||
_showSnack('맛있게 드세요! 🍴');
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_excludedRestaurantIds.clear();
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
_showSnack(
|
||||
'방문 기록 또는 알림 예약에 실패했습니다.',
|
||||
@@ -588,10 +649,22 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
Color backgroundColor = AppColors.lightPrimary,
|
||||
}) {
|
||||
if (!mounted) return;
|
||||
final topInset = MediaQuery.of(context).viewPadding.top;
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(content: Text(message), backgroundColor: backgroundColor),
|
||||
SnackBar(
|
||||
content: Text(message),
|
||||
backgroundColor: backgroundColor,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
(topInset > 0 ? topInset : 16) + 8,
|
||||
16,
|
||||
0,
|
||||
),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,215 +1,258 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lunchpick/core/constants/app_colors.dart';
|
||||
import 'package:lunchpick/core/constants/app_typography.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
|
||||
class RecommendationResultDialog extends StatelessWidget {
|
||||
enum RecommendationDialogResult { confirm, reroll, autoConfirm }
|
||||
|
||||
class RecommendationResultDialog extends StatefulWidget {
|
||||
final Restaurant restaurant;
|
||||
final Future<void> Function() onReroll;
|
||||
final Future<void> Function() onClose;
|
||||
final Duration autoConfirmDuration;
|
||||
|
||||
const RecommendationResultDialog({
|
||||
super.key,
|
||||
required this.restaurant,
|
||||
required this.onReroll,
|
||||
required this.onClose,
|
||||
this.autoConfirmDuration = const Duration(seconds: 12),
|
||||
});
|
||||
|
||||
@override
|
||||
State<RecommendationResultDialog> createState() =>
|
||||
_RecommendationResultDialogState();
|
||||
}
|
||||
|
||||
class _RecommendationResultDialogState
|
||||
extends State<RecommendationResultDialog> {
|
||||
Timer? _autoConfirmTimer;
|
||||
bool _didComplete = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startAutoConfirmTimer();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_autoConfirmTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startAutoConfirmTimer() {
|
||||
_autoConfirmTimer = Timer(widget.autoConfirmDuration, () {
|
||||
if (!mounted || _didComplete) return;
|
||||
_didComplete = true;
|
||||
Navigator.of(context).pop(RecommendationDialogResult.autoConfirm);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleResult(RecommendationDialogResult result) async {
|
||||
if (_didComplete) return;
|
||||
_didComplete = true;
|
||||
_autoConfirmTimer?.cancel();
|
||||
Navigator.of(context).pop(result);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 상단 이미지 영역
|
||||
Container(
|
||||
height: 150,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(20),
|
||||
return WillPopScope(
|
||||
onWillPop: () async {
|
||||
await _handleResult(RecommendationDialogResult.confirm);
|
||||
return true;
|
||||
},
|
||||
child: Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
height: 150,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(20),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.restaurant_menu,
|
||||
size: 64,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'오늘의 추천!',
|
||||
style: AppTypography.heading2(
|
||||
false,
|
||||
).copyWith(color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () async {
|
||||
await onClose();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 맛집 정보
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 가게 이름
|
||||
Center(
|
||||
child: Text(
|
||||
restaurant.name,
|
||||
style: AppTypography.heading1(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 카테고리
|
||||
Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'${restaurant.category} > ${restaurant.subCategory}',
|
||||
style: AppTypography.body2(
|
||||
isDark,
|
||||
).copyWith(color: AppColors.lightPrimary),
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.restaurant_menu,
|
||||
size: 64,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'오늘의 추천!',
|
||||
style: AppTypography.heading2(
|
||||
false,
|
||||
).copyWith(color: Colors.white),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (restaurant.description != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
restaurant.description!,
|
||||
style: AppTypography.body2(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white),
|
||||
onPressed: () async {
|
||||
await _handleResult(
|
||||
RecommendationDialogResult.confirm,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 주소
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.location_on,
|
||||
size: 20,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Text(
|
||||
widget.restaurant.name,
|
||||
style: AppTypography.heading1(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.lightPrimary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
restaurant.roadAddress,
|
||||
style: AppTypography.body2(isDark),
|
||||
'${widget.restaurant.category} > ${widget.restaurant.subCategory}',
|
||||
style: AppTypography.body2(
|
||||
isDark,
|
||||
).copyWith(color: AppColors.lightPrimary),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.restaurant.description != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.restaurant.description!,
|
||||
style: AppTypography.body2(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
if (restaurant.phoneNumber != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.phone,
|
||||
Icons.location_on,
|
||||
size: 20,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
restaurant.phoneNumber!,
|
||||
style: AppTypography.body2(isDark),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.restaurant.roadAddress,
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 버튼들
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () async {
|
||||
await onReroll();
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
side: const BorderSide(
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
if (widget.restaurant.phoneNumber != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.phone,
|
||||
size: 20,
|
||||
color: isDark
|
||||
? AppColors.darkTextSecondary
|
||||
: AppColors.lightTextSecondary,
|
||||
),
|
||||
child: const Text(
|
||||
'다시 뽑기',
|
||||
style: TextStyle(color: AppColors.lightPrimary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
widget.restaurant.phoneNumber!,
|
||||
style: AppTypography.body2(isDark),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
await onClose();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text('닫기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () async {
|
||||
await _handleResult(
|
||||
RecommendationDialogResult.reroll,
|
||||
);
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
side: const BorderSide(
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'다시 뽑기',
|
||||
style: TextStyle(color: AppColors.lightPrimary),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
await _handleResult(
|
||||
RecommendationDialogResult.confirm,
|
||||
);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
backgroundColor: AppColors.lightPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text('닫기'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'조용히 두면 자동으로 방문 처리되고 알림이 예약됩니다.',
|
||||
style: AppTypography.caption(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -290,7 +290,16 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
}
|
||||
|
||||
Future<void> _generateShareCode() async {
|
||||
final hasPermission =
|
||||
await PermissionService.checkAndRequestBluetoothPermission();
|
||||
if (!hasPermission) {
|
||||
if (!mounted) return;
|
||||
_showErrorSnackBar('블루투스 권한을 허용해야 공유 코드를 생성할 수 있어요.');
|
||||
return;
|
||||
}
|
||||
|
||||
final adService = ref.read(adServiceProvider);
|
||||
if (!mounted) return;
|
||||
final adWatched = await adService.showInterstitialAd(context);
|
||||
if (!mounted) return;
|
||||
if (!adWatched) {
|
||||
@@ -301,12 +310,17 @@ class _ShareScreenState extends ConsumerState<ShareScreen> {
|
||||
final random = Random();
|
||||
final code = List.generate(6, (_) => random.nextInt(10)).join();
|
||||
|
||||
setState(() {
|
||||
_shareCode = code;
|
||||
});
|
||||
|
||||
await ref.read(bluetoothServiceProvider).startListening(code);
|
||||
_showSuccessSnackBar('공유 코드가 생성되었습니다.');
|
||||
try {
|
||||
await ref.read(bluetoothServiceProvider).startListening(code);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_shareCode = code;
|
||||
});
|
||||
_showSuccessSnackBar('공유 코드가 생성되었습니다.');
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
_showErrorSnackBar('코드를 생성하지 못했습니다. 잠시 후 다시 시도해 주세요.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _scanDevices() async {
|
||||
|
||||
@@ -4,6 +4,9 @@ import 'package:lunchpick/data/repositories/visit_repository_impl.dart';
|
||||
import 'package:lunchpick/data/repositories/settings_repository_impl.dart';
|
||||
import 'package:lunchpick/data/repositories/weather_repository_impl.dart';
|
||||
import 'package:lunchpick/data/repositories/recommendation_repository_impl.dart';
|
||||
import 'package:lunchpick/data/datasources/remote/naver_url_processor.dart';
|
||||
import 'package:lunchpick/data/datasources/remote/naver_map_parser.dart';
|
||||
import 'package:lunchpick/data/api/naver_api_client.dart';
|
||||
import 'package:lunchpick/domain/repositories/restaurant_repository.dart';
|
||||
import 'package:lunchpick/domain/repositories/visit_repository.dart';
|
||||
import 'package:lunchpick/domain/repositories/settings_repository.dart';
|
||||
@@ -36,3 +39,21 @@ final recommendationRepositoryProvider = Provider<RecommendationRepository>((
|
||||
) {
|
||||
return RecommendationRepositoryImpl();
|
||||
});
|
||||
|
||||
/// NaverApiClient Provider
|
||||
final naverApiClientProvider = Provider<NaverApiClient>((ref) {
|
||||
return NaverApiClient();
|
||||
});
|
||||
|
||||
/// NaverMapParser Provider
|
||||
final naverMapParserProvider = Provider<NaverMapParser>((ref) {
|
||||
final apiClient = ref.watch(naverApiClientProvider);
|
||||
return NaverMapParser(apiClient: apiClient);
|
||||
});
|
||||
|
||||
/// NaverUrlProcessor Provider
|
||||
final naverUrlProcessorProvider = Provider<NaverUrlProcessor>((ref) {
|
||||
final apiClient = ref.watch(naverApiClientProvider);
|
||||
final parser = ref.watch(naverMapParserProvider);
|
||||
return NaverUrlProcessor(apiClient: apiClient, mapParser: parser);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
const double _defaultLatitude = 37.5666805;
|
||||
const double _defaultLongitude = 126.9784147;
|
||||
|
||||
/// 위치 정보를 사용할 수 없을 때 활용하는 기본 좌표(서울 시청).
|
||||
Position defaultPosition() {
|
||||
return Position(
|
||||
latitude: _defaultLatitude,
|
||||
longitude: _defaultLongitude,
|
||||
timestamp: DateTime.now(),
|
||||
accuracy: 0,
|
||||
altitude: 0,
|
||||
altitudeAccuracy: 0,
|
||||
heading: 0,
|
||||
headingAccuracy: 0,
|
||||
speed: 0,
|
||||
speedAccuracy: 0,
|
||||
isMocked: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// 위치 권한 상태 Provider
|
||||
final locationPermissionProvider = FutureProvider<PermissionStatus>((
|
||||
ref,
|
||||
@@ -18,14 +39,16 @@ final currentLocationProvider = FutureProvider<Position?>((ref) async {
|
||||
// 권한이 없으면 요청
|
||||
final result = await Permission.location.request();
|
||||
if (!result.isGranted) {
|
||||
return null;
|
||||
AppLogger.debug('위치 권한 거부됨, 기본 좌표(서울 시청) 사용');
|
||||
return defaultPosition();
|
||||
}
|
||||
}
|
||||
|
||||
// 위치 서비스 활성화 확인
|
||||
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
throw Exception('위치 서비스가 비활성화되어 있습니다');
|
||||
AppLogger.debug('위치 서비스 비활성화, 기본 좌표(서울 시청) 사용');
|
||||
return defaultPosition();
|
||||
}
|
||||
|
||||
// 현재 위치 가져오기
|
||||
@@ -36,7 +59,12 @@ final currentLocationProvider = FutureProvider<Position?>((ref) async {
|
||||
);
|
||||
} catch (e) {
|
||||
// 타임아웃이나 오류 발생 시 마지막 알려진 위치 반환
|
||||
return await Geolocator.getLastKnownPosition();
|
||||
final lastPosition = await Geolocator.getLastKnownPosition();
|
||||
if (lastPosition != null) {
|
||||
return lastPosition;
|
||||
}
|
||||
AppLogger.debug('현재 위치를 가져오지 못해 기본 좌표(서울 시청)를 반환');
|
||||
return defaultPosition();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -83,7 +111,8 @@ class LocationNotifier extends StateNotifier<AsyncValue<Position?>> {
|
||||
if (!permissionStatus.isGranted) {
|
||||
final granted = await requestLocationPermission();
|
||||
if (!granted) {
|
||||
state = const AsyncValue.data(null);
|
||||
AppLogger.debug('위치 권한 거부됨, 기본 좌표(서울 시청)로 대체');
|
||||
state = AsyncValue.data(defaultPosition());
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -91,7 +120,8 @@ class LocationNotifier extends StateNotifier<AsyncValue<Position?>> {
|
||||
// 위치 서비스 확인
|
||||
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
state = AsyncValue.error('위치 서비스가 비활성화되어 있습니다', StackTrace.current);
|
||||
AppLogger.debug('위치 서비스 비활성화, 기본 좌표(서울 시청)로 대체');
|
||||
state = AsyncValue.data(defaultPosition());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -102,13 +132,18 @@ class LocationNotifier extends StateNotifier<AsyncValue<Position?>> {
|
||||
);
|
||||
|
||||
state = AsyncValue.data(position);
|
||||
} catch (e, stack) {
|
||||
} catch (e) {
|
||||
// 오류 발생 시 마지막 알려진 위치 시도
|
||||
try {
|
||||
final lastPosition = await Geolocator.getLastKnownPosition();
|
||||
state = AsyncValue.data(lastPosition);
|
||||
if (lastPosition != null) {
|
||||
state = AsyncValue.data(lastPosition);
|
||||
} else {
|
||||
AppLogger.debug('마지막 위치도 없어 기본 좌표(서울 시청)로 대체');
|
||||
state = AsyncValue.data(defaultPosition());
|
||||
}
|
||||
} catch (_) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
state = AsyncValue.data(defaultPosition());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
import 'package:lunchpick/presentation/pages/calendar/widgets/visit_confirmation_dialog.dart';
|
||||
import 'package:lunchpick/presentation/providers/restaurant_provider.dart';
|
||||
|
||||
@@ -54,8 +55,8 @@ class NotificationPayload {
|
||||
);
|
||||
} catch (e) {
|
||||
// 더 상세한 오류 정보 제공
|
||||
print('NotificationPayload parsing error: $e');
|
||||
print('Original payload: $payload');
|
||||
AppLogger.error('NotificationPayload parsing error', error: e);
|
||||
AppLogger.debug('Original payload: $payload');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -77,17 +78,17 @@ class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
String? payload,
|
||||
) async {
|
||||
if (payload == null || payload.isEmpty) {
|
||||
print('Notification payload is null or empty');
|
||||
AppLogger.debug('Notification payload is null or empty');
|
||||
return;
|
||||
}
|
||||
|
||||
print('Handling notification with payload: $payload');
|
||||
AppLogger.debug('Handling notification with payload: $payload');
|
||||
|
||||
try {
|
||||
// 기존 형식 (visit_reminder:restaurantName) 처리
|
||||
if (payload.startsWith('visit_reminder:')) {
|
||||
final restaurantName = payload.substring(15);
|
||||
print('Legacy format - Restaurant name: $restaurantName');
|
||||
AppLogger.debug('Legacy format - Restaurant name: $restaurantName');
|
||||
|
||||
// 맛집 이름으로 ID 찾기
|
||||
final restaurantsAsync = await _ref.read(restaurantListProvider.future);
|
||||
@@ -110,11 +111,11 @@ class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
}
|
||||
} else {
|
||||
// 새로운 형식의 payload 처리
|
||||
print('Attempting to parse new format payload');
|
||||
AppLogger.debug('Attempting to parse new format payload');
|
||||
|
||||
try {
|
||||
final notificationPayload = NotificationPayload.fromString(payload);
|
||||
print(
|
||||
AppLogger.debug(
|
||||
'Successfully parsed payload - Type: ${notificationPayload.type}, RestaurantId: ${notificationPayload.restaurantId}',
|
||||
);
|
||||
|
||||
@@ -135,8 +136,10 @@ class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
print('Failed to parse new format, attempting fallback parsing');
|
||||
print('Parse error: $parseError');
|
||||
AppLogger.debug(
|
||||
'Failed to parse new format, attempting fallback parsing',
|
||||
);
|
||||
AppLogger.debug('Parse error: $parseError');
|
||||
|
||||
// Fallback: 간단한 파싱 시도
|
||||
if (payload.contains('|')) {
|
||||
@@ -158,8 +161,11 @@ class NotificationHandlerNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
}
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
print('Error handling notification: $e');
|
||||
print('Stack trace: $stackTrace');
|
||||
AppLogger.error(
|
||||
'Error handling notification',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
state = AsyncValue.error(e, stackTrace);
|
||||
|
||||
// 에러 발생 시 기본적으로 캘린더 화면으로 이동
|
||||
|
||||
@@ -50,74 +50,109 @@ class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
|
||||
: super(const AsyncValue.data(null));
|
||||
|
||||
/// 랜덤 추천 실행
|
||||
Future<void> getRandomRecommendation({
|
||||
Future<Restaurant?> getRandomRecommendation({
|
||||
required double maxDistance,
|
||||
required List<String> selectedCategories,
|
||||
List<String> excludedRestaurantIds = const [],
|
||||
bool shouldSaveRecord = true,
|
||||
}) async {
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
try {
|
||||
// 현재 위치 가져오기
|
||||
final location = await _ref.read(currentLocationProvider.future);
|
||||
if (location == null) {
|
||||
throw Exception('위치 정보를 가져올 수 없습니다');
|
||||
}
|
||||
|
||||
// 날씨 정보 가져오기
|
||||
final weather = await _ref.read(weatherProvider.future);
|
||||
|
||||
// 사용자 설정 가져오기
|
||||
final userSettings = await _ref.read(userSettingsProvider.future);
|
||||
|
||||
// 모든 식당 가져오기
|
||||
final allRestaurants = await _ref.read(restaurantListProvider.future);
|
||||
|
||||
// 방문 기록 가져오기
|
||||
final allVisitRecords = await _ref.read(visitRecordsProvider.future);
|
||||
|
||||
// 추천 설정 구성
|
||||
final config = RecommendationConfig(
|
||||
userLatitude: location.latitude,
|
||||
userLongitude: location.longitude,
|
||||
final selectedRestaurant = await _generateCandidate(
|
||||
maxDistance: maxDistance,
|
||||
selectedCategories: selectedCategories,
|
||||
userSettings: userSettings,
|
||||
weather: weather,
|
||||
excludedRestaurantIds: excludedRestaurantIds,
|
||||
);
|
||||
|
||||
// 추천 엔진 사용
|
||||
final selectedRestaurant = await _recommendationEngine
|
||||
.generateRecommendation(
|
||||
allRestaurants: allRestaurants,
|
||||
recentVisits: allVisitRecords,
|
||||
config: config,
|
||||
);
|
||||
|
||||
if (selectedRestaurant == null) {
|
||||
state = const AsyncValue.data(null);
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
// 추천 기록 저장
|
||||
await _saveRecommendationRecord(selectedRestaurant);
|
||||
if (shouldSaveRecord) {
|
||||
await saveRecommendationRecord(selectedRestaurant);
|
||||
}
|
||||
|
||||
state = AsyncValue.data(selectedRestaurant);
|
||||
return selectedRestaurant;
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Restaurant?> _generateCandidate({
|
||||
required double maxDistance,
|
||||
required List<String> selectedCategories,
|
||||
List<String> excludedRestaurantIds = const [],
|
||||
}) async {
|
||||
// 현재 위치 가져오기
|
||||
final location = await _ref.read(currentLocationProvider.future);
|
||||
if (location == null) {
|
||||
throw Exception('위치 정보를 가져올 수 없습니다');
|
||||
}
|
||||
|
||||
// 날씨 정보 가져오기
|
||||
final weather = await _ref.read(weatherProvider.future);
|
||||
|
||||
// 사용자 설정 가져오기
|
||||
final userSettings = await _ref.read(userSettingsProvider.future);
|
||||
|
||||
// 모든 식당 가져오기
|
||||
final allRestaurants = await _ref.read(restaurantListProvider.future);
|
||||
|
||||
// 방문 기록 가져오기
|
||||
final allVisitRecords = await _ref.read(visitRecordsProvider.future);
|
||||
|
||||
// 제외된 식당 제거
|
||||
final availableRestaurants = excludedRestaurantIds.isEmpty
|
||||
? allRestaurants
|
||||
: allRestaurants
|
||||
.where(
|
||||
(restaurant) => !excludedRestaurantIds.contains(restaurant.id),
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (availableRestaurants.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 추천 설정 구성
|
||||
final config = RecommendationConfig(
|
||||
userLatitude: location.latitude,
|
||||
userLongitude: location.longitude,
|
||||
maxDistance: maxDistance,
|
||||
selectedCategories: selectedCategories,
|
||||
userSettings: userSettings,
|
||||
weather: weather,
|
||||
);
|
||||
|
||||
// 추천 엔진 사용
|
||||
return _recommendationEngine.generateRecommendation(
|
||||
allRestaurants: availableRestaurants,
|
||||
recentVisits: allVisitRecords,
|
||||
config: config,
|
||||
);
|
||||
}
|
||||
|
||||
/// 추천 기록 저장
|
||||
Future<void> _saveRecommendationRecord(Restaurant restaurant) async {
|
||||
Future<RecommendationRecord> saveRecommendationRecord(
|
||||
Restaurant restaurant, {
|
||||
DateTime? recommendationTime,
|
||||
}) async {
|
||||
final now = DateTime.now();
|
||||
|
||||
final record = RecommendationRecord(
|
||||
id: const Uuid().v4(),
|
||||
restaurantId: restaurant.id,
|
||||
recommendationDate: DateTime.now(),
|
||||
recommendationDate: recommendationTime ?? now,
|
||||
visited: false,
|
||||
createdAt: DateTime.now(),
|
||||
createdAt: now,
|
||||
);
|
||||
|
||||
await _repository.addRecommendationRecord(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
/// 추천 후 방문 확인
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:lunchpick/domain/entities/weather_info.dart';
|
||||
import 'package:lunchpick/domain/repositories/weather_repository.dart';
|
||||
import 'package:lunchpick/presentation/providers/di_providers.dart';
|
||||
@@ -7,12 +8,17 @@ import 'package:lunchpick/presentation/providers/location_provider.dart';
|
||||
/// 현재 날씨 Provider
|
||||
final weatherProvider = FutureProvider<WeatherInfo>((ref) async {
|
||||
final repository = ref.watch(weatherRepositoryProvider);
|
||||
final location = await ref.watch(currentLocationProvider.future);
|
||||
Position? location;
|
||||
|
||||
if (location == null) {
|
||||
throw Exception('위치 정보를 가져올 수 없습니다');
|
||||
try {
|
||||
location = await ref.watch(currentLocationProvider.future);
|
||||
} catch (_) {
|
||||
// 위치 호출 실패 시 기본 좌표 사용
|
||||
location = defaultPosition();
|
||||
}
|
||||
|
||||
location ??= defaultPosition();
|
||||
|
||||
// 캐시된 날씨 정보 확인
|
||||
final cached = await repository.getCachedWeather();
|
||||
if (cached != null) {
|
||||
|
||||
Reference in New Issue
Block a user