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

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