feat(app): stabilize recommendation flow

This commit is contained in:
JiWoong Sul
2025-12-01 17:22:21 +09:00
parent d05e378569
commit c1aa16c521
12 changed files with 422 additions and 260 deletions

View File

@@ -30,7 +30,7 @@ class AppConstants {
static const String storeSeedMetaAsset = 'assets/data/store_seed.meta.json'; static const String storeSeedMetaAsset = 'assets/data/store_seed.meta.json';
// Default Settings // Default Settings
static const int defaultDaysToExclude = 7; static const int defaultDaysToExclude = 14;
static const int defaultNotificationMinutes = 90; static const int defaultNotificationMinutes = 90;
static const int defaultMaxDistanceNormal = 1000; // meters static const int defaultMaxDistanceNormal = 1000; // meters
static const int defaultMaxDistanceRainy = 500; // meters static const int defaultMaxDistanceRainy = 500; // meters

View File

@@ -64,8 +64,17 @@ class RestaurantRepositoryImpl implements RestaurantRepository {
@override @override
Stream<List<Restaurant>> watchRestaurants() async* { Stream<List<Restaurant>> watchRestaurants() async* {
final box = await _box; final box = await _box;
yield box.values.toList(); final initial = box.values.toList();
yield* box.watch().map((_) => box.values.toList()); AppLogger.debug('[restaurant_repo] initial load count: ${initial.length}');
yield initial;
yield* box.watch().map((event) {
final values = box.values.toList();
AppLogger.debug(
'[restaurant_repo] box watch event -> count: ${values.length} '
'(key=${event.key}, deleted=${event.deleted})',
);
return values;
});
} }
@override @override

View File

@@ -17,7 +17,7 @@ class SettingsRepositoryImpl implements SettingsRepository {
static const String _keyCategoryWeights = 'category_weights'; static const String _keyCategoryWeights = 'category_weights';
// Default values // Default values
static const int _defaultDaysToExclude = 7; static const int _defaultDaysToExclude = 14;
static const int _defaultMaxDistanceRainy = 500; static const int _defaultMaxDistanceRainy = 500;
static const int _defaultMaxDistanceNormal = 1000; static const int _defaultMaxDistanceNormal = 1000;
static const int _defaultNotificationDelayMinutes = 90; static const int _defaultNotificationDelayMinutes = 90;

View File

@@ -39,6 +39,17 @@ class _MainScreenState extends ConsumerState<MainScreen> {
}); });
} }
@override
void didUpdateWidget(covariant MainScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initialTab != widget.initialTab &&
_selectedIndex != widget.initialTab) {
setState(() {
_selectedIndex = widget.initialTab;
});
}
}
@override @override
void dispose() { void dispose() {
NotificationService.onNotificationTap = null; NotificationService.onNotificationTap = null;

View File

@@ -1,7 +1,7 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:go_router/go_router.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';
@@ -9,12 +9,8 @@ import '../../../domain/entities/restaurant.dart';
import '../../../domain/entities/weather_info.dart'; import '../../../domain/entities/weather_info.dart';
import '../../providers/ad_provider.dart'; import '../../providers/ad_provider.dart';
import '../../providers/location_provider.dart'; import '../../providers/location_provider.dart';
import '../../providers/notification_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'
show notificationDelayMinutesProvider, notificationEnabledProvider;
import '../../providers/visit_provider.dart';
import '../../providers/weather_provider.dart'; import '../../providers/weather_provider.dart';
import 'widgets/recommendation_result_dialog.dart'; import 'widgets/recommendation_result_dialog.dart';
@@ -31,6 +27,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
final List<String> _selectedCategories = []; final List<String> _selectedCategories = [];
final List<String> _excludedRestaurantIds = []; final List<String> _excludedRestaurantIds = [];
bool _isProcessingRecommendation = false; bool _isProcessingRecommendation = false;
bool _hasUserAdjustedCategories = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -168,6 +165,60 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
// 카테고리 선택 카드
Card(
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('카테고리', style: AppTypography.heading2(isDark)),
const SizedBox(height: 12),
Consumer(
builder: (context, ref, child) {
final categoriesAsync = ref.watch(categoriesProvider);
return categoriesAsync.when(
data: (categories) {
_ensureDefaultCategorySelection(categories);
final selectedCategories =
_effectiveSelectedCategories(categories);
return Wrap(
spacing: 8,
runSpacing: 8,
children: categories.isEmpty
? [const Text('카테고리 없음')]
: categories
.map(
(category) => _buildCategoryChip(
category,
isDark,
selectedCategories.contains(
category,
),
categories,
),
)
.toList(),
);
},
loading: () => const CircularProgressIndicator(),
error: (_, __) => const Text('카테고리를 불러올 수 없습니다'),
);
},
),
],
),
),
),
const SizedBox(height: 16),
// 거리 설정 카드 // 거리 설정 카드
Card( Card(
color: isDark ? AppColors.darkSurface : AppColors.lightSurface, color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
@@ -222,6 +273,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
final restaurantsAsync = ref.watch( final restaurantsAsync = ref.watch(
restaurantListProvider, restaurantListProvider,
); );
final categoriesAsync = ref.watch(categoriesProvider);
final location = locationAsync.maybeWhen( final location = locationAsync.maybeWhen(
data: (pos) => pos, data: (pos) => pos,
@@ -231,12 +283,17 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
data: (list) => list, data: (list) => list,
orElse: () => null, orElse: () => null,
); );
final categories = categoriesAsync.maybeWhen(
data: (list) => list,
orElse: () => const <String>[],
);
if (location != null && restaurants != null) { if (location != null && restaurants != null) {
final count = _getRestaurantCountInRange( final count = _getRestaurantCountInRange(
restaurants, restaurants,
location, location,
_distanceValue, _distanceValue,
_effectiveSelectedCategories(categories),
); );
return Text( return Text(
'$count개 맛집 포함', '$count개 맛집 포함',
@@ -255,51 +312,6 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
), ),
), ),
const SizedBox(height: 16),
// 카테고리 선택 카드
Card(
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('카테고리', style: AppTypography.heading2(isDark)),
const SizedBox(height: 12),
Consumer(
builder: (context, ref, child) {
final categoriesAsync = ref.watch(categoriesProvider);
return categoriesAsync.when(
data: (categories) => Wrap(
spacing: 8,
runSpacing: 8,
children: categories.isEmpty
? [const Text('카테고리 없음')]
: categories
.map(
(category) => _buildCategoryChip(
category,
isDark,
),
)
.toList(),
),
loading: () => const CircularProgressIndicator(),
error: (_, __) => const Text('카테고리를 불러올 수 없습니다'),
);
},
),
],
),
),
),
const SizedBox(height: 24), const SizedBox(height: 24),
// 추천받기 버튼 // 추천받기 버튼
@@ -392,21 +404,17 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
); );
} }
Widget _buildCategoryChip(String category, bool isDark) { Widget _buildCategoryChip(
final isSelected = _selectedCategories.contains(category); String category,
bool isDark,
bool isSelected,
List<String> availableCategories,
) {
return FilterChip( return FilterChip(
label: Text(category), label: Text(category),
selected: isSelected, selected: isSelected,
onSelected: (selected) { onSelected: (selected) =>
setState(() { _handleCategoryToggle(category, selected, availableCategories),
if (selected) {
_selectedCategories.add(category);
} else {
_selectedCategories.remove(category);
}
});
},
backgroundColor: isDark backgroundColor: isDark
? AppColors.darkSurface ? AppColors.darkSurface
: AppColors.lightBackground, : AppColors.lightBackground,
@@ -425,10 +433,36 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
); );
} }
void _handleCategoryToggle(
String category,
bool isSelected,
List<String> availableCategories,
) {
final currentSelection = _effectiveSelectedCategories(availableCategories);
final nextSelection = currentSelection.toSet();
if (isSelected) {
nextSelection.add(category);
} else {
if (nextSelection.length <= 1 && nextSelection.contains(category)) {
return;
}
nextSelection.remove(category);
}
setState(() {
_hasUserAdjustedCategories = true;
_selectedCategories
..clear()
..addAll(nextSelection);
});
}
int _getRestaurantCountInRange( int _getRestaurantCountInRange(
List<Restaurant> restaurants, List<Restaurant> restaurants,
Position location, Position location,
double maxDistance, double maxDistance,
List<String> selectedCategories,
) { ) {
return restaurants.where((restaurant) { return restaurants.where((restaurant) {
final distance = Geolocator.distanceBetween( final distance = Geolocator.distanceBetween(
@@ -437,13 +471,20 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
restaurant.latitude, restaurant.latitude,
restaurant.longitude, restaurant.longitude,
); );
return distance <= maxDistance; final isWithinDistance = distance <= maxDistance;
final matchesCategory = selectedCategories.isEmpty
? true
: selectedCategories.contains(restaurant.category);
return isWithinDistance && matchesCategory;
}).length; }).length;
} }
bool _canRecommend() { bool _canRecommend() {
final locationAsync = ref.read(currentLocationWithFallbackProvider); final locationAsync = ref.read(currentLocationWithFallbackProvider);
final restaurantsAsync = ref.read(restaurantListProvider); final restaurantsAsync = ref.read(restaurantListProvider);
final categories = ref
.read(categoriesProvider)
.maybeWhen(data: (list) => list, orElse: () => const <String>[]);
final location = locationAsync.maybeWhen( final location = locationAsync.maybeWhen(
data: (pos) => pos, data: (pos) => pos,
@@ -462,6 +503,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
restaurants, restaurants,
location, location,
_distanceValue, _distanceValue,
_effectiveSelectedCategories(categories),
); );
return count > 0; return count > 0;
} }
@@ -518,10 +560,13 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
List<String> excludedRestaurantIds = const [], List<String> excludedRestaurantIds = const [],
}) async { }) async {
final notifier = ref.read(recommendationNotifierProvider.notifier); final notifier = ref.read(recommendationNotifierProvider.notifier);
final categories = ref
.read(categoriesProvider)
.maybeWhen(data: (list) => list, orElse: () => const <String>[]);
final recommendation = await notifier.getRandomRecommendation( final recommendation = await notifier.getRandomRecommendation(
maxDistance: _distanceValue, maxDistance: _distanceValue,
selectedCategories: _selectedCategories, selectedCategories: _effectiveSelectedCategories(categories),
excludedRestaurantIds: excludedRestaurantIds, excludedRestaurantIds: excludedRestaurantIds,
shouldSaveRecord: false, shouldSaveRecord: false,
); );
@@ -547,11 +592,18 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
Restaurant restaurant, { Restaurant restaurant, {
DateTime? recommendedAt, DateTime? recommendedAt,
}) async { }) async {
final location = ref
.read(currentLocationWithFallbackProvider)
.maybeWhen(data: (pos) => pos, orElse: () => null);
final result = await showDialog<RecommendationDialogResult>( final result = await showDialog<RecommendationDialogResult>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (dialogContext) => builder: (dialogContext) => RecommendationResultDialog(
RecommendationResultDialog(restaurant: restaurant), restaurant: restaurant,
currentLatitude: location?.latitude,
currentLongitude: location?.longitude,
),
); );
if (!mounted) return; if (!mounted) return;
@@ -561,7 +613,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
setState(() { setState(() {
_excludedRestaurantIds.add(restaurant.id); _excludedRestaurantIds.add(restaurant.id);
}); });
await _startRecommendation(skipAd: true, isReroll: true); await _startRecommendation(skipAd: false, isReroll: true);
break; break;
case RecommendationDialogResult.confirm: case RecommendationDialogResult.confirm:
case RecommendationDialogResult.autoConfirm: case RecommendationDialogResult.autoConfirm:
@@ -585,48 +637,9 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
await recommendationNotifier.saveRecommendationRecord( await recommendationNotifier.saveRecommendationRecord(
restaurant, restaurant,
recommendationTime: recommendationTime, recommendationTime: recommendationTime,
visited: false,
); );
final notificationEnabled = await ref.read(
notificationEnabledProvider.future,
);
bool notificationScheduled = false;
if (notificationEnabled && !kIsWeb) {
final notificationService = ref.read(notificationServiceProvider);
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
.read(visitNotifierProvider.notifier)
.createVisitFromRecommendation(
restaurantId: restaurant.id,
recommendationTime: recommendationTime,
);
if (notificationEnabled && !notificationScheduled && !kIsWeb) {
_showSnack(
'방문 기록은 저장됐지만 알림 권한이나 설정을 확인해 주세요. 방문 알림을 예약하지 못했습니다.',
type: _SnackType.warning,
);
} else {
_showSnack('맛있게 드세요! 🍴', type: _SnackType.success); _showSnack('맛있게 드세요! 🍴', type: _SnackType.success);
}
if (mounted) { if (mounted) {
setState(() { setState(() {
_excludedRestaurantIds.clear(); _excludedRestaurantIds.clear();
@@ -635,6 +648,10 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
} catch (_) { } catch (_) {
_showSnack('방문 기록 또는 알림 예약에 실패했습니다.', type: _SnackType.error); _showSnack('방문 기록 또는 알림 예약에 실패했습니다.', type: _SnackType.error);
} }
if (!mounted) return;
final ts = DateTime.now().millisecondsSinceEpoch;
context.go('/home?tab=calendar&ts=$ts');
} }
void _showSnack(String message, {_SnackType type = _SnackType.info}) { void _showSnack(String message, {_SnackType type = _SnackType.info}) {
@@ -663,6 +680,42 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
), ),
); );
} }
void _ensureDefaultCategorySelection(List<String> categories) {
if (_hasUserAdjustedCategories || categories.isEmpty) return;
final current = _selectedCategories.toSet();
final incoming = categories.toSet();
if (current.length == incoming.length && current.containsAll(incoming)) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() {
_selectedCategories
..clear()
..addAll(categories);
});
});
}
List<String> _effectiveSelectedCategories(List<String> availableCategories) {
if (_selectedCategories.isEmpty && !_hasUserAdjustedCategories) {
return availableCategories;
}
final availableSet = availableCategories.toSet();
final filtered = _selectedCategories
.where((category) => availableSet.contains(category))
.toList();
if (filtered.isEmpty) {
return availableCategories;
}
return filtered;
}
} }
enum _SnackType { info, warning, error, success } enum _SnackType { info, warning, error, success }

View File

@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lunchpick/core/constants/app_colors.dart'; import 'package:lunchpick/core/constants/app_colors.dart';
import 'package:lunchpick/core/constants/app_typography.dart'; import 'package:lunchpick/core/constants/app_typography.dart';
import 'package:lunchpick/core/utils/distance_calculator.dart';
import 'package:lunchpick/domain/entities/restaurant.dart'; import 'package:lunchpick/domain/entities/restaurant.dart';
enum RecommendationDialogResult { confirm, reroll, autoConfirm } enum RecommendationDialogResult { confirm, reroll, autoConfirm }
@@ -10,11 +11,15 @@ enum RecommendationDialogResult { confirm, reroll, autoConfirm }
class RecommendationResultDialog extends StatefulWidget { class RecommendationResultDialog extends StatefulWidget {
final Restaurant restaurant; final Restaurant restaurant;
final Duration autoConfirmDuration; final Duration autoConfirmDuration;
final double? currentLatitude;
final double? currentLongitude;
const RecommendationResultDialog({ const RecommendationResultDialog({
super.key, super.key,
required this.restaurant, required this.restaurant,
this.autoConfirmDuration = const Duration(seconds: 12), this.autoConfirmDuration = const Duration(seconds: 12),
this.currentLatitude,
this.currentLongitude,
}); });
@override @override
@@ -26,10 +31,12 @@ class _RecommendationResultDialogState
extends State<RecommendationResultDialog> { extends State<RecommendationResultDialog> {
Timer? _autoConfirmTimer; Timer? _autoConfirmTimer;
bool _didComplete = false; bool _didComplete = false;
double? _distanceKm;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_calculateDistance();
_startAutoConfirmTimer(); _startAutoConfirmTimer();
} }
@@ -47,6 +54,27 @@ class _RecommendationResultDialogState
}); });
} }
void _calculateDistance() {
final lat = widget.currentLatitude;
final lon = widget.currentLongitude;
if (lat == null || lon == null) return;
_distanceKm = DistanceCalculator.calculateDistance(
lat1: lat,
lon1: lon,
lat2: widget.restaurant.latitude,
lon2: widget.restaurant.longitude,
);
}
String _formatDistance(double distanceKm) {
final meters = distanceKm * 1000;
if (meters >= 1000) {
return '${distanceKm.toStringAsFixed(1)} km';
}
return '${meters.round()} m';
}
Future<void> _handleResult(RecommendationDialogResult result) async { Future<void> _handleResult(RecommendationDialogResult result) async {
if (_didComplete) return; if (_didComplete) return;
_didComplete = true; _didComplete = true;
@@ -177,6 +205,26 @@ class _RecommendationResultDialogState
), ),
], ],
), ),
if (_distanceKm != null) ...[
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.place,
size: 20,
color: AppColors.lightPrimary,
),
const SizedBox(width: 8),
Text(
_formatDistance(_distanceKm!),
style: AppTypography.body2(isDark).copyWith(
color: AppColors.lightPrimary,
fontWeight: FontWeight.w600,
),
),
],
),
],
if (widget.restaurant.phoneNumber != null) ...[ if (widget.restaurant.phoneNumber != null) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Row(
@@ -237,14 +285,14 @@ class _RecommendationResultDialogState
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
), ),
child: const Text('닫기'), child: const Text('오늘의 선택!'),
), ),
), ),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'조용히 두면 자동으로 방문 처리되고 알림이 예약됩니다.', '앱을 종료하면 자동으로 선택이 확정됩니다.',
style: AppTypography.caption(isDark), style: AppTypography.caption(isDark),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),

View File

@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lunchpick/domain/entities/restaurant.dart'; import 'package:lunchpick/domain/entities/restaurant.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 '../../../core/utils/app_logger.dart';
import '../../providers/restaurant_provider.dart'; import '../../providers/restaurant_provider.dart';
import '../../widgets/category_selector.dart'; import '../../widgets/category_selector.dart';
import 'manual_restaurant_input_screen.dart'; import 'manual_restaurant_input_screen.dart';
@@ -33,11 +34,9 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
final searchQuery = ref.watch(searchQueryProvider); final searchQuery = ref.watch(searchQueryProvider);
final selectedCategory = ref.watch(selectedCategoryProvider); final selectedCategory = ref.watch(selectedCategoryProvider);
final isFiltered = searchQuery.isNotEmpty || selectedCategory != null; final isFiltered = searchQuery.isNotEmpty || selectedCategory != null;
final restaurantsAsync = ref.watch( final restaurantsAsync = isFiltered
isFiltered ? ref.watch(filteredRestaurantsProvider)
? filteredRestaurantsProvider : ref.watch(sortedRestaurantsByDistanceProvider);
: sortedRestaurantsByDistanceProvider,
);
return Scaffold( return Scaffold(
backgroundColor: isDark backgroundColor: isDark
@@ -106,6 +105,9 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
Expanded( Expanded(
child: restaurantsAsync.when( child: restaurantsAsync.when(
data: (restaurantsData) { data: (restaurantsData) {
AppLogger.debug(
'[restaurant_list_ui] data received, filtered=$isFiltered',
);
final items = isFiltered final items = isFiltered
? (restaurantsData as List<Restaurant>) ? (restaurantsData as List<Restaurant>)
.map( .map(
@@ -132,9 +134,14 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
}, },
); );
}, },
loading: () => const Center( loading: () {
child: CircularProgressIndicator(color: AppColors.lightPrimary), AppLogger.debug('[restaurant_list_ui] loading...');
return const Center(
child: CircularProgressIndicator(
color: AppColors.lightPrimary,
), ),
);
},
error: (error, stack) => Center( error: (error, stack) => Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,

View File

@@ -72,7 +72,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
child: ListTile( child: ListTile(
title: const Text('중복 방문 제외 기간'), title: const Text('중복 방문 제외 기간'),
subtitle: Text('$_daysToExclude일 이내 방문한 곳은 추천에서 제외'), subtitle: Text('$_daysToExclude일 이내 방문한 곳은 추천에서 제외'),
trailing: Row( trailing: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
IconButton( IconButton(
@@ -118,6 +120,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
), ),
), ),
), ),
),
], isDark), ], isDark),
// 권한 설정 // 권한 설정
@@ -204,7 +207,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
enabled: _notificationEnabled, enabled: _notificationEnabled,
title: const Text('방문 확인 알림 시간'), title: const Text('방문 확인 알림 시간'),
subtitle: Text('추천 후 $_notificationMinutes분 뒤 알림'), subtitle: Text('추천 후 $_notificationMinutes분 뒤 알림'),
trailing: Row( trailing: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
IconButton( IconButton(
@@ -260,6 +265,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
), ),
), ),
), ),
),
], isDark), ], isDark),
// 테마 설정 // 테마 설정
@@ -310,30 +316,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
title: Text('버전'), title: Text('버전'),
subtitle: Text('1.0.0'), subtitle: Text('1.0.0'),
), ),
const Divider(height: 1),
const ListTile(
leading: Icon(
Icons.person_outline,
color: AppColors.lightPrimary,
),
title: Text('개발자'),
subtitle: Text('NatureBridgeAI'),
),
const Divider(height: 1),
ListTile(
leading: const Icon(
Icons.description_outlined,
color: AppColors.lightPrimary,
),
title: const Text('오픈소스 라이센스'),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: () => showLicensePage(
context: context,
applicationName: '오늘 뭐 먹Z?',
applicationVersion: '1.0.0',
applicationLegalese: '© 2025 NatureBridgeAI',
),
),
], ],
), ),
), ),

View File

@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:lunchpick/core/utils/app_logger.dart'; import 'package:lunchpick/core/utils/app_logger.dart';
@@ -69,13 +70,31 @@ final currentLocationProvider = FutureProvider<Position?>((ref) async {
}); });
/// 위치 스트림 Provider /// 위치 스트림 Provider
final locationStreamProvider = StreamProvider<Position>((ref) { final locationStreamProvider = StreamProvider<Position>((ref) async* {
return Geolocator.getPositionStream( if (kIsWeb) {
AppLogger.debug('[location] web detected, emit fallback immediately');
yield defaultPosition();
return;
}
final status = await Permission.location.status;
if (!status.isGranted) {
AppLogger.debug('[location] permission not granted, emit fallback');
yield defaultPosition();
return;
}
try {
yield* Geolocator.getPositionStream(
locationSettings: const LocationSettings( locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high, accuracy: LocationAccuracy.high,
distanceFilter: 10, // 10미터 이상 이동 시 업데이트 distanceFilter: 10, // 10미터 이상 이동 시 업데이트
), ),
); );
} catch (_) {
AppLogger.error('[location] position stream failed, emit fallback');
yield defaultPosition();
}
}); });
/// 초기 3초 내 위치를 가져오지 못하면 기본 좌표를 우선 반환하고, /// 초기 3초 내 위치를 가져오지 못하면 기본 좌표를 우선 반환하고,
@@ -83,20 +102,30 @@ final locationStreamProvider = StreamProvider<Position>((ref) {
final currentLocationWithFallbackProvider = StreamProvider<Position>(( final currentLocationWithFallbackProvider = StreamProvider<Position>((
ref, ref,
) async* { ) async* {
final initial = await Future.any([ AppLogger.debug('[location] emit fallback immediately (safe start)');
ref // 웹/권한 거부 상황에서는 즉시 기본 좌표를 먼저 흘려보내 리스트 로딩을 막는다.
.watch(currentLocationProvider.future) final fallback = defaultPosition();
.then((pos) => pos ?? defaultPosition()), yield fallback;
Future<Position>.delayed(
const Duration(seconds: 3),
() => defaultPosition(),
),
]).catchError((_) => defaultPosition());
final initial = await Future.any([
ref.watch(currentLocationProvider.future).then((pos) => pos ?? fallback),
Future<Position>.delayed(const Duration(seconds: 3), () => fallback),
]).catchError((_) => fallback);
if (initial.latitude != fallback.latitude ||
initial.longitude != fallback.longitude) {
AppLogger.debug(
'[location] resolved initial position: '
'${initial.latitude}, ${initial.longitude}',
);
yield initial; yield initial;
} else {
AppLogger.debug('[location] initial resolved to fallback');
}
yield* ref.watch(locationStreamProvider.stream).handleError((_) { yield* ref.watch(locationStreamProvider.stream).handleError((_) {
// 스트림 오류는 무시하고 마지막 위치를 유지 // 스트림 오류는 무시하고 마지막 위치를 유지
AppLogger.error('[location] stream error, keeping last position');
}); });
}); });

View File

@@ -140,6 +140,7 @@ class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
Future<RecommendationRecord> saveRecommendationRecord( Future<RecommendationRecord> saveRecommendationRecord(
Restaurant restaurant, { Restaurant restaurant, {
DateTime? recommendationTime, DateTime? recommendationTime,
bool visited = false,
}) async { }) async {
final now = DateTime.now(); final now = DateTime.now();
@@ -147,7 +148,7 @@ class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
id: const Uuid().v4(), id: const Uuid().v4(),
restaurantId: restaurant.id, restaurantId: restaurant.id,
recommendationDate: recommendationTime ?? now, recommendationDate: recommendationTime ?? now,
visited: false, visited: visited,
createdAt: now, createdAt: now,
); );
@@ -172,7 +173,11 @@ class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
await visitNotifier.createVisitFromRecommendation( await visitNotifier.createVisitFromRecommendation(
restaurantId: recommendation.restaurantId, restaurantId: recommendation.restaurantId,
recommendationTime: recommendation.recommendationDate, recommendationTime: recommendation.recommendationDate,
isConfirmed: true,
); );
// 방문 기록을 만들었으므로 추천 기록은 숨김 처리
await _repository.deleteRecommendationRecord(recommendationId);
} catch (e, stack) { } catch (e, stack) {
state = AsyncValue.error(e, stack); state = AsyncValue.error(e, stack);
} }

View File

@@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lunchpick/core/utils/category_mapper.dart'; import 'package:lunchpick/core/utils/category_mapper.dart';
import 'package:lunchpick/core/utils/app_logger.dart';
import 'package:lunchpick/domain/entities/restaurant.dart'; import 'package:lunchpick/domain/entities/restaurant.dart';
import 'package:lunchpick/domain/repositories/restaurant_repository.dart'; import 'package:lunchpick/domain/repositories/restaurant_repository.dart';
import 'package:lunchpick/presentation/providers/di_providers.dart'; import 'package:lunchpick/presentation/providers/di_providers.dart';
@@ -14,16 +15,28 @@ final restaurantListProvider = StreamProvider<List<Restaurant>>((ref) {
}); });
/// 거리 정보를 포함한 맛집 목록 Provider (현재 위치 기반) /// 거리 정보를 포함한 맛집 목록 Provider (현재 위치 기반)
/// StreamProvider 의존으로 초기 이벤트를 놓치는 문제를 피하기 위해
/// 기존 리스트 스트림의 AsyncValue를 그대로 전달하며 정렬만 적용한다.
final sortedRestaurantsByDistanceProvider = final sortedRestaurantsByDistanceProvider =
StreamProvider<List<({Restaurant restaurant, double? distanceKm})>>((ref) { Provider<AsyncValue<List<({Restaurant restaurant, double? distanceKm})>>>((
final restaurantsStream = ref.watch(restaurantListProvider.stream); ref,
final positionAsync = ref.watch(currentLocationProvider); ) {
final restaurantsAsync = ref.watch(restaurantListProvider);
final positionAsync = ref.watch(currentLocationWithFallbackProvider);
final position = positionAsync.maybeWhen( final position = positionAsync.maybeWhen(
data: (pos) => pos ?? defaultPosition(), data: (pos) => pos,
orElse: () => defaultPosition(), orElse: () => defaultPosition(),
); );
return restaurantsStream.map((restaurants) { AppLogger.debug(
'[restaurant_list] position ready for sorting: '
'${position.latitude}, ${position.longitude}',
);
return restaurantsAsync.whenData((restaurants) {
AppLogger.debug(
'[restaurant_list] incoming restaurants: ${restaurants.length}',
);
final sorted = final sorted =
restaurants.map<({Restaurant restaurant, double? distanceKm})>((r) { restaurants.map<({Restaurant restaurant, double? distanceKm})>((r) {
final distanceKm = DistanceCalculator.calculateDistance( final distanceKm = DistanceCalculator.calculateDistance(
@@ -38,6 +51,10 @@ final sortedRestaurantsByDistanceProvider =
b.distanceKm ?? double.infinity, b.distanceKm ?? double.infinity,
), ),
); );
AppLogger.debug(
'[restaurant_list] sorted list emitted, first distanceKm: '
'${sorted.isNotEmpty ? sorted.first.distanceKm?.toStringAsFixed(3) : 'none'}',
);
return sorted; return sorted;
}); });
}); });

View File

@@ -100,6 +100,7 @@ class VisitNotifier extends StateNotifier<AsyncValue<void>> {
Future<void> createVisitFromRecommendation({ Future<void> createVisitFromRecommendation({
required String restaurantId, required String restaurantId,
required DateTime recommendationTime, required DateTime recommendationTime,
bool isConfirmed = false,
}) async { }) async {
// 추천 시간으로부터 1.5시간 후를 방문 시간으로 설정 // 추천 시간으로부터 1.5시간 후를 방문 시간으로 설정
final visitTime = recommendationTime.add(const Duration(minutes: 90)); final visitTime = recommendationTime.add(const Duration(minutes: 90));
@@ -107,7 +108,7 @@ class VisitNotifier extends StateNotifier<AsyncValue<void>> {
await addVisitRecord( await addVisitRecord(
restaurantId: restaurantId, restaurantId: restaurantId,
visitDate: visitTime, visitDate: visitTime,
isConfirmed: false, // 나중에 확인 필요 isConfirmed: isConfirmed,
); );
} }
} }