fix(flutter): 함께 가기 좋은 곳 · 화면 읽기 버튼 · 사진 메모리 수정 #5

Merged
ttipo merged 3 commits from fix/flutter-nearby-a11y-images into main 2026-11-05 01:33:16 +09:00
14 changed files with 246 additions and 32 deletions
+1 -1
View File
@@ -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))`
+5 -1
View File
@@ -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()`.
+2
View File
@@ -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,
+35
View File
@@ -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();
}
@@ -132,17 +132,21 @@ class _BaseLocationButton extends ConsumerWidget {
final label = fellBack
? kFallbackNeighborhood.label(l10n)
: choice.label(l10n);
Future<void> 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(
@@ -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),
@@ -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(
@@ -15,10 +15,12 @@ class PlaceRepository {
/// 장소 검색. [near] 를 주면 [radiusM] 안을 거리순으로, 없으면 이름순으로 준다.
/// [categories] 가 비면 전체 분류, [petOnly] 면 반려동물 동반(전 구역·일부 구역) 장소만.
/// [trustedOnly] 면 관광공사 장소와 운영자가 확인한 장소만(확인 안 된 상가정보 매장·추천 안 함 제외).
Future<PlacePage> search({
String? query,
List<PlaceCategory> 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,
@@ -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),
@@ -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<List<PlaceItem>, 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();
});
@@ -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),
+18 -10
View File
@@ -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,
),
),
),
),
@@ -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,
);
});
});
}
+89 -3
View File
@@ -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<PlaceItem> places;
final List<PlaceItem> petPlaces;
final List<({String? query, List<PlaceCategory> categories, bool petOnly})>
final List<
({
String? query,
List<PlaceCategory> categories,
bool petOnly,
bool trustedOnly,
int limit,
})
>
calls = [];
@override
@@ -121,13 +130,29 @@ class _FakePlaceRepository implements PlaceRepository {
String? query,
List<PlaceCategory> 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 <Pattern>[
'홈',
'관광지',
'장소 이름으로 검색',
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();
});
}