feat(app): stabilize recommendation flow
This commit is contained in:
@@ -30,7 +30,7 @@ class AppConstants {
|
||||
static const String storeSeedMetaAsset = 'assets/data/store_seed.meta.json';
|
||||
|
||||
// Default Settings
|
||||
static const int defaultDaysToExclude = 7;
|
||||
static const int defaultDaysToExclude = 14;
|
||||
static const int defaultNotificationMinutes = 90;
|
||||
static const int defaultMaxDistanceNormal = 1000; // meters
|
||||
static const int defaultMaxDistanceRainy = 500; // meters
|
||||
|
||||
@@ -64,8 +64,17 @@ class RestaurantRepositoryImpl implements RestaurantRepository {
|
||||
@override
|
||||
Stream<List<Restaurant>> watchRestaurants() async* {
|
||||
final box = await _box;
|
||||
yield box.values.toList();
|
||||
yield* box.watch().map((_) => box.values.toList());
|
||||
final initial = 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
|
||||
|
||||
@@ -17,7 +17,7 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
||||
static const String _keyCategoryWeights = 'category_weights';
|
||||
|
||||
// Default values
|
||||
static const int _defaultDaysToExclude = 7;
|
||||
static const int _defaultDaysToExclude = 14;
|
||||
static const int _defaultMaxDistanceRainy = 500;
|
||||
static const int _defaultMaxDistanceNormal = 1000;
|
||||
static const int _defaultNotificationDelayMinutes = 90;
|
||||
|
||||
@@ -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
|
||||
void dispose() {
|
||||
NotificationService.onNotificationTap = null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/constants/app_colors.dart';
|
||||
import '../../../core/constants/app_typography.dart';
|
||||
@@ -9,12 +9,8 @@ import '../../../domain/entities/restaurant.dart';
|
||||
import '../../../domain/entities/weather_info.dart';
|
||||
import '../../providers/ad_provider.dart';
|
||||
import '../../providers/location_provider.dart';
|
||||
import '../../providers/notification_provider.dart';
|
||||
import '../../providers/recommendation_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 'widgets/recommendation_result_dialog.dart';
|
||||
|
||||
@@ -31,6 +27,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
final List<String> _selectedCategories = [];
|
||||
final List<String> _excludedRestaurantIds = [];
|
||||
bool _isProcessingRecommendation = false;
|
||||
bool _hasUserAdjustedCategories = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -168,6 +165,60 @@ 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) {
|
||||
_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(
|
||||
color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
|
||||
@@ -222,6 +273,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
final restaurantsAsync = ref.watch(
|
||||
restaurantListProvider,
|
||||
);
|
||||
final categoriesAsync = ref.watch(categoriesProvider);
|
||||
|
||||
final location = locationAsync.maybeWhen(
|
||||
data: (pos) => pos,
|
||||
@@ -231,12 +283,17 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
data: (list) => list,
|
||||
orElse: () => null,
|
||||
);
|
||||
final categories = categoriesAsync.maybeWhen(
|
||||
data: (list) => list,
|
||||
orElse: () => const <String>[],
|
||||
);
|
||||
|
||||
if (location != null && restaurants != null) {
|
||||
final count = _getRestaurantCountInRange(
|
||||
restaurants,
|
||||
location,
|
||||
_distanceValue,
|
||||
_effectiveSelectedCategories(categories),
|
||||
);
|
||||
return Text(
|
||||
'$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),
|
||||
|
||||
// 추천받기 버튼
|
||||
@@ -392,21 +404,17 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategoryChip(String category, bool isDark) {
|
||||
final isSelected = _selectedCategories.contains(category);
|
||||
|
||||
Widget _buildCategoryChip(
|
||||
String category,
|
||||
bool isDark,
|
||||
bool isSelected,
|
||||
List<String> availableCategories,
|
||||
) {
|
||||
return FilterChip(
|
||||
label: Text(category),
|
||||
selected: isSelected,
|
||||
onSelected: (selected) {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
_selectedCategories.add(category);
|
||||
} else {
|
||||
_selectedCategories.remove(category);
|
||||
}
|
||||
});
|
||||
},
|
||||
onSelected: (selected) =>
|
||||
_handleCategoryToggle(category, selected, availableCategories),
|
||||
backgroundColor: isDark
|
||||
? AppColors.darkSurface
|
||||
: 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(
|
||||
List<Restaurant> restaurants,
|
||||
Position location,
|
||||
double maxDistance,
|
||||
List<String> selectedCategories,
|
||||
) {
|
||||
return restaurants.where((restaurant) {
|
||||
final distance = Geolocator.distanceBetween(
|
||||
@@ -437,13 +471,20 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
restaurant.latitude,
|
||||
restaurant.longitude,
|
||||
);
|
||||
return distance <= maxDistance;
|
||||
final isWithinDistance = distance <= maxDistance;
|
||||
final matchesCategory = selectedCategories.isEmpty
|
||||
? true
|
||||
: selectedCategories.contains(restaurant.category);
|
||||
return isWithinDistance && matchesCategory;
|
||||
}).length;
|
||||
}
|
||||
|
||||
bool _canRecommend() {
|
||||
final locationAsync = ref.read(currentLocationWithFallbackProvider);
|
||||
final restaurantsAsync = ref.read(restaurantListProvider);
|
||||
final categories = ref
|
||||
.read(categoriesProvider)
|
||||
.maybeWhen(data: (list) => list, orElse: () => const <String>[]);
|
||||
|
||||
final location = locationAsync.maybeWhen(
|
||||
data: (pos) => pos,
|
||||
@@ -462,6 +503,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
restaurants,
|
||||
location,
|
||||
_distanceValue,
|
||||
_effectiveSelectedCategories(categories),
|
||||
);
|
||||
return count > 0;
|
||||
}
|
||||
@@ -518,10 +560,13 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
List<String> excludedRestaurantIds = const [],
|
||||
}) async {
|
||||
final notifier = ref.read(recommendationNotifierProvider.notifier);
|
||||
final categories = ref
|
||||
.read(categoriesProvider)
|
||||
.maybeWhen(data: (list) => list, orElse: () => const <String>[]);
|
||||
|
||||
final recommendation = await notifier.getRandomRecommendation(
|
||||
maxDistance: _distanceValue,
|
||||
selectedCategories: _selectedCategories,
|
||||
selectedCategories: _effectiveSelectedCategories(categories),
|
||||
excludedRestaurantIds: excludedRestaurantIds,
|
||||
shouldSaveRecord: false,
|
||||
);
|
||||
@@ -547,11 +592,18 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
Restaurant restaurant, {
|
||||
DateTime? recommendedAt,
|
||||
}) async {
|
||||
final location = ref
|
||||
.read(currentLocationWithFallbackProvider)
|
||||
.maybeWhen(data: (pos) => pos, orElse: () => null);
|
||||
|
||||
final result = await showDialog<RecommendationDialogResult>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) =>
|
||||
RecommendationResultDialog(restaurant: restaurant),
|
||||
builder: (dialogContext) => RecommendationResultDialog(
|
||||
restaurant: restaurant,
|
||||
currentLatitude: location?.latitude,
|
||||
currentLongitude: location?.longitude,
|
||||
),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
@@ -561,7 +613,7 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
setState(() {
|
||||
_excludedRestaurantIds.add(restaurant.id);
|
||||
});
|
||||
await _startRecommendation(skipAd: true, isReroll: true);
|
||||
await _startRecommendation(skipAd: false, isReroll: true);
|
||||
break;
|
||||
case RecommendationDialogResult.confirm:
|
||||
case RecommendationDialogResult.autoConfirm:
|
||||
@@ -585,48 +637,9 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
await recommendationNotifier.saveRecommendationRecord(
|
||||
restaurant,
|
||||
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);
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_excludedRestaurantIds.clear();
|
||||
@@ -635,6 +648,10 @@ class _RandomSelectionScreenState extends ConsumerState<RandomSelectionScreen> {
|
||||
} catch (_) {
|
||||
_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}) {
|
||||
@@ -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 }
|
||||
|
||||
@@ -3,6 +3,7 @@ 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/core/utils/distance_calculator.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
|
||||
enum RecommendationDialogResult { confirm, reroll, autoConfirm }
|
||||
@@ -10,11 +11,15 @@ enum RecommendationDialogResult { confirm, reroll, autoConfirm }
|
||||
class RecommendationResultDialog extends StatefulWidget {
|
||||
final Restaurant restaurant;
|
||||
final Duration autoConfirmDuration;
|
||||
final double? currentLatitude;
|
||||
final double? currentLongitude;
|
||||
|
||||
const RecommendationResultDialog({
|
||||
super.key,
|
||||
required this.restaurant,
|
||||
this.autoConfirmDuration = const Duration(seconds: 12),
|
||||
this.currentLatitude,
|
||||
this.currentLongitude,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -26,10 +31,12 @@ class _RecommendationResultDialogState
|
||||
extends State<RecommendationResultDialog> {
|
||||
Timer? _autoConfirmTimer;
|
||||
bool _didComplete = false;
|
||||
double? _distanceKm;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_calculateDistance();
|
||||
_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 {
|
||||
if (_didComplete) return;
|
||||
_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) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
@@ -237,14 +285,14 @@ class _RecommendationResultDialogState
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text('닫기'),
|
||||
child: const Text('오늘의 선택!'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'조용히 두면 자동으로 방문 처리되고 알림이 예약됩니다.',
|
||||
'앱을 종료하면 자동으로 선택이 확정됩니다.',
|
||||
style: AppTypography.caption(isDark),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
import '../../../core/constants/app_colors.dart';
|
||||
import '../../../core/constants/app_typography.dart';
|
||||
import '../../../core/utils/app_logger.dart';
|
||||
import '../../providers/restaurant_provider.dart';
|
||||
import '../../widgets/category_selector.dart';
|
||||
import 'manual_restaurant_input_screen.dart';
|
||||
@@ -33,11 +34,9 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
||||
final searchQuery = ref.watch(searchQueryProvider);
|
||||
final selectedCategory = ref.watch(selectedCategoryProvider);
|
||||
final isFiltered = searchQuery.isNotEmpty || selectedCategory != null;
|
||||
final restaurantsAsync = ref.watch(
|
||||
isFiltered
|
||||
? filteredRestaurantsProvider
|
||||
: sortedRestaurantsByDistanceProvider,
|
||||
);
|
||||
final restaurantsAsync = isFiltered
|
||||
? ref.watch(filteredRestaurantsProvider)
|
||||
: ref.watch(sortedRestaurantsByDistanceProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: isDark
|
||||
@@ -106,6 +105,9 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
||||
Expanded(
|
||||
child: restaurantsAsync.when(
|
||||
data: (restaurantsData) {
|
||||
AppLogger.debug(
|
||||
'[restaurant_list_ui] data received, filtered=$isFiltered',
|
||||
);
|
||||
final items = isFiltered
|
||||
? (restaurantsData as List<Restaurant>)
|
||||
.map(
|
||||
@@ -132,9 +134,14 @@ class _RestaurantListScreenState extends ConsumerState<RestaurantListScreen> {
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.lightPrimary),
|
||||
loading: () {
|
||||
AppLogger.debug('[restaurant_list_ui] loading...');
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.lightPrimary,
|
||||
),
|
||||
);
|
||||
},
|
||||
error: (error, stack) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
|
||||
@@ -72,7 +72,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
child: ListTile(
|
||||
title: const Text('중복 방문 제외 기간'),
|
||||
subtitle: Text('$_daysToExclude일 이내 방문한 곳은 추천에서 제외'),
|
||||
trailing: Row(
|
||||
trailing: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
@@ -118,6 +120,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
], isDark),
|
||||
|
||||
// 권한 설정
|
||||
@@ -204,7 +207,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
enabled: _notificationEnabled,
|
||||
title: const Text('방문 확인 알림 시간'),
|
||||
subtitle: Text('추천 후 $_notificationMinutes분 뒤 알림'),
|
||||
trailing: Row(
|
||||
trailing: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
@@ -260,6 +265,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
], isDark),
|
||||
|
||||
// 테마 설정
|
||||
@@ -310,30 +316,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
title: Text('버전'),
|
||||
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',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:lunchpick/core/utils/app_logger.dart';
|
||||
@@ -69,13 +70,31 @@ final currentLocationProvider = FutureProvider<Position?>((ref) async {
|
||||
});
|
||||
|
||||
/// 위치 스트림 Provider
|
||||
final locationStreamProvider = StreamProvider<Position>((ref) {
|
||||
return Geolocator.getPositionStream(
|
||||
final locationStreamProvider = StreamProvider<Position>((ref) async* {
|
||||
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(
|
||||
accuracy: LocationAccuracy.high,
|
||||
distanceFilter: 10, // 10미터 이상 이동 시 업데이트
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
AppLogger.error('[location] position stream failed, emit fallback');
|
||||
yield defaultPosition();
|
||||
}
|
||||
});
|
||||
|
||||
/// 초기 3초 내 위치를 가져오지 못하면 기본 좌표를 우선 반환하고,
|
||||
@@ -83,20 +102,30 @@ final locationStreamProvider = StreamProvider<Position>((ref) {
|
||||
final currentLocationWithFallbackProvider = StreamProvider<Position>((
|
||||
ref,
|
||||
) async* {
|
||||
final initial = await Future.any([
|
||||
ref
|
||||
.watch(currentLocationProvider.future)
|
||||
.then((pos) => pos ?? defaultPosition()),
|
||||
Future<Position>.delayed(
|
||||
const Duration(seconds: 3),
|
||||
() => defaultPosition(),
|
||||
),
|
||||
]).catchError((_) => defaultPosition());
|
||||
AppLogger.debug('[location] emit fallback immediately (safe start)');
|
||||
// 웹/권한 거부 상황에서는 즉시 기본 좌표를 먼저 흘려보내 리스트 로딩을 막는다.
|
||||
final fallback = defaultPosition();
|
||||
yield fallback;
|
||||
|
||||
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;
|
||||
} else {
|
||||
AppLogger.debug('[location] initial resolved to fallback');
|
||||
}
|
||||
|
||||
yield* ref.watch(locationStreamProvider.stream).handleError((_) {
|
||||
// 스트림 오류는 무시하고 마지막 위치를 유지
|
||||
AppLogger.error('[location] stream error, keeping last position');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -140,6 +140,7 @@ class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
|
||||
Future<RecommendationRecord> saveRecommendationRecord(
|
||||
Restaurant restaurant, {
|
||||
DateTime? recommendationTime,
|
||||
bool visited = false,
|
||||
}) async {
|
||||
final now = DateTime.now();
|
||||
|
||||
@@ -147,7 +148,7 @@ class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
|
||||
id: const Uuid().v4(),
|
||||
restaurantId: restaurant.id,
|
||||
recommendationDate: recommendationTime ?? now,
|
||||
visited: false,
|
||||
visited: visited,
|
||||
createdAt: now,
|
||||
);
|
||||
|
||||
@@ -172,7 +173,11 @@ class RecommendationNotifier extends StateNotifier<AsyncValue<Restaurant?>> {
|
||||
await visitNotifier.createVisitFromRecommendation(
|
||||
restaurantId: recommendation.restaurantId,
|
||||
recommendationTime: recommendation.recommendationDate,
|
||||
isConfirmed: true,
|
||||
);
|
||||
|
||||
// 방문 기록을 만들었으므로 추천 기록은 숨김 처리
|
||||
await _repository.deleteRecommendationRecord(recommendationId);
|
||||
} catch (e, stack) {
|
||||
state = AsyncValue.error(e, stack);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.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/repositories/restaurant_repository.dart';
|
||||
import 'package:lunchpick/presentation/providers/di_providers.dart';
|
||||
@@ -14,16 +15,28 @@ final restaurantListProvider = StreamProvider<List<Restaurant>>((ref) {
|
||||
});
|
||||
|
||||
/// 거리 정보를 포함한 맛집 목록 Provider (현재 위치 기반)
|
||||
/// StreamProvider 의존으로 초기 이벤트를 놓치는 문제를 피하기 위해
|
||||
/// 기존 리스트 스트림의 AsyncValue를 그대로 전달하며 정렬만 적용한다.
|
||||
final sortedRestaurantsByDistanceProvider =
|
||||
StreamProvider<List<({Restaurant restaurant, double? distanceKm})>>((ref) {
|
||||
final restaurantsStream = ref.watch(restaurantListProvider.stream);
|
||||
final positionAsync = ref.watch(currentLocationProvider);
|
||||
Provider<AsyncValue<List<({Restaurant restaurant, double? distanceKm})>>>((
|
||||
ref,
|
||||
) {
|
||||
final restaurantsAsync = ref.watch(restaurantListProvider);
|
||||
final positionAsync = ref.watch(currentLocationWithFallbackProvider);
|
||||
final position = positionAsync.maybeWhen(
|
||||
data: (pos) => pos ?? defaultPosition(),
|
||||
data: (pos) => pos,
|
||||
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 =
|
||||
restaurants.map<({Restaurant restaurant, double? distanceKm})>((r) {
|
||||
final distanceKm = DistanceCalculator.calculateDistance(
|
||||
@@ -38,6 +51,10 @@ final sortedRestaurantsByDistanceProvider =
|
||||
b.distanceKm ?? double.infinity,
|
||||
),
|
||||
);
|
||||
AppLogger.debug(
|
||||
'[restaurant_list] sorted list emitted, first distanceKm: '
|
||||
'${sorted.isNotEmpty ? sorted.first.distanceKm?.toStringAsFixed(3) : 'none'}',
|
||||
);
|
||||
return sorted;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,6 +100,7 @@ class VisitNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
Future<void> createVisitFromRecommendation({
|
||||
required String restaurantId,
|
||||
required DateTime recommendationTime,
|
||||
bool isConfirmed = false,
|
||||
}) async {
|
||||
// 추천 시간으로부터 1.5시간 후를 방문 시간으로 설정
|
||||
final visitTime = recommendationTime.add(const Duration(minutes: 90));
|
||||
@@ -107,7 +108,7 @@ class VisitNotifier extends StateNotifier<AsyncValue<void>> {
|
||||
await addVisitRecord(
|
||||
restaurantId: restaurantId,
|
||||
visitDate: visitTime,
|
||||
isConfirmed: false, // 나중에 확인 필요
|
||||
isConfirmed: isConfirmed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user