fix(flutter): 대구 밖 현재 위치는 대구 도심으로 대신하고 안내함
CI (Flutter) / Flutter (analyze · test) (push) Successful in 20m41s
CI / Backend (lint · test · e2e · build) (push) Successful in 12m16s
CI / Frontend (lint · type-check · test) (push) Successful in 11m4s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 11m8s

현재 위치를 그대로 쓰면 대구 밖(서울 등)에서는 모든 칸이 "근처에 아직 없어요"로 비었다(2026-11-04 회귀 점검).
서비스 지역(대구, 군위 포함 — 백엔드 geo.ts 와 같은 영역) 밖이면 대구 도심을 기준으로 쓰고,
홈에서 "아직 대구 지역만 볼 수 있어요"를 한 번 알린다.
위치를 받지 못해 대신하는 경우도 지금까지는 기준만 조용히 바뀌었는데, 같은 방식으로 알린다.
대신한 이유는 BasePoint.fallback 에 남는다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #7.
This commit is contained in:
2026-11-05 13:38:11 +09:00
parent 90d41f0e6d
commit 43e339a95f
8 changed files with 107 additions and 14 deletions
+1
View File
@@ -65,4 +65,5 @@ lib/
(서버가 `trusted=true` 로 거른다 — 앱에서 거르면 상가가 빽빽한 도심에서 칸이 비었다).
- `Semantics(excludeSemantics: true)` 로 버튼을 감싸면 안쪽 InkWell 의 누르기 동작까지 지워진다. Semantics 에도 `onTap` 을 같이 준다
(빠뜨리면 TalkBack·VoiceOver 로 누를 수 없다 — `widget_test.dart` 의 화면 읽기 테스트가 잡는다).
- 현재 위치를 받지 못하거나 서비스 지역(대구, `kServiceArea`) 밖이면 대구 도심을 기준으로 쓰고 홈에서 이유를 한 번 알린다(`BasePoint.fallback`).
- 실패 문구는 에러 사전(`error_lexicon.g.dart`)에서만 가져온다 — `errorMessageOf()`.
@@ -44,6 +44,20 @@ class HomePage extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final l10n = context.l10n;
final textTheme = Theme.of(context).textTheme;
// 현재 위치 대신 대구 도심을 쓰게 되면 이유를 한 번 알린다(같은 이유로 다시 받을 때는 띄우지 않는다)
ref.listen(basePointProvider.select((base) => base.value?.fallback), (
previous,
next,
) {
if (next == null || next == previous) return;
final message = switch (next) {
BaseFallback.unavailable => l10n.locationUnavailable,
BaseFallback.outsideServiceArea => l10n.locationOutsideServiceArea,
};
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
});
return Scaffold(
body: SafeArea(
bottom: false,
@@ -55,17 +55,37 @@ class BaseLocationChoice {
int get hashCode => neighborhood.hashCode;
}
/// 서비스 지역(1차 출시: 대구) 대략 영역 — 2023년 편입한 군위군 포함.
/// 백엔드 `shared/utils/geo.ts` 의 시·도 영역(27 대구)과 같은 값이다.
const ({double latMin, double latMax, double lngMin, double lngMax})
kServiceArea = (latMin: 35.55, latMax: 36.36, lngMin: 128.3, lngMax: 128.9);
/// 좌표가 서비스 지역 안인지 본다. 밖에서는 볼 장소·행사가 거의 없다
bool isInServiceArea(GeoPoint p) =>
p.lat >= kServiceArea.latMin &&
p.lat <= kServiceArea.latMax &&
p.lng >= kServiceArea.lngMin &&
p.lng <= kServiceArea.lngMax;
/// 현재 위치 대신 [kFallbackNeighborhood] 를 쓴 이유
enum BaseFallback {
/// 위치를 받지 못했다(권한 없음·위치 서비스 꺼짐·시간 초과)
unavailable,
/// 서비스 지역(대구) 밖이다 — 그대로 쓰면 모든 칸이 "근처에 아직 없어요"로 빈다
outsideServiceArea,
}
/// 실제로 조회에 쓰는 기준 좌표
class BasePoint {
const BasePoint({
required this.point,
required this.choice,
this.fellBack = false,
});
const BasePoint({required this.point, required this.choice, this.fallback});
final GeoPoint point;
final BaseLocationChoice choice;
/// 현재 위치를 고르고도 위치를 못 받아 [kFallbackNeighborhood] 를 쓴 경우
final bool fellBack;
/// 현재 위치를 고르고도 [kFallbackNeighborhood] 를 쓴 이유. 대신하지 않았으면 null
final BaseFallback? fallback;
/// 현재 위치를 고르고도 [kFallbackNeighborhood] 를 쓴 경우
bool get fellBack => fallback != null;
}
@@ -28,7 +28,8 @@ class BaseLocationNotifier extends Notifier<BaseLocationChoice> {
}
}
/// 조회에 쓰는 기준 좌표 — 현재 위치를 골랐는데 받지 못하면 [kFallbackNeighborhood] 로 대신한다
/// 조회에 쓰는 기준 좌표 — 현재 위치를 골랐는데 받지 못하거나 서비스 지역(대구) 밖이면
/// [kFallbackNeighborhood] 로 대신하고 이유를 남긴다(홈이 안내한다)
final basePointProvider = FutureProvider<BasePoint>((ref) async {
final choice = ref.watch(baseLocationProvider);
final neighborhood = choice.neighborhood;
@@ -36,10 +37,15 @@ final basePointProvider = FutureProvider<BasePoint>((ref) async {
return BasePoint(point: neighborhood.point, choice: choice);
}
final current = await ref.watch(locationServiceProvider).currentPoint();
if (current != null) return BasePoint(point: current, choice: choice);
final BaseFallback? fallback = current == null
? BaseFallback.unavailable
: (isInServiceArea(current) ? null : BaseFallback.outsideServiceArea);
if (current != null && fallback == null) {
return BasePoint(point: current, choice: choice);
}
return BasePoint(
point: kFallbackNeighborhood.point,
choice: choice,
fellBack: true,
fallback: fallback,
);
});
+1
View File
@@ -24,6 +24,7 @@
"regionApsan": "Apsan Cafe Street",
"regionApsanHint": "Daemyeong-dong, Nam-gu",
"locationUnavailable": "We couldn't get your location, so we're showing downtown Daegu.",
"locationOutsideServiceArea": "WanderGo only covers Daegu for now, so we're showing downtown Daegu.",
"baseLocationButtonLabel": "Change base location, current {place}",
"homeTitle": "Where to this weekend?",
"homeWeekendEvents": "Festivals & events this weekend",
+1
View File
@@ -35,6 +35,7 @@
"regionApsan": "앞산 카페거리",
"regionApsanHint": "남구 대명동",
"locationUnavailable": "현재 위치를 가져오지 못해 대구 도심을 기준으로 보여 드려요.",
"locationOutsideServiceArea": "아직 대구 지역만 볼 수 있어요. 대구 도심을 기준으로 보여 드려요.",
"baseLocationButtonLabel": "기준 위치 변경, 현재 {place}",
"@baseLocationButtonLabel": {
"placeholders": {
@@ -0,0 +1,20 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:wandergo/features/location/domain/base_location.dart';
void main() {
group('isInServiceArea', () {
test('대구 동네·군위군·경산 경계는 서비스 지역 안이다', () {
for (final n in Neighborhood.values) {
expect(isInServiceArea(n.point), isTrue, reason: n.name);
}
expect(isInServiceArea((lat: 36.2427, lng: 128.5728)), isTrue); // 군위군청
expect(isInServiceArea((lat: 35.8251, lng: 128.7415)), isTrue); // 경산시청
});
test('서울·부산·포항은 서비스 지역 밖이다', () {
expect(isInServiceArea((lat: 37.5663, lng: 126.9779)), isFalse);
expect(isInServiceArea((lat: 35.1796, lng: 129.0756)), isFalse);
expect(isInServiceArea((lat: 36.0190, lng: 129.3435)), isFalse);
});
});
}
+34 -4
View File
@@ -179,16 +179,21 @@ class _FakePlaceRepository implements PlaceRepository {
}
class _FakeLocationService implements LocationService {
const _FakeLocationService({this.granted = true});
const _FakeLocationService({
this.granted = true,
this.point = (lat: 35.85, lng: 128.6),
});
final bool granted;
/// 권한이 있을 때 돌려줄 현재 위치(기본은 대구 수성구)
final GeoPoint point;
@override
Future<bool> requestPermission() async => granted;
@override
Future<GeoPoint?> currentPoint() async =>
granted ? (lat: 35.85, lng: 128.6) : null;
Future<GeoPoint?> currentPoint() async => granted ? point : null;
}
Future<_FakeEventRepository> _pumpApp(
@@ -197,6 +202,7 @@ Future<_FakeEventRepository> _pumpApp(
_FakeEventRepository? repo,
_FakePlaceRepository? places,
bool locationGranted = true,
GeoPoint currentPoint = (lat: 35.85, lng: 128.6),
}) async {
SharedPreferences.setMockInitialValues({'app_locale': 'ko', ...prefs});
final preferences = await SharedPreferences.getInstance();
@@ -210,7 +216,7 @@ Future<_FakeEventRepository> _pumpApp(
places ?? _FakePlaceRepository(),
),
locationServiceProvider.overrideWithValue(
_FakeLocationService(granted: locationGranted),
_FakeLocationService(granted: locationGranted, point: currentPoint),
),
clockProvider.overrideWithValue(() => _now),
],
@@ -475,4 +481,28 @@ void main() {
);
semantics.dispose();
});
// 2026-11-04 회귀 점검: 대구 밖 좌표를 그대로 써서 모든 칸이 "근처에 아직 없어요"로 비었다
testWidgets('대구 밖에서 현재 위치를 고르면 대구 도심을 기준으로 보이고 안내한다', (tester) async {
final events = await _pumpApp(
tester,
prefs: {'onboarding_done': true, 'base_location': 'current'},
currentPoint: (lat: 37.5663, lng: 126.9779), // 서울시청
);
expect(find.text('아직 대구 지역만 볼 수 있어요. 대구 도심을 기준으로 보여 드려요.'), findsOneWidget);
expect(find.text('대구 도심'), findsOneWidget);
expect(events.calls.last.near, Neighborhood.downtown.point);
});
testWidgets('현재 위치를 받지 못하면 대구 도심을 기준으로 보이고 안내한다', (tester) async {
final events = await _pumpApp(
tester,
prefs: {'onboarding_done': true, 'base_location': 'current'},
locationGranted: false,
);
expect(find.text('현재 위치를 가져오지 못해 대구 도심을 기준으로 보여 드려요.'), findsOneWidget);
expect(events.calls.last.near, Neighborhood.downtown.point);
});
}