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

@@ -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(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: 10, // 10미터 이상 이동 시 업데이트
),
);
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;
yield initial;
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');
});
});

View File

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

View File

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

View File

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