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 2732584..2d6b597 100644 --- a/flutter/README.md +++ b/flutter/README.md @@ -60,6 +60,7 @@ lib/ - 한국어 문단은 `keepAll()` 을 거쳐 단어 단위로 줄바꿈한다(Flutter 기본은 글자 단위). - 카드·배지 대신 흰 바탕 위 평면 목록과 구분선, 색 글자로 메타 정보를 보인다. - 공공누리 Type3 사진은 자르지 않는다(`PublicImage` 가 contain 으로 보인다 — 행사·장소 사진 모두 이 위젯을 거친다). +- 사진은 화면에 그릴 크기로 줄여 푼다(`PublicImage` → `decodeWidthFor`). 공연 포스터는 목록 썸네일도 원본(최대 9MB)이라 그대로 풀면 메모리를 많이 쓴다. - 영업시간을 모르는 장소는 "영업시간 미확인 · 방문 전 확인"으로 보이고, 확인 안 된 상가정보 매장은 "함께 가기 좋은 곳"에 고르지 않는다 (서버가 `trusted=true` 로 거른다 — 앱에서 거르면 상가가 빽빽한 도심에서 칸이 비었다). - `Semantics(excludeSemantics: true)` 로 버튼을 감싸면 안쪽 InkWell 의 누르기 동작까지 지워진다. Semantics 에도 `onTap` 을 같이 준다 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/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, + ); + }); + }); +}