diff --git a/flutter/README.md b/flutter/README.md index 0a75a1b..dc30539 100644 --- a/flutter/README.md +++ b/flutter/README.md @@ -60,5 +60,6 @@ lib/ - 한국어 문단은 `keepAll()` 을 거쳐 단어 단위로 줄바꿈한다(Flutter 기본은 글자 단위). - 카드·배지 대신 흰 바탕 위 평면 목록과 구분선, 색 글자로 메타 정보를 보인다. - 공공누리 Type3 사진은 자르지 않는다(`PublicImage` 가 contain 으로 보인다 — 행사·장소 사진 모두 이 위젯을 거친다). -- 영업시간을 모르는 장소는 "영업시간 미확인 · 방문 전 확인"으로 보이고, 확인 안 된 상가정보 매장은 "함께 가기 좋은 곳"에 고르지 않는다. +- 영업시간을 모르는 장소는 "영업시간 미확인 · 방문 전 확인"으로 보이고, 확인 안 된 상가정보 매장은 "함께 가기 좋은 곳"에 고르지 않는다 + (서버가 `trusted=true` 로 거른다 — 앱에서 거르면 상가가 빽빽한 도심에서 칸이 비었다). - 실패 문구는 에러 사전(`error_lexicon.g.dart`)에서만 가져온다 — `errorMessageOf()`. diff --git a/flutter/lib/features/place/data/repositories/place_repository.dart b/flutter/lib/features/place/data/repositories/place_repository.dart index aaf3070..4f413bf 100644 --- a/flutter/lib/features/place/data/repositories/place_repository.dart +++ b/flutter/lib/features/place/data/repositories/place_repository.dart @@ -15,10 +15,12 @@ class PlaceRepository { /// 장소 검색. [near] 를 주면 [radiusM] 안을 거리순으로, 없으면 이름순으로 준다. /// [categories] 가 비면 전체 분류, [petOnly] 면 반려동물 동반(전 구역·일부 구역) 장소만. + /// [trustedOnly] 면 관광공사 장소와 운영자가 확인한 장소만(확인 안 된 상가정보 매장·추천 안 함 제외). Future search({ String? query, List categories = const [], bool petOnly = false, + bool trustedOnly = false, GeoPoint? near, int? radiusM, int limit = 20, @@ -31,6 +33,7 @@ class PlaceRepository { if (categories.isNotEmpty) 'category': categories.map((c) => c.name).join(','), if (petOnly) 'pet': 'true', + if (trustedOnly) 'trusted': 'true', 'lat': ?near?.lat, 'lng': ?near?.lng, 'radius': ?radiusM, diff --git a/flutter/lib/features/place/presentation/providers/place_providers.dart b/flutter/lib/features/place/presentation/providers/place_providers.dart index 0b8da1a..6c710cd 100644 --- a/flutter/lib/features/place/presentation/providers/place_providers.dart +++ b/flutter/lib/features/place/presentation/providers/place_providers.dart @@ -18,9 +18,8 @@ const int kHomePetPlaceLimit = 3; const int kNearbyEventRadiusM = 5000; const int kNearbyEventLimit = 3; -/// 장소 상세 '함께 가기 좋은 곳' — 반경 안에서 받아 고른 뒤 이만큼 보인다 +/// 장소 상세 '함께 가기 좋은 곳' — 반경 안에서 이만큼 보인다 const int kNearbyPlaceRadiusM = 1500; -const int kNearbyPlaceFetchLimit = 30; const int kNearbyPlaceLimit = 6; /// 장소 상세 @@ -66,21 +65,20 @@ typedef NearbyPlaceKey = ({String id, double lat, double lng}); /// /// 확인 안 된 상가정보 매장은 고르지 않는다(관광공사 장소 또는 운영자가 확인한 장소만). /// 폐업·분위기를 검증할 수 없는 곳을 추천처럼 보이지 않으려는 것이다(2026-11-02 합의). +/// 서버가 거른다(`trusted`) — 앱이 가까운 몇 곳을 받아 거르면 도심처럼 상가가 빽빽한 곳에서는 +/// 받은 곳이 모두 상가라 칸이 비었다(2026-11-04 회귀 점검). 자기 자신이 섞여 오므로 하나 더 받는다. final nearbyPlacesProvider = FutureProvider.family, NearbyPlaceKey>((ref, key) async { final page = await ref .watch(placeRepositoryProvider) .search( + trustedOnly: true, near: (lat: key.lat, lng: key.lng), radiusM: kNearbyPlaceRadiusM, - limit: kNearbyPlaceFetchLimit, + limit: kNearbyPlaceLimit + 1, ); return page.items - .where( - (p) => - p.id != key.id && - (p.source == PlaceSource.tour || p.verifiedAt != null), - ) + .where((p) => p.id != key.id) .take(kNearbyPlaceLimit) .toList(); }); diff --git a/flutter/test/widget_test.dart b/flutter/test/widget_test.dart index 8cf3195..73d0f84 100644 --- a/flutter/test/widget_test.dart +++ b/flutter/test/widget_test.dart @@ -12,6 +12,7 @@ import 'package:wandergo/features/location/data/location_service.dart'; import 'package:wandergo/features/location/domain/base_location.dart'; import 'package:wandergo/features/place/data/models/place_models.dart'; import 'package:wandergo/features/place/data/repositories/place_repository.dart'; +import 'package:wandergo/features/place/presentation/providers/place_providers.dart'; /// 2026-11-04(수) 한국 정오 — 이번 주말은 11-07~11-08 final DateTime _now = DateTime.utc(2026, 11, 4, 3); @@ -113,7 +114,15 @@ class _FakePlaceRepository implements PlaceRepository { final List places; final List petPlaces; - final List<({String? query, List categories, bool petOnly})> + final List< + ({ + String? query, + List categories, + bool petOnly, + bool trustedOnly, + int limit, + }) + > calls = []; @override @@ -121,13 +130,29 @@ class _FakePlaceRepository implements PlaceRepository { String? query, List categories = const [], bool petOnly = false, + bool trustedOnly = false, GeoPoint? near, int? radiusM, int limit = 20, String? cursor, }) async { - calls.add((query: query, categories: categories, petOnly: petOnly)); - final items = petOnly ? petPlaces : places; + calls.add(( + query: query, + categories: categories, + petOnly: petOnly, + trustedOnly: trustedOnly, + limit: limit, + )); + // 서버처럼 trusted 면 관광공사·운영자 확인 장소만 주고, limit 만큼 자른다 + final items = (petOnly ? petPlaces : places) + .where( + (p) => + !trustedOnly || + p.source == PlaceSource.tour || + p.verifiedAt != null, + ) + .take(limit) + .toList(); return PlacePage(items: items, hasNext: false, total: items.length); } @@ -389,4 +414,33 @@ void main() { expect(find.text('Need help? Call 1330'), findsOneWidget); expect(_text('Hours not confirmed · check before you go'), findsOneWidget); }); + + // 2026-11-04 회귀 점검: 앱이 가까운 30곳을 받아 거르면 도심에서는 모두 상가라 칸이 비었다 + testWidgets('함께 가기 좋은 곳은 관광공사·확인 장소만 서버에 요청해, 상가가 더 가까워도 채워진다', ( + tester, + ) async { + await _useTallScreen(tester); + final alley = _place( + 'p-alley', + '대구근대골목', + category: PlaceCategory.attraction, + distanceM: 320, + ); + final places = _FakePlaceRepository( + places: [_storeCafe, _petPark, alley], + petPlaces: [_petPark], + ); + await _pumpApp(tester, prefs: {'onboarding_done': true}, places: places); + + await tester.tap(find.text('경상감영공원')); + await tester.pumpAndSettle(); + + final nearby = places.calls.where((c) => c.trustedOnly).toList(); + expect(nearby, hasLength(1)); + // 자기 자신이 섞여 오므로 보일 수보다 하나 더 받는다 + expect(nearby.single.limit, kNearbyPlaceLimit + 1); + expect(find.text('함께 가기 좋은 곳'), findsOneWidget); + expect(find.text('대구근대골목'), findsOneWidget); + expect(find.text('마실커피 동성로점'), findsNothing); + }); }