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';
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user