diff --git a/.claude/skills/flutter/SKILL.md b/.claude/skills/flutter/SKILL.md index ad7b124..40efbf8 100644 --- a/.claude/skills/flutter/SKILL.md +++ b/.claude/skills/flutter/SKILL.md @@ -29,7 +29,7 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S | 테마·색 | `lib/core/theme/` | `AppColors` · `AppTheme` — 색·글자 크기를 화면에 새로 하드코딩하지 않는다 | | 한/영 문구 | `lib/l10n/*.arb` | `context.l10n.키`. 생성물 `lib/l10n/generated/` 는 git 제외 — `flutter pub get` 이 만든다 | | 한국어 줄바꿈 | `lib/core/utils/keep_all.dart` | 한국어 문단은 `keepAll()` 로 단어 단위 줄바꿈 (Flutter 기본은 글자 단위) | -| 공공데이터 사진 | `lib/shared/widgets/public_image.dart` | 공공누리 Type3 은 자르지 않는다. 사진이 없으면 분류별 자리 표시. 출처 모델은 `shared/models/source_models.dart` | +| 공공데이터 사진 | `lib/shared/widgets/public_image.dart` | 공공누리 Type3 은 자르지 않는다. 사진이 없으면 분류별 자리 표시. 그릴 크기로 줄여 푼다(`memCacheWidth`). 출처 모델은 `shared/models/source_models.dart` | > **확장 시 따르는 규칙** (아래 "프로젝트 구조"는 목표 구조 — 기능 추가 시 점진적으로 채운다): > - **상태관리**: `features/user` 의 model→repository→provider 패턴을 복제. 위젯에서 `ref.watch(userProvider(id))` diff --git a/flutter/README.md b/flutter/README.md index 0a75a1b..2d6b597 100644 --- a/flutter/README.md +++ b/flutter/README.md @@ -60,5 +60,9 @@ lib/ - 한국어 문단은 `keepAll()` 을 거쳐 단어 단위로 줄바꿈한다(Flutter 기본은 글자 단위). - 카드·배지 대신 흰 바탕 위 평면 목록과 구분선, 색 글자로 메타 정보를 보인다. - 공공누리 Type3 사진은 자르지 않는다(`PublicImage` 가 contain 으로 보인다 — 행사·장소 사진 모두 이 위젯을 거친다). -- 영업시간을 모르는 장소는 "영업시간 미확인 · 방문 전 확인"으로 보이고, 확인 안 된 상가정보 매장은 "함께 가기 좋은 곳"에 고르지 않는다. +- 사진은 화면에 그릴 크기로 줄여 푼다(`PublicImage` → `decodeWidthFor`). 공연 포스터는 목록 썸네일도 원본(최대 9MB)이라 그대로 풀면 메모리를 많이 쓴다. +- 영업시간을 모르는 장소는 "영업시간 미확인 · 방문 전 확인"으로 보이고, 확인 안 된 상가정보 매장은 "함께 가기 좋은 곳"에 고르지 않는다 + (서버가 `trusted=true` 로 거른다 — 앱에서 거르면 상가가 빽빽한 도심에서 칸이 비었다). +- `Semantics(excludeSemantics: true)` 로 버튼을 감싸면 안쪽 InkWell 의 누르기 동작까지 지워진다. Semantics 에도 `onTap` 을 같이 준다 + (빠뜨리면 TalkBack·VoiceOver 로 누를 수 없다 — `widget_test.dart` 의 화면 읽기 테스트가 잡는다). - 실패 문구는 에러 사전(`error_lexicon.g.dart`)에서만 가져온다 — `errorMessageOf()`. diff --git a/flutter/lib/core/router/app_shell.dart b/flutter/lib/core/router/app_shell.dart index ced0544..8bdda1f 100644 --- a/flutter/lib/core/router/app_shell.dart +++ b/flutter/lib/core/router/app_shell.dart @@ -70,11 +70,13 @@ class _TabItem extends StatelessWidget { @override Widget build(BuildContext context) { final color = selected ? AppColors.brand : AppColors.textTertiary; + // excludeSemantics 는 아래 InkResponse 의 누르기 동작까지 지운다 — 화면 읽기 프로그램이 누를 수 있게 onTap 을 함께 준다 return Semantics( button: true, selected: selected, label: label, excludeSemantics: true, + onTap: onTap, child: InkResponse( onTap: onTap, radius: 36, diff --git a/flutter/lib/core/utils/image_decode.dart b/flutter/lib/core/utils/image_decode.dart new file mode 100644 index 0000000..dde4e2a --- /dev/null +++ b/flutter/lib/core/utils/image_decode.dart @@ -0,0 +1,35 @@ +import 'dart:math' as math; + +import 'package:flutter/painting.dart'; + +/// cover 로 채울 때 가로가 긴 사진(최대 2:1)도 세로를 채우도록 세로 길이의 이만큼까지 가로를 둔다 +const double kCoverAspectAllowance = 2; + +/// 사진을 화면에 그릴 크기에 맞춰 줄여 풀 가로 픽셀 수 (순수 함수). +/// +/// 공연 포스터는 목록 썸네일도 원본(수 MB, 수천 픽셀)이라 그대로 풀면 사진 한 장에 수십 MB 를 쓴다. +/// 가로만 정하면 비율은 유지되고, 원본보다 크게 늘리지는 않는다. +/// - contain: 상자 가로만큼이면 된다 +/// - cover: 가로가 긴 사진도 세로를 채워야 하므로 max(가로, 세로 × [kCoverAspectAllowance]) +/// 크기를 모르면 null(원본 그대로 푼다). +int? decodeWidthFor({ + required double? boxWidth, + required double? boxHeight, + required BoxFit fit, + required double devicePixelRatio, +}) { + final width = boxWidth != null && boxWidth.isFinite && boxWidth > 0 + ? boxWidth + : null; + final height = boxHeight != null && boxHeight.isFinite && boxHeight > 0 + ? boxHeight + : null; + final logical = switch ((width, height)) { + (final w?, final h?) => + fit == BoxFit.cover ? math.max(w, h * kCoverAspectAllowance) : w, + (final w?, null) => w, + (null, final h?) => h * kCoverAspectAllowance, + (null, null) => null, + }; + return logical == null ? null : (logical * devicePixelRatio).ceil(); +} diff --git a/flutter/lib/features/home/presentation/pages/home_page.dart b/flutter/lib/features/home/presentation/pages/home_page.dart index 500e02c..f7c9250 100644 --- a/flutter/lib/features/home/presentation/pages/home_page.dart +++ b/flutter/lib/features/home/presentation/pages/home_page.dart @@ -132,17 +132,21 @@ class _BaseLocationButton extends ConsumerWidget { final label = fellBack ? kFallbackNeighborhood.label(l10n) : choice.label(l10n); + Future pickBase() async { + final picked = await showRegionSheet(context, selected: choice); + if (picked != null && context.mounted) { + await applyBaseLocation(context, ref, picked); + } + } + + // excludeSemantics 는 InkWell 의 누르기 동작까지 지운다 — 화면 읽기 프로그램이 누를 수 있게 onTap 을 함께 준다 return Semantics( button: true, label: l10n.baseLocationButtonLabel(label), excludeSemantics: true, + onTap: pickBase, child: InkWell( - onTap: () async { - final picked = await showRegionSheet(context, selected: choice); - if (picked != null && context.mounted) { - await applyBaseLocation(context, ref, picked); - } - }, + onTap: pickBase, child: SizedBox( height: 32, child: Row( diff --git a/flutter/lib/features/home/presentation/widgets/category_grid.dart b/flutter/lib/features/home/presentation/widgets/category_grid.dart index 02ba698..9e18c46 100644 --- a/flutter/lib/features/home/presentation/widgets/category_grid.dart +++ b/flutter/lib/features/home/presentation/widgets/category_grid.dart @@ -115,10 +115,12 @@ class _CategoryCell extends StatelessWidget { @override Widget build(BuildContext context) { final label = shortcut.label(context.l10n); + // excludeSemantics 는 InkWell 의 누르기 동작까지 지운다 — 화면 읽기 프로그램이 누를 수 있게 onTap 을 함께 준다 return Semantics( button: true, label: label, excludeSemantics: true, + onTap: onTap, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(16), diff --git a/flutter/lib/features/home/presentation/widgets/home_search_bar.dart b/flutter/lib/features/home/presentation/widgets/home_search_bar.dart index 6dbfaea..47bd545 100644 --- a/flutter/lib/features/home/presentation/widgets/home_search_bar.dart +++ b/flutter/lib/features/home/presentation/widgets/home_search_bar.dart @@ -14,16 +14,19 @@ class HomeSearchBar extends StatelessWidget { @override Widget build(BuildContext context) { final hint = context.l10n.searchHint; + void openSearch() => context.push(AppRoutes.placeSearch(focus: true)); + // excludeSemantics 는 InkWell 의 누르기 동작까지 지운다 — 화면 읽기 프로그램이 누를 수 있게 onTap 을 함께 준다 return Semantics( button: true, label: hint, excludeSemantics: true, + onTap: openSearch, child: Material( color: AppColors.inputFill, borderRadius: BorderRadius.circular(kRadius), child: InkWell( borderRadius: BorderRadius.circular(kRadius), - onTap: () => context.push(AppRoutes.placeSearch(focus: true)), + onTap: openSearch, child: SizedBox( height: 48, child: Row( 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/pages/place_search_page.dart b/flutter/lib/features/place/presentation/pages/place_search_page.dart index 5186738..14dedda 100644 --- a/flutter/lib/features/place/presentation/pages/place_search_page.dart +++ b/flutter/lib/features/place/presentation/pages/place_search_page.dart @@ -427,17 +427,20 @@ class FilterToggleChip extends StatelessWidget { color: selected ? AppColors.chipBorderSelected : AppColors.chipBorder, ), ); + void toggle() => onChanged(!selected); + // excludeSemantics 는 InkWell 의 누르기 동작까지 지운다 — 화면 읽기 프로그램이 누를 수 있게 onTap 을 함께 준다 return Semantics( toggled: selected, button: true, excludeSemantics: true, label: label, + onTap: toggle, child: Material( color: selected ? AppColors.brandSoft : AppColors.background, shape: shape, child: InkWell( customBorder: shape, - onTap: () => onChanged(!selected), + onTap: toggle, child: Container( height: 36, padding: const EdgeInsets.symmetric(horizontal: 12), 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/lib/shared/widgets/language_button.dart b/flutter/lib/shared/widgets/language_button.dart index e52ccfe..18f0a69 100644 --- a/flutter/lib/shared/widgets/language_button.dart +++ b/flutter/lib/shared/widgets/language_button.dart @@ -16,12 +16,15 @@ class LanguageButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final l10n = context.l10n; + void openSheet() => _openSheet(context, ref); + // excludeSemantics 는 버튼의 누르기 동작까지 지운다 — 화면 읽기 프로그램이 누를 수 있게 onTap 을 함께 준다 return Semantics( button: true, label: l10n.languageButtonLabel(l10n.languageName), excludeSemantics: true, + onTap: openSheet, child: OutlinedButton( - onPressed: () => _openSheet(context, ref), + onPressed: openSheet, style: OutlinedButton.styleFrom( minimumSize: Size(44, compact ? 44 : 40), padding: EdgeInsets.only(left: 12, right: compact ? 12 : 10), diff --git a/flutter/lib/shared/widgets/public_image.dart b/flutter/lib/shared/widgets/public_image.dart index 92244b4..f543ff3 100644 --- a/flutter/lib/shared/widgets/public_image.dart +++ b/flutter/lib/shared/widgets/public_image.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:wandergo/core/theme/app_colors.dart'; import 'package:wandergo/core/theme/app_theme.dart'; +import 'package:wandergo/core/utils/image_decode.dart'; import 'package:wandergo/shared/models/source_models.dart'; /// 사진이 없거나 못 불러올 때의 자리 표시 — 분류별 배경색·아이콘 @@ -49,6 +50,8 @@ class PublicImage extends StatelessWidget { ), ); final imageUrl = url; + final boxFit = + fit ?? (license == ImageLicense.type3 ? BoxFit.contain : BoxFit.cover); return ClipRRect( borderRadius: BorderRadius.circular(radius), child: SizedBox( @@ -58,16 +61,21 @@ class PublicImage extends StatelessWidget { ? fallback : ColoredBox( color: AppColors.placeholder, - child: CachedNetworkImage( - imageUrl: imageUrl, - width: width, - height: height, - fit: - fit ?? - (license == ImageLicense.type3 - ? BoxFit.contain - : BoxFit.cover), - errorWidget: (_, _, _) => fallback, + // 크기를 정하지 않은 쪽(상세 위쪽 사진의 가로 등)은 실제로 받은 폭으로 계산한다 + child: LayoutBuilder( + builder: (context, constraints) => CachedNetworkImage( + imageUrl: imageUrl, + width: width, + height: height, + fit: boxFit, + memCacheWidth: decodeWidthFor( + boxWidth: width ?? constraints.maxWidth, + boxHeight: height ?? constraints.maxHeight, + fit: boxFit, + devicePixelRatio: MediaQuery.devicePixelRatioOf(context), + ), + errorWidget: (_, _, _) => fallback, + ), ), ), ), diff --git a/flutter/test/core/utils/image_decode_test.dart b/flutter/test/core/utils/image_decode_test.dart new file mode 100644 index 0000000..5da51e7 --- /dev/null +++ b/flutter/test/core/utils/image_decode_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter/painting.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wandergo/core/utils/image_decode.dart'; + +void main() { + group('decodeWidthFor', () { + test('cover 는 가로가 긴 사진도 세로를 채우도록 세로 × 2 까지 가로를 둔다', () { + // 목록 줄 64×64, 화면 배율 3 → max(64, 128) × 3 + expect( + decodeWidthFor( + boxWidth: 64, + boxHeight: 64, + fit: BoxFit.cover, + devicePixelRatio: 3, + ), + 384, + ); + // 함께 가기 카드 148×104 → max(148, 208) × 3 + expect( + decodeWidthFor( + boxWidth: 148, + boxHeight: 104, + fit: BoxFit.cover, + devicePixelRatio: 3, + ), + 624, + ); + }); + + test('contain(공공누리 Type3)은 상자 가로만큼이면 된다', () { + expect( + decodeWidthFor( + boxWidth: 148, + boxHeight: 104, + fit: BoxFit.contain, + devicePixelRatio: 2.625, + ), + 389, + ); + }); + + test('가로를 모르면 세로로, 둘 다 모르면 null(원본 그대로)', () { + expect( + decodeWidthFor( + boxWidth: double.infinity, + boxHeight: 240, + fit: BoxFit.contain, + devicePixelRatio: 2, + ), + 960, + ); + expect( + decodeWidthFor( + boxWidth: null, + boxHeight: double.infinity, + fit: BoxFit.cover, + devicePixelRatio: 2, + ), + isNull, + ); + }); + }); +} diff --git a/flutter/test/widget_test.dart b/flutter/test/widget_test.dart index 8cf3195..d79e57c 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,65 @@ 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); + }); + + // 2026-11-04 회귀 점검: excludeSemantics 가 누르기 동작까지 지워 TalkBack 으로 누를 수 없었다 + testWidgets('홈·검색의 버튼은 화면 읽기 프로그램으로도 누를 수 있다', (tester) async { + final semantics = tester.ensureSemantics(); + await _pumpApp( + tester, + prefs: {'onboarding_done': true}, + places: _FakePlaceRepository(places: [_storeCafe]), + ); + + for (final label in [ + '홈', + '관광지', + '장소 이름으로 검색', + RegExp('^언어 변경'), + RegExp('^기준 위치 변경'), + ]) { + expect( + tester.getSemantics(find.bySemanticsLabel(label).first), + isSemantics(isButton: true, hasTapAction: true), + reason: '$label', + ); + } + + await tester.tap(find.text('관광지')); + await tester.pumpAndSettle(); + expect( + tester.getSemantics(find.bySemanticsLabel('반려견 동반')), + isSemantics(isButton: true, hasTapAction: true), + ); + semantics.dispose(); + }); }