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:
JiWoong Sul
2025-11-22 00:10:51 +09:00
parent 947fe59486
commit 2a01fa50c6
43 changed files with 1777 additions and 571 deletions

View File

@@ -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;
}

View File

@@ -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(),
);
}
}

View File

@@ -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),
),
);
}
}

View File

@@ -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,
),
],
),
),
),
],
],
),
),
),
);

View File

@@ -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 {

View File

@@ -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);
});

View File

@@ -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());
}
}
}

View File

@@ -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);
// 에러 발생 시 기본적으로 캘린더 화면으로 이동

View File

@@ -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;
}
/// 추천 후 방문 확인

View File

@@ -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) {