From 8f711c17064bb397ab49bc02aeec38ceeb1ccb8a Mon Sep 17 00:00:00 2001 From: ttipo Date: Wed, 4 Nov 2026 16:44:55 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor(flutter):=20=EA=B3=B5=EA=B3=B5?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=82=AC=EC=A7=84=20=EC=9C=84?= =?UTF-8?q?=EC=A0=AF=EA=B3=BC=20=EC=B6=9C=EC=B2=98=20=EB=AA=A8=EB=8D=B8?= =?UTF-8?q?=EC=9D=84=20=EA=B3=B5=EC=9A=A9=EC=9C=BC=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 장소 화면도 공공누리 사진과 출처 표기를 써야 해서, 행사 쪽에 있던 것을 shared 로 옮겼다. Type3 사진을 자르지 않는 규칙이 한 위젯(PublicImage)에만 있게 한다. event_models 는 출처 모델을 다시 내보내 기존 import 는 그대로 동작한다. Co-Authored-By: Claude Opus 5.5 --- .../event/data/models/event_models.dart | 39 ++-------- .../presentation/widgets/event_visuals.dart | 46 +++-------- flutter/lib/shared/models/source_models.dart | 36 +++++++++ flutter/lib/shared/widgets/public_image.dart | 76 +++++++++++++++++++ 4 files changed, 128 insertions(+), 69 deletions(-) create mode 100644 flutter/lib/shared/models/source_models.dart create mode 100644 flutter/lib/shared/widgets/public_image.dart diff --git a/flutter/lib/features/event/data/models/event_models.dart b/flutter/lib/features/event/data/models/event_models.dart index f09b737..9f03e06 100644 --- a/flutter/lib/features/event/data/models/event_models.dart +++ b/flutter/lib/features/event/data/models/event_models.dart @@ -1,6 +1,11 @@ /// 축제·공연·행사·전시 — 백엔드 EventListItemDto / EventDetailDto 와 1:1 대응한다. library; +import 'package:wandergo/shared/models/source_models.dart'; + +// 기존 import 를 깨지 않도록 출처 모델을 함께 내보낸다 +export 'package:wandergo/shared/models/source_models.dart'; + /// 행사 분류 (백엔드 EVENT_KINDS) enum EventKind { festival, @@ -17,23 +22,6 @@ enum EventKind { } } -/// 사진 공공누리 유형 — Type3 은 변경 금지(자르지 않고 그대로 보인다) -enum ImageLicense { - type1('Type1'), - type3('Type3'); - - const ImageLicense(this.code); - - final String code; - - static ImageLicense? parse(Object? value) { - for (final license in values) { - if (license.code == value) return license; - } - return null; - } -} - /// 목록의 한 건 class EventItem { const EventItem({ @@ -97,23 +85,6 @@ class EventItem { } } -/// 정보 출처 표기 — 화면에 반드시 보인다 -class Attribution { - const Attribution({required this.provider, this.asOf}); - - final String provider; - - /// 원본 기준 시점 - final String? asOf; - - factory Attribution.fromJson(Map json) { - return Attribution( - provider: json['provider'] as String, - asOf: json['asOf'] as String?, - ); - } -} - /// 상세 — 목록 필드에 소개·시간·링크·출처를 더한다 class EventDetail { const EventDetail({ diff --git a/flutter/lib/features/event/presentation/widgets/event_visuals.dart b/flutter/lib/features/event/presentation/widgets/event_visuals.dart index 79505b6..fa81c3d 100644 --- a/flutter/lib/features/event/presentation/widgets/event_visuals.dart +++ b/flutter/lib/features/event/presentation/widgets/event_visuals.dart @@ -1,11 +1,10 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:wandergo/core/l10n/l10n.dart'; -import 'package:wandergo/core/theme/app_colors.dart'; import 'package:wandergo/core/theme/app_theme.dart'; import 'package:wandergo/core/utils/format.dart'; import 'package:wandergo/features/event/data/models/event_models.dart'; +import 'package:wandergo/shared/widgets/public_image.dart'; extension EventKindLabel on EventKind { String label(AppLocalizations l10n) => switch (this) { @@ -17,9 +16,7 @@ extension EventKindLabel on EventKind { } /// 사진이 없을 때 분류별 자리 표시 색·아이콘 (디자인 캔버스의 자리 표시 톤) -({Color background, Color foreground, IconData icon}) _placeholderOf( - EventKind? kind, -) => switch (kind) { +ImagePlaceholder _placeholderOf(EventKind? kind) => switch (kind) { EventKind.festival => ( background: const Color(0xFFFFE7B8), foreground: const Color(0xFF6E4A00), @@ -51,8 +48,7 @@ String eventMeta(EventItem item) { return parts.join(' · '); } -/// 행사 사진 — 공공누리 Type3 은 자르지 않고(변경 금지) 통째로 보인다. -/// [fit] 을 주지 않으면 Type3 은 contain, 나머지는 cover. +/// 행사 사진 — 사진이 없으면 행사 종류별 자리 표시를 보인다([PublicImage]). class EventImage extends StatelessWidget { const EventImage({ super.key, @@ -75,34 +71,14 @@ class EventImage extends StatelessWidget { @override Widget build(BuildContext context) { - final placeholder = _placeholderOf(kind); - final fallback = ColoredBox( - color: placeholder.background, - child: Center( - child: Icon(placeholder.icon, size: 28, color: placeholder.foreground), - ), - ); - final imageUrl = url; - return ClipRRect( - borderRadius: BorderRadius.circular(radius), - child: SizedBox( - width: width, - height: height, - child: imageUrl == null - ? fallback - : ColoredBox( - color: AppColors.placeholder, - child: CachedNetworkImage( - imageUrl: imageUrl, - fit: - fit ?? - (license == ImageLicense.type3 - ? BoxFit.contain - : BoxFit.cover), - errorWidget: (_, _, _) => fallback, - ), - ), - ), + return PublicImage( + url: url, + license: license, + placeholder: _placeholderOf(kind), + width: width, + height: height, + radius: radius, + fit: fit, ); } } diff --git a/flutter/lib/shared/models/source_models.dart b/flutter/lib/shared/models/source_models.dart new file mode 100644 index 0000000..2aa1264 --- /dev/null +++ b/flutter/lib/shared/models/source_models.dart @@ -0,0 +1,36 @@ +/// 공공데이터 출처 표기 — 행사·장소가 함께 쓴다. +library; + +/// 사진 공공누리 유형 — Type3 은 변경 금지(자르지 않고 그대로 보인다) +enum ImageLicense { + type1('Type1'), + type3('Type3'); + + const ImageLicense(this.code); + + final String code; + + static ImageLicense? parse(Object? value) { + for (final license in values) { + if (license.code == value) return license; + } + return null; + } +} + +/// 정보 출처 표기 — 화면에 반드시 보인다 +class Attribution { + const Attribution({required this.provider, this.asOf}); + + final String provider; + + /// 원본 기준 시점 — 'YYYY-MM-DD' 또는 상가정보 기준년월 'YYYYMM' + final String? asOf; + + factory Attribution.fromJson(Map json) { + return Attribution( + provider: json['provider'] as String, + asOf: json['asOf'] as String?, + ); + } +} diff --git a/flutter/lib/shared/widgets/public_image.dart b/flutter/lib/shared/widgets/public_image.dart new file mode 100644 index 0000000..92244b4 --- /dev/null +++ b/flutter/lib/shared/widgets/public_image.dart @@ -0,0 +1,76 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +import 'package:wandergo/core/theme/app_colors.dart'; +import 'package:wandergo/core/theme/app_theme.dart'; +import 'package:wandergo/shared/models/source_models.dart'; + +/// 사진이 없거나 못 불러올 때의 자리 표시 — 분류별 배경색·아이콘 +typedef ImagePlaceholder = ({ + Color background, + Color foreground, + IconData icon, +}); + +/// 공공데이터 사진 — 공공누리 Type3 은 자르지 않고(변경 금지) 통째로 보인다. +/// [fit] 을 주지 않으면 Type3 은 contain, 나머지는 cover. +class PublicImage extends StatelessWidget { + const PublicImage({ + super.key, + required this.url, + required this.license, + required this.placeholder, + this.width, + this.height, + this.radius = kRadius, + this.fit, + this.iconSize = 28, + }); + + final String? url; + final ImageLicense? license; + final ImagePlaceholder placeholder; + final double? width; + final double? height; + final double radius; + final BoxFit? fit; + final double iconSize; + + @override + Widget build(BuildContext context) { + final fallback = ColoredBox( + color: placeholder.background, + child: Center( + child: Icon( + placeholder.icon, + size: iconSize, + color: placeholder.foreground, + ), + ), + ); + final imageUrl = url; + return ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: SizedBox( + width: width, + height: height, + child: imageUrl == null + ? fallback + : ColoredBox( + color: AppColors.placeholder, + child: CachedNetworkImage( + imageUrl: imageUrl, + width: width, + height: height, + fit: + fit ?? + (license == ImageLicense.type3 + ? BoxFit.contain + : BoxFit.cover), + errorWidget: (_, _, _) => fallback, + ), + ), + ), + ); + } +} -- 2.52.0 From 4bd142a9b7a4caea215106a703ff19bf3a044323 Mon Sep 17 00:00:00 2001 From: ttipo Date: Wed, 4 Nov 2026 16:44:56 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(flutter):=20=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=C2=B7=EC=83=81=EC=84=B8=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EA=B3=BC=20=ED=99=88=20=EA=B2=80=EC=83=89=EC=B0=BD=C2=B7?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=C2=B7=EB=B0=98=EB=A0=A4?= =?UTF-8?q?=EB=8F=99=EB=AC=BC=20=EC=9E=A5=EC=86=8C=20=EC=B9=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 디자인 캔버스 4(검색)·6·7(장소 상세) 중 지금 장소 API 로 되는 부분을 붙였다. - 검색은 반경 20km(백엔드 최대) 거리순. 대구 도심에서 대구 전역이 들어온다. - 영업시간을 모르는 곳은 "영업시간 미확인 · 방문 전 확인"으로 보인다. - 한눈에 보기는 공공데이터 원문으로 확실한 것만 올린다(주차 '인근 공영주차장' 같은 애매한 값은 빼기). - 함께 가기 좋은 곳에는 확인 안 된 상가정보 매장을 넣지 않는다(2026-11-02 합의). - 1330 관광통역안내는 영어 화면에만(2026-10-29 합의). 검색의 '축제' 탭과 실내·무료·주차·이번 주말 조건은 데이터가 없어 넣지 않았다 (행사 API 검색어 조건 · 운영자 확인 장소 · 날씨). Co-Authored-By: Claude Opus 5.5 --- .claude/skills/flutter/SKILL.md | 1 + flutter/README.md | 12 +- flutter/lib/core/icons/app_icons.dart | 53 +- flutter/lib/core/router/app_router.dart | 22 + flutter/lib/core/router/app_routes.dart | 25 + flutter/lib/core/theme/app_colors.dart | 12 + flutter/lib/core/utils/format.dart | 6 + flutter/lib/core/utils/geo.dart | 27 + .../home/presentation/pages/home_page.dart | 60 +- .../presentation/widgets/category_grid.dart | 158 ++++ .../presentation/widgets/home_search_bar.dart | 55 ++ .../widgets/pet_places_section.dart | 66 ++ .../place/data/models/place_models.dart | 336 +++++++++ .../data/repositories/place_repository.dart | 52 ++ .../features/place/domain/place_facts.dart | 33 + .../features/place/domain/place_search.dart | 67 ++ .../presentation/pages/place_detail_page.dart | 306 ++++++++ .../presentation/pages/place_search_page.dart | 586 +++++++++++++++ .../providers/place_providers.dart | 86 +++ .../widgets/place_detail_sections.dart | 708 ++++++++++++++++++ .../presentation/widgets/place_tiles.dart | 225 ++++++ .../presentation/widgets/place_visuals.dart | 210 ++++++ flutter/lib/l10n/app_en.arb | 93 ++- flutter/lib/l10n/app_ko.arb | 178 ++++- .../lib/shared/widgets/section_header.dart | 46 ++ flutter/test/core/utils/format_test.dart | 10 + flutter/test/core/utils/geo_test.dart | 41 + flutter/test/features/place_models_test.dart | 177 +++++ flutter/test/widget_test.dart | 210 ++++++ 29 files changed, 3816 insertions(+), 45 deletions(-) create mode 100644 flutter/lib/core/utils/geo.dart create mode 100644 flutter/lib/features/home/presentation/widgets/category_grid.dart create mode 100644 flutter/lib/features/home/presentation/widgets/home_search_bar.dart create mode 100644 flutter/lib/features/home/presentation/widgets/pet_places_section.dart create mode 100644 flutter/lib/features/place/data/models/place_models.dart create mode 100644 flutter/lib/features/place/data/repositories/place_repository.dart create mode 100644 flutter/lib/features/place/domain/place_facts.dart create mode 100644 flutter/lib/features/place/domain/place_search.dart create mode 100644 flutter/lib/features/place/presentation/pages/place_detail_page.dart create mode 100644 flutter/lib/features/place/presentation/pages/place_search_page.dart create mode 100644 flutter/lib/features/place/presentation/providers/place_providers.dart create mode 100644 flutter/lib/features/place/presentation/widgets/place_detail_sections.dart create mode 100644 flutter/lib/features/place/presentation/widgets/place_tiles.dart create mode 100644 flutter/lib/features/place/presentation/widgets/place_visuals.dart create mode 100644 flutter/lib/shared/widgets/section_header.dart create mode 100644 flutter/test/core/utils/geo_test.dart create mode 100644 flutter/test/features/place_models_test.dart diff --git a/.claude/skills/flutter/SKILL.md b/.claude/skills/flutter/SKILL.md index eb3d68f..ad7b124 100644 --- a/.claude/skills/flutter/SKILL.md +++ b/.claude/skills/flutter/SKILL.md @@ -29,6 +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` | > **확장 시 따르는 규칙** (아래 "프로젝트 구조"는 목표 구조 — 기능 추가 시 점진적으로 채운다): > - **상태관리**: `features/user` 의 model→repository→provider 패턴을 복제. 위젯에서 `ref.watch(userProvider(id))` diff --git a/flutter/README.md b/flutter/README.md index df38640..0a75a1b 100644 --- a/flutter/README.md +++ b/flutter/README.md @@ -43,14 +43,15 @@ lib/ │ ├── l10n/ context.l10n · 앱 언어 선택 │ ├── storage/ shared_preferences (민감하지 않은 설정만) │ ├── icons/ 디자인 캔버스의 선 아이콘(SVG) -│ └── utils/ 순수 함수 — 거리·기간 표기, 주말 계산, 한국어 줄바꿈(keepAll) +│ └── utils/ 순수 함수 — 거리·기간 표기, 주말 계산, 직선 거리·도보 시간, 한국어 줄바꿈(keepAll) ├── features/ │ ├── onboarding/ 첫 실행 · 위치 권한 │ ├── location/ 기준 위치(현재 위치 / 대구 동네) -│ ├── home/ 홈 -│ └── event/ 축제·공연·행사·전시 목록·상세 +│ ├── home/ 홈 — 검색창·카테고리·축제·반려동물 장소 칸 +│ ├── event/ 축제·공연·행사·전시 목록·상세 +│ └── place/ 장소 검색·상세 (domain/ 에 검색 조건·공공데이터 원문 해석) ├── l10n/ app_ko.arb · app_en.arb (생성물 generated/ 는 git 제외) -└── shared/ 여러 화면이 쓰는 위젯·유틸 +└── shared/ 여러 화면이 쓰는 위젯·유틸·출처 모델(공공누리 유형·출처 표기) ``` ## 규칙 메모 @@ -58,5 +59,6 @@ lib/ - 화면 문구는 `lib/l10n/*.arb` 에만 쓴다. 출시는 한국어·영어, 설계는 en/ja/zh-Hans/zh-Hant 까지 전제. - 한국어 문단은 `keepAll()` 을 거쳐 단어 단위로 줄바꿈한다(Flutter 기본은 글자 단위). - 카드·배지 대신 흰 바탕 위 평면 목록과 구분선, 색 글자로 메타 정보를 보인다. -- 공공누리 Type3 사진은 자르지 않는다(`EventImage` 가 contain 으로 보인다). +- 공공누리 Type3 사진은 자르지 않는다(`PublicImage` 가 contain 으로 보인다 — 행사·장소 사진 모두 이 위젯을 거친다). +- 영업시간을 모르는 장소는 "영업시간 미확인 · 방문 전 확인"으로 보이고, 확인 안 된 상가정보 매장은 "함께 가기 좋은 곳"에 고르지 않는다. - 실패 문구는 에러 사전(`error_lexicon.g.dart`)에서만 가져온다 — `errorMessageOf()`. diff --git a/flutter/lib/core/icons/app_icons.dart b/flutter/lib/core/icons/app_icons.dart index 98bec59..ab466f9 100644 --- a/flutter/lib/core/icons/app_icons.dart +++ b/flutter/lib/core/icons/app_icons.dart @@ -25,7 +25,58 @@ enum AppIconData { '', ), chevronDown(''), - chevronRight(''); + chevronRight(''), + chevronLeft(''), + search(''), + close(''), + check(''), + info( + '', + ), + ban(''), + clock(''), + parking( + '', + ), + ticket( + '', + ), + externalLink( + '', + ), + + /// 반려동물 — 선 + 16% 채움(한눈에 보기) + paw( + '', + ), + + /// 반려동물 — 꽉 찬 작은 발바닥(목록 글자 앞) + pawSolid( + '', + ), + + // 홈 카테고리 — 늘 같은 색 16% 로 채운다(선 1.6, 30px) + categorySights( + '', + ), + categoryFestival( + '', + ), + categoryPet( + '', + ), + categoryFood( + '', + ), + categoryStay( + '', + ), + categoryIndoor( + '', + ), + categoryNature( + '', + ); const AppIconData(this.body); diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index e82ed3e..6e1ea7d 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -10,6 +10,9 @@ import 'package:wandergo/features/event/presentation/pages/event_list_page.dart' import 'package:wandergo/features/home/presentation/pages/home_page.dart'; import 'package:wandergo/features/onboarding/presentation/pages/onboarding_page.dart'; import 'package:wandergo/features/onboarding/presentation/providers/onboarding_provider.dart'; +import 'package:wandergo/features/place/domain/place_search.dart'; +import 'package:wandergo/features/place/presentation/pages/place_detail_page.dart'; +import 'package:wandergo/features/place/presentation/pages/place_search_page.dart'; import 'package:wandergo/shared/widgets/coming_soon_page.dart'; /// 화면 이동 — 첫 실행을 마치기 전에는 어느 경로든 첫 실행 화면으로 보낸다. @@ -87,6 +90,25 @@ final routerProvider = Provider((ref) { builder: (context, state) => EventDetailPage(id: state.pathParameters['id']!), ), + GoRoute( + path: AppRoutes.search, + builder: (context, state) { + final query = state.uri.queryParameters; + return PlaceSearchPage( + initialQuery: PlaceQuery( + text: query['q'] ?? '', + tab: PlaceTab.parse(query['tab']), + petOnly: query['pet'] == '1', + ), + autofocus: query['focus'] == '1', + ); + }, + ), + GoRoute( + path: AppRoutes.placeDetailPattern, + builder: (context, state) => + PlaceDetailPage(id: state.pathParameters['id']!), + ), ], ); ref.onDispose(router.dispose); diff --git a/flutter/lib/core/router/app_routes.dart b/flutter/lib/core/router/app_routes.dart index 4f71464..7986d49 100644 --- a/flutter/lib/core/router/app_routes.dart +++ b/flutter/lib/core/router/app_routes.dart @@ -1,4 +1,5 @@ import 'package:wandergo/core/utils/weekend.dart'; +import 'package:wandergo/features/place/domain/place_search.dart'; /// 화면 경로 — 앱 전체에서 이 상수만 쓴다 abstract final class AppRoutes { @@ -22,4 +23,28 @@ abstract final class AppRoutes { if (isUpcoming) 'upcoming': '1', }, ).toString(); + + /// 장소 검색 — ?q=&tab=&pet=1&focus=1 + static const String search = '/search'; + static const String placeDetailPattern = '/places/:id'; + + static String placeDetail(String id) => '/places/$id'; + + /// [focus] 면 들어가자마자 검색어를 입력할 수 있게 한다(홈 검색창) + static String placeSearch({ + PlaceQuery query = const PlaceQuery(), + bool focus = false, + }) { + final params = { + 'q': ?query.queryOrNull, + if (query.tab != PlaceTab.all) 'tab': query.tab.name, + if (query.petOnly) 'pet': '1', + if (focus) 'focus': '1', + }; + // 빈 맵을 넘기면 끝에 '?' 가 붙는다 + return Uri( + path: search, + queryParameters: params.isEmpty ? null : params, + ).toString(); + } } diff --git a/flutter/lib/core/theme/app_colors.dart b/flutter/lib/core/theme/app_colors.dart index 42bf366..79b0fc6 100644 --- a/flutter/lib/core/theme/app_colors.dart +++ b/flutter/lib/core/theme/app_colors.dart @@ -46,4 +46,16 @@ abstract final class AppColors { /// 반려동물 동반 글자(발바닥 아이콘과 함께) static const Color pet = Color(0xFF0B6157); + + /// 금지·불가 표시 아이콘(예: 반려동물 동반 불가 구역) + static const Color danger = Color(0xFFB42318); + + /// 칩 테두리(선택 전) + static const Color chipBorder = Color(0xFFD9DCE6); + + /// 선택된 칩 테두리 — 배경은 [brandSoft] + static const Color chipBorderSelected = Color(0xFFFFC2C4); + + /// 카테고리 아이콘 아래 이름 + static const Color label = Color(0xFF2E3340); } diff --git a/flutter/lib/core/utils/format.dart b/flutter/lib/core/utils/format.dart index 355e5aa..98ac795 100644 --- a/flutter/lib/core/utils/format.dart +++ b/flutter/lib/core/utils/format.dart @@ -29,6 +29,12 @@ String toYmd(DateTime date) { return '${date.year}-$mm-$dd'; } +/// 출처 기준 시점 표시 — 상가정보 기준년월 'YYYYMM' 은 'YYYY-MM' 으로, 나머지는 그대로. +String formatSourceDate(String asOf) { + final m = RegExp(r'^(\d{4})(\d{2})$').firstMatch(asOf); + return m == null ? asOf : '${m[1]}-${m[2]}'; +} + /// 행사 기간 표시 — 한국어는 "10.31–11.2", 하루짜리는 "11.8". /// 다른 언어는 그 언어의 월·일 표기를 쓴다(영어 "Oct 31 – Nov 2"). String formatDateRange(String start, String end, {String localeName = 'ko'}) { diff --git a/flutter/lib/core/utils/geo.dart b/flutter/lib/core/utils/geo.dart new file mode 100644 index 0000000..e54a773 --- /dev/null +++ b/flutter/lib/core/utils/geo.dart @@ -0,0 +1,27 @@ +import 'dart:math' as math; + +/// 지구 평균 반지름(m) +const double _earthRadiusM = 6371000; + +/// 걷는 속도 — 시속 4km(분당 약 67m)로 어림한다 +const double _walkMetersPerMinute = 4000 / 60; + +/// 이보다 멀면 도보 시간 대신 거리만 보인다(m) +const double kWalkMaxM = 2000; + +/// 두 좌표 사이의 직선 거리(m) — 하버사인 공식 +double distanceMeters(double lat1, double lng1, double lat2, double lng2) { + double rad(double deg) => deg * math.pi / 180; + final dLat = rad(lat2 - lat1); + final dLng = rad(lng2 - lng1); + final a = + math.pow(math.sin(dLat / 2), 2) + + math.cos(rad(lat1)) * + math.cos(rad(lat2)) * + math.pow(math.sin(dLng / 2), 2); + return 2 * _earthRadiusM * math.asin(math.min(1, math.sqrt(a))); +} + +/// 걸어서 걸리는 시간(분, 올림) — 직선 거리 기준의 어림값이라 최소 1분 +int walkMinutes(num meters) => + math.max(1, (meters / _walkMetersPerMinute).ceil()); diff --git a/flutter/lib/features/home/presentation/pages/home_page.dart b/flutter/lib/features/home/presentation/pages/home_page.dart index acb07ce..500e02c 100644 --- a/flutter/lib/features/home/presentation/pages/home_page.dart +++ b/flutter/lib/features/home/presentation/pages/home_page.dart @@ -10,24 +10,34 @@ import 'package:wandergo/core/theme/app_theme.dart'; import 'package:wandergo/core/utils/keep_all.dart'; import 'package:wandergo/features/event/presentation/providers/event_providers.dart'; import 'package:wandergo/features/event/presentation/widgets/event_card.dart'; +import 'package:wandergo/features/home/presentation/widgets/category_grid.dart'; +import 'package:wandergo/features/home/presentation/widgets/home_search_bar.dart'; +import 'package:wandergo/features/home/presentation/widgets/pet_places_section.dart'; import 'package:wandergo/features/location/domain/base_location.dart'; import 'package:wandergo/features/location/presentation/providers/base_location_provider.dart'; import 'package:wandergo/features/location/presentation/widgets/region_sheet.dart'; +import 'package:wandergo/features/place/presentation/providers/place_providers.dart'; import 'package:wandergo/shared/widgets/language_button.dart'; import 'package:wandergo/shared/widgets/load_error.dart'; +import 'package:wandergo/shared/widgets/section_header.dart'; /// 홈 가로 목록 높이 — 사진 110 + 여백·글자 세 줄 const double _kEventStripHeight = 186; -/// 3 홈 — 지금은 기준 위치 · 제목 · 이번 주말 축제·행사 · 출처까지. -/// 검색창·배너·카테고리·AI·추천 코스·반려동물 장소 칸은 해당 기능을 만들 때 붙인다. +/// 3 홈 — 기준 위치 · 제목 · 검색창 · 카테고리 · 이번 주말 축제·행사 · 가까운 반려동물 동반 장소 · 출처. +/// 배너·AI·추천 코스·지역 바로가기·로그인 버튼은 해당 기능을 만들 때 붙인다. class HomePage extends ConsumerWidget { const HomePage({super.key}); Future _refresh(WidgetRef ref) async { // 현재 위치를 쓰는 경우 위치도 다시 받는다 - ref.invalidate(basePointProvider); - await ref.read(homeEventsProvider.future); + ref + ..invalidate(basePointProvider) + ..invalidate(homePetPlacesProvider); + await Future.wait([ + ref.read(homeEventsProvider.future), + ref.read(homePetPlacesProvider.future), + ]); } @override @@ -57,11 +67,20 @@ class HomePage extends ConsumerWidget { const _BaseLocationButton(), const SizedBox(height: 4), Text(keepAll(l10n.homeTitle), style: textTheme.titleLarge), + const SizedBox(height: 16), + const HomeSearchBar(), + const SizedBox(height: 28), + const CategoryGrid(), ], ), ), - const SizedBox(height: 24), + const SizedBox(height: 32), const _EventsSection(), + const SizedBox(height: 32), + const Padding( + padding: EdgeInsets.symmetric(horizontal: kPagePadding), + child: PetPlacesSection(), + ), const SizedBox(height: 36), const Padding( padding: EdgeInsets.symmetric(horizontal: kPagePadding), @@ -177,35 +196,16 @@ class _EventsSection extends ConsumerWidget { children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: kPagePadding), - child: Row( - children: [ - Expanded(child: Text(title, style: textTheme.titleMedium)), - if (data != null && data.items.isNotEmpty) - TextButton( - onPressed: () => context.push( + child: SectionHeader( + title: title, + onSeeAll: data != null && data.items.isNotEmpty + ? () => context.push( AppRoutes.eventList( data.range, isUpcoming: data.isUpcoming, ), - ), - style: TextButton.styleFrom( - padding: EdgeInsets.zero, - textStyle: textTheme.bodyMedium, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(l10n.seeAll), - const SizedBox(width: 2), - const AppIcon( - AppIconData.chevronRight, - size: 14, - color: AppColors.textTertiary, - ), - ], - ), - ), - ], + ) + : null, ), ), const SizedBox(height: 8), diff --git a/flutter/lib/features/home/presentation/widgets/category_grid.dart b/flutter/lib/features/home/presentation/widgets/category_grid.dart new file mode 100644 index 0000000..02ba698 --- /dev/null +++ b/flutter/lib/features/home/presentation/widgets/category_grid.dart @@ -0,0 +1,158 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:wandergo/core/clock.dart'; +import 'package:wandergo/core/icons/app_icons.dart'; +import 'package:wandergo/core/l10n/l10n.dart'; +import 'package:wandergo/core/router/app_routes.dart'; +import 'package:wandergo/core/theme/app_colors.dart'; +import 'package:wandergo/core/utils/weekend.dart'; +import 'package:wandergo/features/event/presentation/providers/event_providers.dart'; +import 'package:wandergo/features/place/domain/place_search.dart'; + +/// 한 줄에 놓는 카테고리 수 +const int _kColumns = 4; + +/// 홈 카테고리 바로가기 — 아이콘(분류색) · 이름 · 여는 검색 조건. +/// +/// 디자인의 '데이트'는 운영자 큐레이션 장소가 쌓이면 붙인다(지금은 고를 근거가 없다). +enum CategoryShortcut { + sights(AppIconData.categorySights, Color(0xFF3B7DDD)), + festival(AppIconData.categoryFestival, Color(0xFFEE9A1A)), + pet(AppIconData.categoryPet, Color(0xFF12A08A)), + food(AppIconData.categoryFood, Color(0xFFC0753A)), + stay(AppIconData.categoryStay, Color(0xFF7D5BE6)), + indoor(AppIconData.categoryIndoor, Color(0xFFD9548F)), + nature(AppIconData.categoryNature, Color(0xFF4FA43A)); + + const CategoryShortcut(this.icon, this.color); + + final AppIconData icon; + + /// 디자인 캔버스의 분류색 — 선과 16% 채움에 함께 쓴다 + final Color color; + + String label(AppLocalizations l10n) => switch (this) { + sights => l10n.categorySights, + festival => l10n.categoryFestival, + pet => l10n.categoryPet, + food => l10n.categoryFood, + stay => l10n.categoryStay, + indoor => l10n.categoryIndoor, + nature => l10n.categoryNature, + }; + + /// 누르면 갈 곳 — 축제는 행사 목록(다가오는 30일), 나머지는 검색 조건 + String location(DateTime now) => switch (this) { + festival => AppRoutes.eventList( + upcomingRange(now, days: kUpcomingDays), + isUpcoming: true, + ), + pet => AppRoutes.placeSearch(query: const PlaceQuery(petOnly: true)), + sights => AppRoutes.placeSearch( + query: const PlaceQuery(tab: PlaceTab.sights), + ), + food => AppRoutes.placeSearch(query: const PlaceQuery(tab: PlaceTab.food)), + stay => AppRoutes.placeSearch(query: const PlaceQuery(tab: PlaceTab.stay)), + indoor => AppRoutes.placeSearch( + query: const PlaceQuery(tab: PlaceTab.indoor), + ), + nature => AppRoutes.placeSearch( + query: const PlaceQuery(tab: PlaceTab.nature), + ), + }; +} + +/// 카테고리 — 4열, 연회색 둥근 칸 안의 분류색 아이콘과 이름 +class CategoryGrid extends ConsumerWidget { + const CategoryGrid({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + const items = CategoryShortcut.values; + final rows = [ + for (var i = 0; i < items.length; i += _kColumns) + items.sublist(i, (i + _kColumns).clamp(0, items.length)), + ]; + return Semantics( + container: true, + label: l10n.categoryNavLabel, + child: Column( + children: [ + for (var r = 0; r < rows.length; r++) ...[ + if (r > 0) const SizedBox(height: 20), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var c = 0; c < _kColumns; c++) + Expanded( + child: c < rows[r].length + ? _CategoryCell( + shortcut: rows[r][c], + onTap: () => context.push( + rows[r][c].location(ref.read(clockProvider)()), + ), + ) + : const SizedBox.shrink(), + ), + ], + ), + ], + ], + ), + ); + } +} + +class _CategoryCell extends StatelessWidget { + const _CategoryCell({required this.shortcut, required this.onTap}); + + final CategoryShortcut shortcut; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final label = shortcut.label(context.l10n); + return Semantics( + button: true, + label: label, + excludeSemantics: true, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Column( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: AppColors.placeholder, + borderRadius: BorderRadius.circular(16), + ), + alignment: Alignment.center, + child: AppIcon( + shortcut.icon, + size: 30, + color: shortcut.color, + strokeWidth: 1.6, + ), + ), + const SizedBox(height: 8), + Text( + label, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 13, + height: 18 / 13, + fontWeight: FontWeight.w500, + color: AppColors.label, + ), + ), + ], + ), + ), + ); + } +} diff --git a/flutter/lib/features/home/presentation/widgets/home_search_bar.dart b/flutter/lib/features/home/presentation/widgets/home_search_bar.dart new file mode 100644 index 0000000..6dbfaea --- /dev/null +++ b/flutter/lib/features/home/presentation/widgets/home_search_bar.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:wandergo/core/icons/app_icons.dart'; +import 'package:wandergo/core/l10n/l10n.dart'; +import 'package:wandergo/core/router/app_routes.dart'; +import 'package:wandergo/core/theme/app_colors.dart'; +import 'package:wandergo/core/theme/app_theme.dart'; + +/// 홈 검색창 — 누르면 검색 화면을 열고 바로 입력하게 한다(홈에서는 입력하지 않는다) +class HomeSearchBar extends StatelessWidget { + const HomeSearchBar({super.key}); + + @override + Widget build(BuildContext context) { + final hint = context.l10n.searchHint; + return Semantics( + button: true, + label: hint, + excludeSemantics: true, + child: Material( + color: AppColors.inputFill, + borderRadius: BorderRadius.circular(kRadius), + child: InkWell( + borderRadius: BorderRadius.circular(kRadius), + onTap: () => context.push(AppRoutes.placeSearch(focus: true)), + child: SizedBox( + height: 48, + child: Row( + children: [ + const SizedBox(width: 14), + const AppIcon( + AppIconData.search, + size: 20, + color: AppColors.textTertiary, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + hint, + style: const TextStyle( + fontSize: 15, + height: 21 / 15, + color: AppColors.textHint, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/home/presentation/widgets/pet_places_section.dart b/flutter/lib/features/home/presentation/widgets/pet_places_section.dart new file mode 100644 index 0000000..bc847f5 --- /dev/null +++ b/flutter/lib/features/home/presentation/widgets/pet_places_section.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:wandergo/core/l10n/l10n.dart'; +import 'package:wandergo/core/router/app_routes.dart'; +import 'package:wandergo/core/utils/keep_all.dart'; +import 'package:wandergo/features/place/domain/place_search.dart'; +import 'package:wandergo/features/place/presentation/providers/place_providers.dart'; +import 'package:wandergo/features/place/presentation/widgets/place_tiles.dart'; +import 'package:wandergo/shared/widgets/load_error.dart'; +import 'package:wandergo/shared/widgets/section_header.dart'; + +/// 홈 '가까운 반려동물 동반 장소' — 기준 위치에서 가까운 3곳, 전체 보기는 반려견 동반 검색 +class PetPlacesSection extends ConsumerWidget { + const PetPlacesSection({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final places = ref.watch(homePetPlacesProvider); + final hasItems = places.value?.isNotEmpty ?? false; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SectionHeader( + title: l10n.homePetPlaces, + onSeeAll: hasItems + ? () => context.push( + AppRoutes.placeSearch(query: const PlaceQuery(petOnly: true)), + ) + : null, + ), + switch (places) { + AsyncData(:final value) when value.isEmpty => Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + keepAll(l10n.homePetPlacesEmpty), + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + AsyncData(:final value) => Column( + children: [ + for (var i = 0; i < value.length; i++) ...[ + if (i > 0) const Divider(), + PetPlaceRow( + item: value[i], + onTap: () => context.push(AppRoutes.placeDetail(value[i].id)), + ), + ], + ], + ), + AsyncError(:final error) => LoadError( + error: error, + onRetry: () => ref.invalidate(homePetPlacesProvider), + ), + _ => const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator()), + ), + }, + ], + ); + } +} diff --git a/flutter/lib/features/place/data/models/place_models.dart b/flutter/lib/features/place/data/models/place_models.dart new file mode 100644 index 0000000..f232f8a --- /dev/null +++ b/flutter/lib/features/place/data/models/place_models.dart @@ -0,0 +1,336 @@ +/// 장소 — 백엔드 PlaceListItemDto / PlaceDetailDto 와 1:1 대응한다. +library; + +import 'package:wandergo/shared/models/source_models.dart'; + +/// 코드값을 enum 으로 읽는다. 모르는 값이면 null — 백엔드가 값을 늘려도 앱이 죽지 않게 한다 +T? _parseCode( + List values, + Object? value, + String Function(T) code, +) { + for (final v in values) { + if (code(v) == value) return v; + } + return null; +} + +/// 빈 문자열은 값 없음으로 본다(공공데이터는 빈 칸을 ""로 주기도 한다) +String? _text(Object? value) { + if (value is! String) return null; + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; +} + +/// 자체 분류 (백엔드 PLACE_CATEGORIES) +enum PlaceCategory { + nature, + attraction, + culture, + leisure, + food, + cafe, + bar, + shopping, + lodging, + other; + + static PlaceCategory? parse(Object? value) => + _parseCode(values, value, (c) => c.name); +} + +/// 데이터 출처 — 관광공사 · 소상공인 상가정보 +enum PlaceSource { + tour, + store; + + static PlaceSource? parse(Object? value) => + _parseCode(values, value, (s) => s.name); +} + +/// 값의 출처 — 운영자 확인 · 공공데이터 · 미확인 +enum ValueOrigin { + curation, + public, + unknown; + + static ValueOrigin parse(Object? value) => + _parseCode(values, value, (o) => o.name) ?? unknown; +} + +/// 반려동물 동반 — 정보가 없으면 '불가'가 아니라 [unknown] 이다 +enum PetPolicy { + allowed('allowed'), + partial('partial'), + notAllowed('not_allowed'), + unknown('unknown'); + + const PetPolicy(this.code); + + final String code; + + /// 동반할 수 있는 곳(전 구역·일부 구역) + bool get isPetFriendly => this == allowed || this == partial; + + static PetPolicy parse(Object? value) => + _parseCode(values, value, (p) => p.code) ?? unknown; +} + +enum PetSize { + small, + medium, + large; + + static PetSize? parse(Object? value) => + _parseCode(values, value, (s) => s.name); +} + +/// 실내/실외 — 운영자가 확인한 장소만 값이 있다 +enum PlaceSetting { + indoor, + outdoor, + both; + + static PlaceSetting? parse(Object? value) => + _parseCode(values, value, (s) => s.name); +} + +/// 영업시간 해석 결과 — varies(점포별 상이)·unknown 이면 시간을 단정하지 않는다 +enum HoursStatus { + parsed('parsed'), + alwaysOpen('always_open'), + varies('varies'), + unknown('unknown'); + + const HoursStatus(this.code); + + final String code; + + static HoursStatus parse(Object? value) => + _parseCode(values, value, (s) => s.code) ?? unknown; +} + +/// 검색·목록의 장소 한 건 +class PlaceItem { + const PlaceItem({ + required this.id, + required this.name, + required this.lat, + required this.lng, + this.branchName, + this.category, + this.source, + this.address, + this.distanceM, + this.imageThumbUrl, + this.imageLicense, + this.hoursOrigin = ValueOrigin.unknown, + this.openNow, + this.petPolicy = PetPolicy.unknown, + this.petOrigin = ValueOrigin.unknown, + this.setting, + this.verifiedAt, + }); + + final String id; + final String name; + final String? branchName; + final PlaceCategory? category; + final PlaceSource? source; + final String? address; + final double lat; + final double lng; + + /// 기준 좌표에서의 거리(m). 좌표 없이 조회하면 null + final double? distanceM; + final String? imageThumbUrl; + final ImageLicense? imageLicense; + final ValueOrigin hoursOrigin; + + /// 지금(한국 시간) 영업 중인지. 모르면 null + final bool? openNow; + final PetPolicy petPolicy; + final ValueOrigin petOrigin; + final PlaceSetting? setting; + + /// 운영자 확인일 'YYYY-MM-DD'. 확인하지 않았으면 null + final String? verifiedAt; + + /// 화면에 보이는 이름 — 지점명이 있으면 붙인다("마실커피 동성로점") + String get displayName => branchName == null ? name : '$name $branchName'; + + /// 공공데이터·운영자 어느 쪽에서도 영업시간을 모르는 곳 — "영업시간 미확인 · 방문 전 확인" + bool get hoursUnknown => hoursOrigin == ValueOrigin.unknown; + + factory PlaceItem.fromJson(Map json) { + return PlaceItem( + id: json['id'] as String, + name: json['name'] as String, + branchName: _text(json['branchName']), + category: PlaceCategory.parse(json['category']), + source: PlaceSource.parse(json['source']), + address: _text(json['address']), + lat: (json['lat'] as num).toDouble(), + lng: (json['lng'] as num).toDouble(), + distanceM: (json['distanceM'] as num?)?.toDouble(), + imageThumbUrl: _text(json['imageThumbUrl']), + imageLicense: ImageLicense.parse(json['imageLicense']), + hoursOrigin: ValueOrigin.parse(json['hoursOrigin']), + openNow: json['openNow'] as bool?, + petPolicy: PetPolicy.parse(json['petPolicy']), + petOrigin: ValueOrigin.parse(json['petOrigin']), + setting: PlaceSetting.parse(json['setting']), + verifiedAt: _text(json['verifiedAt']), + ); + } +} + +/// 영업시간 +class PlaceHours { + const PlaceHours({ + this.text, + this.restText, + this.status = HoursStatus.unknown, + }); + + /// 원문(운영자 확인 값이 있으면 그것, 없으면 공공데이터) + final String? text; + + /// 쉬는 날 원문 + final String? restText; + final HoursStatus status; + + factory PlaceHours.fromJson(Map? json) { + if (json == null) return const PlaceHours(); + return PlaceHours( + text: _text(json['text']), + restText: _text(json['restText']), + status: HoursStatus.parse(json['status']), + ); + } +} + +/// 반려동물 동반 조건 +class PlacePet { + const PlacePet({this.sizes, this.conditions}); + + /// 동반 가능한 크기(운영자 확인). 모르면 null + final List? sizes; + + /// 조건 원문 — "목줄 착용 · 배변봉투 지참" 처럼 ' · ' 로 이어진다 + final String? conditions; + + factory PlacePet.fromJson(Map? json) { + if (json == null) return const PlacePet(); + final sizes = json['sizes'] as List?; + return PlacePet( + sizes: sizes?.map(PetSize.parse).whereType().toList(), + conditions: _text(json['conditions']), + ); + } +} + +/// 운영자 추천 정보 — 추천(O)하고 확인한 장소만 +class PlaceCuration { + const PlaceCuration({this.reason, this.officialUrl}); + + /// 추천 이유(운영자가 직접 쓴 글) + final String? reason; + + /// 업체 공식 채널 + final String? officialUrl; + + factory PlaceCuration.fromJson(Map json) { + return PlaceCuration( + reason: _text(json['reason']), + officialUrl: _text(json['officialUrl']), + ); + } +} + +/// 장소 상세 — 목록 필드에 상세 정보를 더한다 +class PlaceDetail { + const PlaceDetail({ + required this.item, + required this.attribution, + this.addressJibun, + this.phone, + this.homepage, + this.overview, + this.imageUrl, + this.hours = const PlaceHours(), + this.pet = const PlacePet(), + this.extra = const {}, + this.curation, + }); + + final PlaceItem item; + final Attribution attribution; + final String? addressJibun; + final String? phone; + final String? homepage; + + /// 소개(공공데이터) + final String? overview; + + /// 큰 사진 + final String? imageUrl; + final PlaceHours hours; + final PlacePet pet; + + /// 이용 안내 원문 — 키는 백엔드 tour-mapper 의 INTRO_FIELDS(fee·parking·firstMenu …) + final Map extra; + final PlaceCuration? curation; + + factory PlaceDetail.fromJson(Map json) { + final extra = json['extra'] as Map?; + final curation = json['curation'] as Map?; + return PlaceDetail( + item: PlaceItem.fromJson(json), + attribution: Attribution.fromJson( + json['attribution'] as Map, + ), + addressJibun: _text(json['addressJibun']), + phone: _text(json['phone']), + homepage: _text(json['homepage']), + overview: _text(json['overview']), + imageUrl: _text(json['imageUrl']), + hours: PlaceHours.fromJson(json['hours'] as Map?), + pet: PlacePet.fromJson(json['pet'] as Map?), + extra: { + for (final entry in (extra ?? const {}).entries) + entry.key: ?_text(entry.value), + }, + curation: curation == null ? null : PlaceCuration.fromJson(curation), + ); + } +} + +/// 커서 페이지 — 응답 data 의 {items, pageInfo} +class PlacePage { + const PlacePage({ + required this.items, + required this.hasNext, + this.nextCursor, + this.total, + }); + + final List items; + final bool hasNext; + final String? nextCursor; + + /// 조건에 맞는 전체 수. 계산 비용이 크면 null + final int? total; + + factory PlacePage.fromJson(Map json) { + final pageInfo = json['pageInfo'] as Map; + return PlacePage( + items: (json['items'] as List) + .map((e) => PlaceItem.fromJson(e as Map)) + .toList(), + hasNext: pageInfo['hasNext'] as bool? ?? false, + nextCursor: pageInfo['nextCursor'] as String?, + total: pageInfo['total'] as int?, + ); + } +} diff --git a/flutter/lib/features/place/data/repositories/place_repository.dart b/flutter/lib/features/place/data/repositories/place_repository.dart new file mode 100644 index 0000000..aaf3070 --- /dev/null +++ b/flutter/lib/features/place/data/repositories/place_repository.dart @@ -0,0 +1,52 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:wandergo/core/api/dio_provider.dart'; +import 'package:wandergo/features/location/domain/base_location.dart'; +import 'package:wandergo/features/place/data/models/place_models.dart'; + +/// 장소 API — `GET /places`, `GET /places/{id}` (로그인 없이 쓴다). +/// +/// 응답 언래핑과 에러 코드 매핑은 dio 인터셉터가 맡는다. 여기서는 파싱만 한다. +class PlaceRepository { + const PlaceRepository(this._dio); + + final Dio _dio; + + /// 장소 검색. [near] 를 주면 [radiusM] 안을 거리순으로, 없으면 이름순으로 준다. + /// [categories] 가 비면 전체 분류, [petOnly] 면 반려동물 동반(전 구역·일부 구역) 장소만. + Future search({ + String? query, + List categories = const [], + bool petOnly = false, + GeoPoint? near, + int? radiusM, + int limit = 20, + String? cursor, + }) async { + final response = await _dio.get>( + '/places', + queryParameters: { + 'q': ?query, + if (categories.isNotEmpty) + 'category': categories.map((c) => c.name).join(','), + if (petOnly) 'pet': 'true', + 'lat': ?near?.lat, + 'lng': ?near?.lng, + 'radius': ?radiusM, + 'limit': limit, + 'cursor': ?cursor, + }, + ); + return PlacePage.fromJson(response.data!); + } + + Future getPlace(String id) async { + final response = await _dio.get>('/places/$id'); + return PlaceDetail.fromJson(response.data!); + } +} + +final placeRepositoryProvider = Provider( + (ref) => PlaceRepository(ref.watch(dioProvider)), +); diff --git a/flutter/lib/features/place/domain/place_facts.dart b/flutter/lib/features/place/domain/place_facts.dart new file mode 100644 index 0000000..e994348 --- /dev/null +++ b/flutter/lib/features/place/domain/place_facts.dart @@ -0,0 +1,33 @@ +/// 공공데이터 원문에서 화면용 사실을 꺼내는 순수 함수. +/// +/// 원문은 사람이 쓴 문장이라 형식이 제각각이다. 확실한 경우만 true 로 보고, +/// 애매하면 '모름'으로 둔다(한눈에 보기에 단정해서 보이지 않는다). +library; + +/// 주차 원문 → 가능 true · 불가 false · 모름 null. +/// 예: "가능", "주차가능(20대)" → true / "불가능", "없음" → false / "인근 공영주차장 이용" → null +bool? parkingAvailable(String? text) { + if (text == null) return null; + if (text.contains('불가') || text.contains('없음')) return false; + if (text.contains('가능') || text.contains('있음')) return true; + return null; +} + +/// 입장료 원문이 무료인지 — "무료"만 있고 "유료"가 섞이지 않을 때만 true +bool isFreeEntry(String? fee) => + fee != null && fee.contains('무료') && !fee.contains('유료'); + +/// 반려동물 조건 원문을 항목으로 나눈다 — ' · ' 와 줄바꿈 기준 +List splitConditions(String? text) { + if (text == null) return const []; + return text + .split(RegExp(r'\s·\s|\n')) + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); +} + +/// 금지·불가를 말하는 조건인지 — 빨간 금지 아이콘으로 보인다. +/// "제한 없음"은 허용이라 뺀다. +bool isRestriction(String condition) => + RegExp(r'불가|금지|제한(?!\s*없)').hasMatch(condition); diff --git a/flutter/lib/features/place/domain/place_search.dart b/flutter/lib/features/place/domain/place_search.dart new file mode 100644 index 0000000..85c32e5 --- /dev/null +++ b/flutter/lib/features/place/domain/place_search.dart @@ -0,0 +1,67 @@ +import 'package:wandergo/features/place/data/models/place_models.dart'; + +/// 검색 반경(m) — 백엔드 최대값. 대구 도심에서 20km 면 대구 전역이 들어온다. +/// 결과는 거리순이라 가까운 곳이 먼저 나온다. +const int kPlaceSearchRadiusM = 20000; + +/// 검색 화면의 분류 탭 — 백엔드 분류 여러 개를 사람이 고르는 단위로 묶는다. +/// +/// 디자인의 '축제' 탭은 행사 API 에 검색어 조건이 없어 아직 넣지 않았다 +/// (홈 카테고리의 '축제'는 행사 목록으로 간다). +enum PlaceTab { + all([]), + sights([PlaceCategory.attraction, PlaceCategory.leisure]), + food([PlaceCategory.food, PlaceCategory.cafe, PlaceCategory.bar]), + indoor([PlaceCategory.culture]), + stay([PlaceCategory.lodging]), + nature([PlaceCategory.nature]); + + const PlaceTab(this.categories); + + /// 백엔드에 보내는 분류. 비면 전체 + final List categories; + + /// 경로 쿼리 값을 읽는다. 모르는 값이면 [all] + static PlaceTab parse(String? value) { + for (final tab in values) { + if (tab.name == value) return tab; + } + return all; + } +} + +/// 검색 조건 — 검색어 · 분류 탭 · 반려동물 동반 +class PlaceQuery { + const PlaceQuery({ + this.text = '', + this.tab = PlaceTab.all, + this.petOnly = false, + }); + + final String text; + final PlaceTab tab; + final bool petOnly; + + /// 백엔드에 보내는 검색어 — 앞뒤 공백을 빼고, 비면 null + String? get queryOrNull { + final trimmed = text.trim(); + return trimmed.isEmpty ? null : trimmed; + } + + PlaceQuery copyWith({String? text, PlaceTab? tab, bool? petOnly}) => + PlaceQuery( + text: text ?? this.text, + tab: tab ?? this.tab, + petOnly: petOnly ?? this.petOnly, + ); + + @override + bool operator ==(Object other) => + other is PlaceQuery && + other.queryOrNull == queryOrNull && + other.tab == tab && + other.petOnly == petOnly; + + @override + int get hashCode => Object.hash(queryOrNull, tab, petOnly); +} diff --git a/flutter/lib/features/place/presentation/pages/place_detail_page.dart b/flutter/lib/features/place/presentation/pages/place_detail_page.dart new file mode 100644 index 0000000..fd0bd35 --- /dev/null +++ b/flutter/lib/features/place/presentation/pages/place_detail_page.dart @@ -0,0 +1,306 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:wandergo/core/icons/app_icons.dart'; +import 'package:wandergo/core/l10n/l10n.dart'; +import 'package:wandergo/core/theme/app_colors.dart'; +import 'package:wandergo/core/theme/app_theme.dart'; +import 'package:wandergo/core/utils/format.dart'; +import 'package:wandergo/core/utils/geo.dart'; +import 'package:wandergo/core/utils/keep_all.dart'; +import 'package:wandergo/features/location/domain/base_location.dart'; +import 'package:wandergo/features/location/presentation/providers/base_location_provider.dart'; +import 'package:wandergo/features/location/presentation/widgets/region_sheet.dart'; +import 'package:wandergo/features/place/data/models/place_models.dart'; +import 'package:wandergo/features/place/presentation/providers/place_providers.dart'; +import 'package:wandergo/features/place/presentation/widgets/place_detail_sections.dart'; +import 'package:wandergo/features/place/presentation/widgets/place_visuals.dart'; +import 'package:wandergo/shared/models/source_models.dart'; +import 'package:wandergo/shared/widgets/load_error.dart'; + +/// 대표 사진 높이 — 사진이 없으면 낮춘다(분류 자리 표시만 있으므로) +const double _kHeroHeight = 300; +const double _kHeroHeightNoPhoto = 180; + +/// 본문이 사진 위로 올라오는 만큼(둥근 윗변) +const double _kSheetOverlap = 16; + +/// 6·7 장소 상세 — 사진 · 이름 · 한눈에 보기 · 반려동물 · 이용 안내 · 소개 · 근처 축제 · 함께 가기 좋은 곳. +/// +/// 디자인의 저장·공유·정보 정정 요청·지도 미리보기·AI 코스 만들기·길찾기·코스에 추가는 +/// 로그인·코스·지도 기능을 만들 때 붙인다. +class PlaceDetailPage extends ConsumerWidget { + const PlaceDetailPage({super.key, required this.id}); + + final String id; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final detail = ref.watch(placeDetailProvider(id)); + return Scaffold( + body: switch (detail) { + AsyncData(:final value) => _DetailBody(detail: value), + AsyncError(:final error) => SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding(padding: EdgeInsets.all(12), child: _BackButton()), + Padding( + padding: const EdgeInsets.all(kPagePadding), + child: LoadError( + error: error, + onRetry: () => ref.invalidate(placeDetailProvider(id)), + ), + ), + ], + ), + ), + _ => const SafeArea( + child: Stack( + children: [ + Center(child: CircularProgressIndicator()), + Padding(padding: EdgeInsets.all(12), child: _BackButton()), + ], + ), + ), + }, + ); + } +} + +class _DetailBody extends StatelessWidget { + const _DetailBody({required this.detail}); + + final PlaceDetail detail; + + @override + Widget build(BuildContext context) { + final item = detail.item; + final topInset = MediaQuery.paddingOf(context).top; + return Stack( + children: [ + ListView( + padding: EdgeInsets.zero, + children: [ + _Hero(detail: detail), + Transform.translate( + offset: const Offset(0, -_kSheetOverlap), + child: Container( + decoration: const BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: const EdgeInsets.fromLTRB( + kPagePadding, + 24, + kPagePadding, + 28, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _TitleBlock(detail: detail), + PlaceGlance(detail: detail), + if (item.petPolicy != PetPolicy.unknown) + PlacePetSection(detail: detail), + PlaceInfoSection(detail: detail), + if (detail.overview != null) + PlaceAboutSection( + text: detail.overview!, + provider: detail.attribution.provider, + ), + NearbyEventsSection(point: (lat: item.lat, lng: item.lng)), + NearbyPlacesSection( + placeKey: (id: item.id, lat: item.lat, lng: item.lng), + ), + _SourceFooter(attribution: detail.attribution), + ], + ), + ), + ), + ], + ), + // 내려도 늘 보이는 뒤로 버튼 + Positioned(top: topInset + 12, left: 12, child: const _BackButton()), + ], + ); + } +} + +/// 흰 원 안의 뒤로 버튼 — 사진 위에서도 보이게 옅은 그림자 +class _BackButton extends StatelessWidget { + const _BackButton(); + + @override + Widget build(BuildContext context) { + return Material( + color: AppColors.background, + shape: const CircleBorder(), + elevation: 1, + shadowColor: AppColors.text.withValues(alpha: 0.4), + child: IconButton( + tooltip: context.l10n.back, + onPressed: () => context.pop(), + icon: const AppIcon(AppIconData.chevronLeft, color: AppColors.text), + ), + ); + } +} + +/// 대표 사진 + 사진 출처(공공누리 유형) +class _Hero extends StatelessWidget { + const _Hero({required this.detail}); + + final PlaceDetail detail; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final item = detail.item; + final url = detail.imageUrl ?? item.imageThumbUrl; + final license = item.imageLicense; + final height = url == null ? _kHeroHeightNoPhoto : _kHeroHeight; + return SizedBox( + height: height, + child: Stack( + fit: StackFit.expand, + children: [ + PlaceImage( + url: url, + category: item.category, + license: license, + height: height, + radius: 0, + iconSize: 40, + ), + if (url != null && license != null) ...[ + const Positioned( + left: 0, + right: 0, + bottom: 0, + height: 110, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x00161A23), Color(0x6B161A23)], + ), + ), + ), + ), + Positioned( + left: kPagePadding, + right: kPagePadding, + bottom: _kSheetOverlap + 12, + child: Text( + l10n.photoCredit( + detail.attribution.provider, + license == ImageLicense.type3 + ? l10n.koglType3 + : l10n.koglType1, + ), + style: const TextStyle(fontSize: 11, color: Colors.white), + ), + ), + ], + ], + ), + ); + } +} + +/// 분류·지역 · 이름 · 추천 이유 · 기준 위치에서의 거리 +class _TitleBlock extends ConsumerWidget { + const _TitleBlock({required this.detail}); + + final PlaceDetail detail; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final textTheme = Theme.of(context).textTheme; + final item = detail.item; + final meta = [ + ?item.category?.label(l10n), + ?shortRegion(item.address), + ].join(' · '); + final reason = detail.curation?.reason; + final distance = _distanceText(l10n, ref.watch(basePointProvider).value); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (meta.isNotEmpty) Text(meta, style: textTheme.bodyMedium), + const SizedBox(height: 6), + Text( + keepAll(item.displayName), + style: textTheme.titleLarge?.copyWith(fontSize: 24, height: 32 / 24), + ), + if (reason != null) ...[ + const SizedBox(height: 8), + Text(keepAll(reason), style: textTheme.bodyLarge), + ], + if (distance != null) ...[ + const SizedBox(height: 6), + Text(distance, style: textTheme.bodyMedium), + ], + ], + ); + } + + /// "현재 위치에서 도보 8분 · 650m" / "대구 도심에서 2.4km" — 상세 응답에는 거리가 없어 여기서 잰다 + String? _distanceText(AppLocalizations l10n, BasePoint? base) { + if (base == null) return null; + final item = detail.item; + final meters = distanceMeters( + base.point.lat, + base.point.lng, + item.lat, + item.lng, + ); + final value = walkAndDistance(l10n, meters); + if (base.choice.usesCurrentLocation && !base.fellBack) { + return l10n.distanceFromHere(value); + } + final place = base.fellBack + ? kFallbackNeighborhood.label(l10n) + : base.choice.label(l10n); + return l10n.distanceFromPlace(place, value); + } +} + +/// 맨 아래 출처 — 제공 기관 · 기준 시점 · 방문 전 확인 안내 +class _SourceFooter extends StatelessWidget { + const _SourceFooter({required this.attribution}); + + final Attribution attribution; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final asOf = attribution.asOf; + final style = Theme.of( + context, + ).textTheme.bodySmall?.copyWith(height: 18 / 12, color: AppColors.textHint); + return Padding( + padding: const EdgeInsets.only(top: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Divider(), + const SizedBox(height: 16), + Text( + asOf == null + ? l10n.sourceLabel(attribution.provider) + : l10n.sourceAsOf(attribution.provider, formatSourceDate(asOf)), + style: style, + ), + const SizedBox(height: 4), + Text(keepAll(l10n.footerNotice), style: style), + ], + ), + ); + } +} diff --git a/flutter/lib/features/place/presentation/pages/place_search_page.dart b/flutter/lib/features/place/presentation/pages/place_search_page.dart new file mode 100644 index 0000000..5186738 --- /dev/null +++ b/flutter/lib/features/place/presentation/pages/place_search_page.dart @@ -0,0 +1,586 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:wandergo/core/icons/app_icons.dart'; +import 'package:wandergo/core/l10n/l10n.dart'; +import 'package:wandergo/core/router/app_routes.dart'; +import 'package:wandergo/core/theme/app_colors.dart'; +import 'package:wandergo/core/theme/app_theme.dart'; +import 'package:wandergo/core/utils/keep_all.dart'; +import 'package:wandergo/features/location/presentation/providers/base_location_provider.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/domain/place_search.dart'; +import 'package:wandergo/features/place/presentation/widgets/place_tiles.dart'; +import 'package:wandergo/features/place/presentation/widgets/place_visuals.dart'; +import 'package:wandergo/shared/widgets/load_error.dart'; + +/// 한 번에 받는 수 +const int _kPageSize = 20; + +/// 끝에서 이만큼 남으면 다음 쪽을 미리 받는다 +const int _kPrefetchThreshold = 5; + +/// 입력을 멈춘 뒤 이만큼 지나면 찾는다(글자마다 요청하지 않게) +const Duration _kTypingDebounce = Duration(milliseconds: 350); + +/// 4 검색 — 검색어 · 분류 탭 · 반려견 동반 조건으로 기준 위치에서 가까운 순. +/// +/// 디자인의 '이번 주말'·'실내'·'무료 입장'·'주차' 조건과 날씨 안내, '지도로 보기'·'코스에 추가'는 +/// 데이터(운영자 확인·날씨)와 지도·코스 기능이 생길 때 붙인다. +class PlaceSearchPage extends ConsumerStatefulWidget { + const PlaceSearchPage({ + super.key, + this.initialQuery = const PlaceQuery(), + this.autofocus = false, + }); + + final PlaceQuery initialQuery; + + /// 홈 검색창에서 들어오면 바로 입력할 수 있게 한다 + final bool autofocus; + + @override + ConsumerState createState() => _PlaceSearchPageState(); +} + +class _PlaceSearchPageState extends ConsumerState { + late final TextEditingController _controller; + late PlaceQuery _query; + Timer? _debounce; + + final List _items = []; + String? _cursor; + bool _hasNext = true; + bool _loading = false; + int? _total; + Object? _error; + + /// 조건이 바뀔 때마다 올린다 — 늦게 도착한 예전 조건의 응답을 버린다 + int _generation = 0; + + @override + void initState() { + super.initState(); + _query = widget.initialQuery; + _controller = TextEditingController(text: _query.text); + _loadMore(); + } + + @override + void dispose() { + _debounce?.cancel(); + _controller.dispose(); + super.dispose(); + } + + void _setQuery(PlaceQuery next) { + _debounce?.cancel(); + if (next == _query) return; + setState(() { + _query = next; + _generation++; + _items.clear(); + _cursor = null; + _hasNext = true; + _loading = false; + _total = null; + _error = null; + }); + _loadMore(); + } + + void _onTextChanged(String text) { + // 지우기 버튼 표시를 바로 바꾼다 + setState(() {}); + _debounce?.cancel(); + _debounce = Timer( + _kTypingDebounce, + () => _setQuery(_query.copyWith(text: text)), + ); + } + + void _clearText() { + _controller.clear(); + _setQuery(_query.copyWith(text: '')); + } + + Future _loadMore() async { + if (_loading || !_hasNext) return; + final generation = _generation; + final query = _query; + setState(() { + _loading = true; + _error = null; + }); + try { + final base = await ref.read(basePointProvider.future); + final page = await ref + .read(placeRepositoryProvider) + .search( + query: query.queryOrNull, + categories: query.tab.categories, + petOnly: query.petOnly, + near: base.point, + radiusM: kPlaceSearchRadiusM, + limit: _kPageSize, + cursor: _cursor, + ); + if (!mounted || generation != _generation) return; + setState(() { + _items.addAll(page.items); + _cursor = page.nextCursor; + _hasNext = page.hasNext; + _total = page.total ?? _total; + }); + } on Exception catch (e) { + // 문구는 LoadError 가 에러 사전에서 가져온다 + if (mounted && generation == _generation) setState(() => _error = e); + } finally { + if (mounted && generation == _generation) { + setState(() => _loading = false); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Column( + children: [ + _SearchHeader( + controller: _controller, + autofocus: widget.autofocus, + onChanged: _onTextChanged, + onSubmitted: (text) => _setQuery(_query.copyWith(text: text)), + onClear: _clearText, + ), + _TabBar( + selected: _query.tab, + onSelected: (tab) => _setQuery(_query.copyWith(tab: tab)), + ), + _FilterRow( + petOnly: _query.petOnly, + onPetChanged: (value) => + _setQuery(_query.copyWith(petOnly: value)), + ), + Expanded(child: _results(context)), + ], + ), + ), + ); + } + + Widget _results(BuildContext context) { + final error = _error; + if (_items.isEmpty) { + if (error != null) { + return Padding( + padding: const EdgeInsets.all(kPagePadding), + child: Align( + alignment: Alignment.topCenter, + child: LoadError(error: error, onRetry: _loadMore), + ), + ); + } + if (!_hasNext && !_loading) { + return _EmptyResult( + petOnly: _query.petOnly, + onRemovePet: () => _setQuery(_query.copyWith(petOnly: false)), + ); + } + return const Center(child: CircularProgressIndicator()); + } + + return ListView.separated( + padding: const EdgeInsets.fromLTRB(kPagePadding, 0, kPagePadding, 24), + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + itemCount: _items.length + 2, + separatorBuilder: (_, index) => + index < _items.length ? const Divider() : const SizedBox.shrink(), + itemBuilder: (context, index) { + if (index == 0) return _ResultHeader(total: _total); + final i = index - 1; + if (i >= _items.length - _kPrefetchThreshold && + _hasNext && + _error == null) { + WidgetsBinding.instance.addPostFrameCallback((_) => _loadMore()); + } + if (i == _items.length) return _footer(context); + final item = _items[i]; + return PlaceRow( + item: item, + onTap: () => context.push(AppRoutes.placeDetail(item.id)), + ); + }, + ); + } + + Widget _footer(BuildContext context) { + final error = _error; + if (error != null) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: LoadError(error: error, onRetry: _loadMore), + ); + } + if (_hasNext) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator()), + ); + } + return const _SourceNote(); + } +} + +/// 뒤로 + 검색 입력창 +class _SearchHeader extends StatelessWidget { + const _SearchHeader({ + required this.controller, + required this.autofocus, + required this.onChanged, + required this.onSubmitted, + required this.onClear, + }); + + final TextEditingController controller; + final bool autofocus; + final ValueChanged onChanged; + final ValueChanged onSubmitted; + final VoidCallback onClear; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 16, 8), + child: Row( + children: [ + IconButton( + tooltip: l10n.back, + onPressed: () => context.pop(), + icon: const AppIcon(AppIconData.chevronLeft, color: AppColors.text), + ), + const SizedBox(width: 4), + Expanded( + child: SizedBox( + height: 44, + child: TextField( + controller: controller, + autofocus: autofocus, + textInputAction: TextInputAction.search, + onChanged: onChanged, + onSubmitted: onSubmitted, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppColors.text, + height: 21 / 15, + ), + decoration: InputDecoration( + hintText: l10n.searchHint, + contentPadding: EdgeInsets.zero, + prefixIcon: const Padding( + padding: EdgeInsets.only(left: 12, right: 8), + child: AppIcon( + AppIconData.search, + size: 18, + color: AppColors.textTertiary, + strokeWidth: 1.75, + ), + ), + prefixIconConstraints: const BoxConstraints(), + suffixIcon: controller.text.isEmpty + ? null + : IconButton( + tooltip: l10n.searchClear, + onPressed: onClear, + icon: Container( + width: 18, + height: 18, + decoration: const BoxDecoration( + color: AppColors.textHint, + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: const AppIcon( + AppIconData.close, + size: 10, + color: Colors.white, + strokeWidth: 3.5, + ), + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +/// 분류 탭 — 글자 + 선택된 탭 아래 굵은 선 (가로로 밀어 본다) +class _TabBar extends StatelessWidget { + const _TabBar({required this.selected, required this.onSelected}); + + final PlaceTab selected; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Container( + height: 44, + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: AppColors.divider)), + ), + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: kPagePadding), + itemCount: PlaceTab.values.length, + separatorBuilder: (_, _) => const SizedBox(width: 20), + itemBuilder: (context, index) { + final tab = PlaceTab.values[index]; + final isSelected = tab == selected; + return Semantics( + selected: isSelected, + button: true, + child: InkWell( + onTap: () => onSelected(tab), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: isSelected ? AppColors.text : Colors.transparent, + width: 2, + ), + ), + ), + child: Text( + tab.label(l10n), + style: TextStyle( + fontSize: 15, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: isSelected ? AppColors.text : AppColors.textTertiary, + ), + ), + ), + ), + ); + }, + ), + ); + } +} + +/// 조건 칩 — 지금은 데이터가 있는 '반려견 동반' 하나 +class _FilterRow extends StatelessWidget { + const _FilterRow({required this.petOnly, required this.onPetChanged}); + + final bool petOnly; + final ValueChanged onPetChanged; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: kPagePadding, + vertical: 12, + ), + child: Row( + children: [ + FilterToggleChip( + label: context.l10n.filterPet, + selected: petOnly, + onChanged: onPetChanged, + ), + ], + ), + ); + } +} + +/// 켜고 끄는 조건 칩 — 켜면 연한 브랜드 배경 + 굵은 글자 +class FilterToggleChip extends StatelessWidget { + const FilterToggleChip({ + super.key, + required this.label, + required this.selected, + required this.onChanged, + }); + + final String label; + final bool selected; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final shape = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(999), + side: BorderSide( + color: selected ? AppColors.chipBorderSelected : AppColors.chipBorder, + ), + ); + return Semantics( + toggled: selected, + button: true, + excludeSemantics: true, + label: label, + child: Material( + color: selected ? AppColors.brandSoft : AppColors.background, + shape: shape, + child: InkWell( + customBorder: shape, + onTap: () => onChanged(!selected), + child: Container( + height: 36, + padding: const EdgeInsets.symmetric(horizontal: 12), + alignment: Alignment.center, + child: Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + color: selected ? AppColors.text : AppColors.label, + ), + ), + ), + ), + ), + ); + } +} + +/// 결과 수 · 정렬 +class _ResultHeader extends StatelessWidget { + const _ResultHeader({required this.total}); + + final int? total; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final count = total; + return SizedBox( + height: 44, + child: Row( + children: [ + if (count != null) + Text( + l10n.searchCount(count), + style: const TextStyle( + fontSize: 14, + height: 20 / 14, + fontWeight: FontWeight.w600, + color: AppColors.text, + ), + ), + const Spacer(), + Text( + l10n.searchSortNearest, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: AppColors.textSecondary), + ), + ], + ), + ); + } +} + +/// 결과 없음 — 조건을 줄이도록 안내한다 +class _EmptyResult extends StatelessWidget { + const _EmptyResult({required this.petOnly, required this.onRemovePet}); + + final bool petOnly; + final VoidCallback onRemovePet; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final textTheme = Theme.of(context).textTheme; + return ListView( + padding: const EdgeInsets.fromLTRB(28, 40, 28, 44), + children: [ + Center( + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: AppColors.placeholder, + borderRadius: BorderRadius.circular(20), + ), + alignment: Alignment.center, + child: const AppIcon( + AppIconData.search, + size: 30, + color: AppColors.textHint, + ), + ), + ), + const SizedBox(height: 16), + Text( + keepAll(l10n.searchEmptyTitle), + textAlign: TextAlign.center, + style: textTheme.titleMedium?.copyWith( + fontSize: 17, + height: 25 / 17, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Text( + keepAll(petOnly ? l10n.searchEmptyPetBody : l10n.searchEmptyBody), + textAlign: TextAlign.center, + style: textTheme.bodyMedium?.copyWith(fontSize: 14, height: 21 / 14), + ), + if (petOnly) ...[ + const SizedBox(height: 20), + Center( + child: OutlinedButton( + onPressed: onRemovePet, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 18), + side: const BorderSide(color: AppColors.chipBorder), + textStyle: textTheme.labelLarge?.copyWith(fontSize: 15), + ), + child: Text(l10n.searchRemovePet), + ), + ), + ], + ], + ); + } +} + +/// 목록 끝의 출처 표기 +class _SourceNote extends StatelessWidget { + const _SourceNote(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Divider(), + const SizedBox(height: 16), + Text( + keepAll(context.l10n.placeSources), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + height: 18 / 12, + color: AppColors.textHint, + ), + ), + ], + ), + ); + } +} diff --git a/flutter/lib/features/place/presentation/providers/place_providers.dart b/flutter/lib/features/place/presentation/providers/place_providers.dart new file mode 100644 index 0000000..0b8da1a --- /dev/null +++ b/flutter/lib/features/place/presentation/providers/place_providers.dart @@ -0,0 +1,86 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:wandergo/core/clock.dart'; +import 'package:wandergo/core/utils/weekend.dart'; +import 'package:wandergo/features/event/data/models/event_models.dart'; +import 'package:wandergo/features/event/data/repositories/event_repository.dart'; +import 'package:wandergo/features/event/presentation/providers/event_providers.dart'; +import 'package:wandergo/features/location/domain/base_location.dart'; +import 'package:wandergo/features/location/presentation/providers/base_location_provider.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/domain/place_search.dart'; + +/// 홈 '가까운 반려동물 동반 장소' 에 보이는 수 +const int kHomePetPlaceLimit = 3; + +/// 장소 상세 '근처 축제·행사' 반경(m) — 없으면 칸을 숨긴다(2026-11-03 합의) +const int kNearbyEventRadiusM = 5000; +const int kNearbyEventLimit = 3; + +/// 장소 상세 '함께 가기 좋은 곳' — 반경 안에서 받아 고른 뒤 이만큼 보인다 +const int kNearbyPlaceRadiusM = 1500; +const int kNearbyPlaceFetchLimit = 30; +const int kNearbyPlaceLimit = 6; + +/// 장소 상세 +final placeDetailProvider = FutureProvider.family( + (ref, id) => ref.watch(placeRepositoryProvider).getPlace(id), +); + +/// 홈 '가까운 반려동물 동반 장소' — 기준 위치에서 가까운 순 +final homePetPlacesProvider = FutureProvider>((ref) async { + final base = await ref.watch(basePointProvider.future); + final page = await ref + .watch(placeRepositoryProvider) + .search( + petOnly: true, + near: base.point, + radiusM: kPlaceSearchRadiusM, + limit: kHomePetPlaceLimit, + ); + return page.items; +}); + +/// 장소 상세 '근처 축제·행사' — 그 장소 반경 5km, 오늘부터 30일 +final nearbyEventsProvider = FutureProvider.family, GeoPoint>(( + ref, + point, +) async { + final now = ref.watch(clockProvider)(); + final page = await ref + .watch(eventRepositoryProvider) + .search( + range: upcomingRange(now, days: kUpcomingDays), + near: point, + radiusM: kNearbyEventRadiusM, + limit: kNearbyEventLimit, + ); + return page.items; +}); + +/// 장소 상세 '함께 가기 좋은 곳' 의 기준 — 장소 id 와 좌표 +typedef NearbyPlaceKey = ({String id, double lat, double lng}); + +/// 장소 상세 '함께 가기 좋은 곳' — 그 장소에서 가까운 순. +/// +/// 확인 안 된 상가정보 매장은 고르지 않는다(관광공사 장소 또는 운영자가 확인한 장소만). +/// 폐업·분위기를 검증할 수 없는 곳을 추천처럼 보이지 않으려는 것이다(2026-11-02 합의). +final nearbyPlacesProvider = + FutureProvider.family, NearbyPlaceKey>((ref, key) async { + final page = await ref + .watch(placeRepositoryProvider) + .search( + near: (lat: key.lat, lng: key.lng), + radiusM: kNearbyPlaceRadiusM, + limit: kNearbyPlaceFetchLimit, + ); + return page.items + .where( + (p) => + p.id != key.id && + (p.source == PlaceSource.tour || p.verifiedAt != null), + ) + .take(kNearbyPlaceLimit) + .toList(); + }); diff --git a/flutter/lib/features/place/presentation/widgets/place_detail_sections.dart b/flutter/lib/features/place/presentation/widgets/place_detail_sections.dart new file mode 100644 index 0000000..f75721c --- /dev/null +++ b/flutter/lib/features/place/presentation/widgets/place_detail_sections.dart @@ -0,0 +1,708 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:wandergo/core/icons/app_icons.dart'; +import 'package:wandergo/core/l10n/l10n.dart'; +import 'package:wandergo/core/router/app_routes.dart'; +import 'package:wandergo/core/theme/app_colors.dart'; +import 'package:wandergo/core/utils/keep_all.dart'; +import 'package:wandergo/features/event/presentation/widgets/event_card.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/domain/place_facts.dart'; +import 'package:wandergo/features/place/presentation/providers/place_providers.dart'; +import 'package:wandergo/features/place/presentation/widgets/place_tiles.dart'; +import 'package:wandergo/features/place/presentation/widgets/place_visuals.dart'; +import 'package:wandergo/shared/utils/open_link.dart'; + +/// 관광통역안내 전화(영어·일본어·중국어 24시간) — 영어 화면에만 보인다(2026-10-29 합의) +const String kTravelHotline = '1330'; + +/// 이용 안내 왼쪽 이름 칸 너비 +const double _kInfoLabelWidth = 72; + +/// 이용 안내 오른쪽 동작 칸 너비 — 글자(복사·전화) · 아이콘(외부 링크) +const double _kTextActionWidth = 56; +const double _kLinkActionWidth = 44; + +/// 소개를 접어 둘 때의 줄 수 +const int _kAboutCollapsedLines = 5; + +/// 함께 가기 좋은 곳 가로 목록 높이 — 사진 104 + 여백·글자 세 줄 +const double _kPlaceStripHeight = 180; + +/// 문의처 원문에서 전화번호를 찾는다("대구관광정보센터 053-627-1337") +final RegExp _phonePattern = RegExp(r'\d{2,4}-\d{3,4}-\d{4}'); + +/// 섹션 사이 구분 — 위아래 24 여백 + 아래 구분선 +class _Section extends StatelessWidget { + const _Section({required this.title, required this.children, this.gap = 14}); + + final String title; + final List children; + final double gap; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 24), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: AppColors.divider)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleMedium), + for (final child in children) ...[SizedBox(height: gap), child], + ], + ), + ); + } +} + +/// 한눈에 보기 — 반려동물 · 주차 · 입장 무료 · 상시 개방(확실한 것만) +class PlaceGlance extends StatelessWidget { + const PlaceGlance({super.key, required this.detail}); + + final PlaceDetail detail; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final item = detail.item; + final pet = petLabel(l10n, item.petPolicy); + final facts = <({AppIconData icon, String label, bool emphasis})>[ + if (pet != null) (icon: AppIconData.paw, label: pet, emphasis: true), + if (parkingAvailable(detail.extra['parking']) ?? false) + (icon: AppIconData.parking, label: l10n.glanceParking, emphasis: false), + if (isFreeEntry(detail.extra['fee'])) + (icon: AppIconData.ticket, label: l10n.glanceFree, emphasis: false), + if (detail.hours.status == HoursStatus.alwaysOpen) + (icon: AppIconData.clock, label: l10n.glanceAlwaysOpen, emphasis: false) + else if (item.openNow ?? false) + (icon: AppIconData.clock, label: l10n.placeOpenNow, emphasis: false), + ]; + if (facts.isEmpty) return const SizedBox(height: 4); + + return Semantics( + label: l10n.placeGlance, + container: true, + child: Container( + margin: const EdgeInsets.only(top: 20), + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: const BoxDecoration( + border: Border( + top: BorderSide(color: AppColors.divider), + bottom: BorderSide(color: AppColors.divider), + ), + ), + child: Row( + children: [ + for (final fact in facts) + Expanded( + child: Column( + children: [ + AppIcon( + fact.icon, + color: fact.emphasis + ? AppColors.pet + : AppColors.textSecondary, + filled: fact.emphasis, + ), + const SizedBox(height: 8), + Text( + fact.label, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + height: 16 / 12, + fontWeight: fact.emphasis + ? FontWeight.w600 + : FontWeight.w400, + color: fact.emphasis + ? AppColors.pet + : AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// 반려동물 동반 정보 — 동반 여부 · 크기 · 조건 · 확인 근거 +class PlacePetSection extends StatelessWidget { + const PlacePetSection({super.key, required this.detail}); + + final PlaceDetail detail; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final textTheme = Theme.of(context).textTheme; + final item = detail.item; + final sizes = detail.pet.sizes ?? const []; + final conditions = splitConditions(detail.pet.conditions); + final verifiedAt = item.verifiedAt; + final source = item.petOrigin == ValueOrigin.curation && verifiedAt != null + ? l10n.placeVerifiedOn(verifiedAt) + : l10n.petSourcePublic(detail.attribution.provider); + final pet = petLabel(l10n, item.petPolicy); + + return _Section( + title: l10n.placePetSection, + children: [ + if (pet != null) + PetLabel(text: pet, fontSize: 14) + else + _ConditionLine(text: l10n.petNotAllowed, restriction: true), + if (sizes.isNotEmpty) + Semantics( + label: l10n.petSizesLabel, + container: true, + child: Wrap( + spacing: 18, + runSpacing: 8, + children: [ + for (final size in sizes) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const AppIcon( + AppIconData.check, + size: 16, + color: AppColors.pet, + strokeWidth: 2.25, + ), + const SizedBox(width: 4), + Text( + size.label(l10n), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.pet, + ), + ), + ], + ), + ], + ), + ), + if (conditions.isNotEmpty) + Column( + children: [ + for (var i = 0; i < conditions.length; i++) ...[ + if (i > 0) const SizedBox(height: 10), + _ConditionLine( + text: conditions[i], + restriction: isRestriction(conditions[i]), + ), + ], + ], + ), + Text( + keepAll('$source\n${l10n.petCheckOnSite}'), + style: textTheme.bodySmall?.copyWith(height: 18 / 12), + ), + ], + ); + } +} + +/// 조건 한 줄 — 안내(i) 또는 금지(x) 아이콘 + 글자 +class _ConditionLine extends StatelessWidget { + const _ConditionLine({required this.text, required this.restriction}); + + final String text; + final bool restriction; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 1), + child: AppIcon( + restriction ? AppIconData.ban : AppIconData.info, + size: 18, + color: restriction ? AppColors.danger : AppColors.textSecondary, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + keepAll(text), + style: const TextStyle( + fontSize: 14, + height: 20 / 14, + color: AppColors.text, + ), + ), + ), + ], + ); + } +} + +/// 이용 안내 — 주소 · 운영시간 · 쉬는 날 · 요금·주차 등 · 전화 · 홈페이지 (영어 화면은 1330) +class PlaceInfoSection extends StatelessWidget { + const PlaceInfoSection({super.key, required this.detail}); + + final PlaceDetail detail; + + /// 공공데이터 이용 안내를 보이는 순서와 이름 — 모르는 키는 보이지 않는다 + static List<(String, String)> _extraRows( + AppLocalizations l10n, + Map extra, + ) { + String? label(String key) => switch (key) { + 'fee' => l10n.infoFee, + 'parking' => l10n.infoParking, + 'parkingFee' => l10n.infoParkingFee, + 'spendTime' => l10n.infoSpendTime, + 'reservation' => l10n.infoReservation, + 'firstMenu' => l10n.infoFirstMenu, + 'treatMenu' => l10n.infoTreatMenu, + 'openPeriod' => l10n.infoOpenPeriod, + 'season' => l10n.infoSeason, + 'infocenter' => l10n.infoInquiry, + _ => null, + }; + const order = [ + 'fee', + 'parking', + 'parkingFee', + 'spendTime', + 'reservation', + 'firstMenu', + 'treatMenu', + 'openPeriod', + 'season', + 'infocenter', + ]; + return [ + for (final key in order) + if (extra[key] case final value?) + if (label(key) case final name?) (name, value), + ]; + } + + Future _copyAddress(BuildContext context, String address) async { + final messenger = ScaffoldMessenger.of(context); + final copied = context.l10n.addressCopied; + await Clipboard.setData(ClipboardData(text: address)); + messenger.showSnackBar(SnackBar(content: Text(copied))); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final item = detail.item; + final address = item.address; + final hours = detail.hours; + final phone = detail.phone; + final homepage = detail.homepage; + final official = detail.curation?.officialUrl; + final isKorean = l10n.localeName.startsWith('ko'); + + final rows = [ + if (address != null) + _InfoRow( + label: l10n.infoAddress, + value: address, + secondary: detail.addressJibun, + action: _TextAction( + label: l10n.actionCopy, + onPressed: () => _copyAddress(context, address), + ), + ), + _InfoRow( + label: l10n.infoHours, + value: hours.text ?? l10n.placeHoursUnknown, + secondary: hours.status == HoursStatus.varies + ? l10n.placeHoursVaries + : null, + ), + if (hours.restText != null) + _InfoRow(label: l10n.infoRest, value: hours.restText!), + for (final (name, value) in _extraRows(l10n, detail.extra)) + _InfoRow( + label: name, + value: value, + action: switch (_phonePattern.firstMatch(value)) { + final match? when name == l10n.infoInquiry => _TextAction( + label: l10n.actionCall, + onPressed: () => openPhone(context, match[0]!), + ), + _ => null, + }, + ), + if (phone != null) + _InfoRow( + label: l10n.infoPhone, + value: phone, + action: _TextAction( + label: l10n.actionCall, + onPressed: () => openPhone(context, phone), + ), + ), + if (homepage != null) + _InfoRow( + label: l10n.infoHomepage, + value: homepage, + maxLines: 1, + action: _LinkAction( + tooltip: l10n.openWebsite, + onPressed: () => openWebLink(context, homepage), + ), + ), + if (official != null && official != homepage) + _InfoRow( + label: l10n.infoOfficial, + value: official, + maxLines: 1, + action: _LinkAction( + tooltip: l10n.openWebsite, + onPressed: () => openWebLink(context, official), + ), + ), + ]; + + return _Section( + title: l10n.placeInfoSection, + gap: 12, + children: [ + Column(children: rows), + if (!isKorean) const _HotlineRow(), + ], + ); + } +} + +/// 이용 안내 한 줄 — 이름 · 값(보조 줄) · 오른쪽 동작 +class _InfoRow extends StatelessWidget { + const _InfoRow({ + required this.label, + required this.value, + this.secondary, + this.action, + this.maxLines, + }); + + final String label; + final String value; + final String? secondary; + final Widget? action; + final int? maxLines; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: _kInfoLabelWidth, + child: Text(label, style: textTheme.bodyMedium), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + maxLines == null ? keepAll(value) : value, + maxLines: maxLines, + overflow: maxLines == null ? null : TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + height: 20 / 14, + color: AppColors.text, + ), + ), + if (secondary != null) ...[ + const SizedBox(height: 2), + Text(keepAll(secondary!), style: textTheme.bodySmall), + ], + ], + ), + ), + ?action, + ], + ), + ); + } +} + +/// 글자 한 줄(20) 높이만 차지하고 누르는 영역(44)은 위아래로 넘치게 둔다 — 줄 높이를 늘리지 않는다. +/// 넘치게 두려면 너비를 정해야 해서 [width] 를 받는다. +class _LineOverflow extends StatelessWidget { + const _LineOverflow({required this.width, required this.child}); + + final double width; + final Widget child; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width, + height: 20, + child: OverflowBox( + minHeight: 44, + maxHeight: 44, + alignment: Alignment.centerRight, + child: child, + ), + ); + } +} + +/// 브랜드색 글자 동작(복사·전화) +class _TextAction extends StatelessWidget { + const _TextAction({required this.label, required this.onPressed}); + + final String label; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return _LineOverflow( + width: _kTextActionWidth, + child: TextButton( + onPressed: onPressed, + style: TextButton.styleFrom( + foregroundColor: AppColors.brand, + padding: const EdgeInsets.only(left: 8), + minimumSize: const Size(44, 44), + textStyle: Theme.of(context).textTheme.labelLarge, + ), + child: Text(label), + ), + ); + } +} + +/// 외부 사이트 열기 아이콘 +class _LinkAction extends StatelessWidget { + const _LinkAction({required this.tooltip, required this.onPressed}); + + final String tooltip; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return _LineOverflow( + width: _kLinkActionWidth, + child: IconButton( + tooltip: tooltip, + onPressed: onPressed, + icon: const AppIcon( + AppIconData.externalLink, + size: 16, + color: AppColors.textSecondary, + ), + ), + ); + } +} + +/// 1330 관광통역안내 — 외국어 화면에서만 +class _HotlineRow extends StatelessWidget { + const _HotlineRow(); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.hotlineTitle, + style: const TextStyle( + fontSize: 14, + height: 20 / 14, + fontWeight: FontWeight.w600, + color: AppColors.text, + ), + ), + const SizedBox(height: 2), + Text( + l10n.hotlineBody, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + OutlinedButton( + onPressed: () => openPhone(context, kTravelHotline), + child: Text(l10n.actionCall), + ), + ], + ); + } +} + +/// 소개 — 길면 접어 두고 '더 보기' +class PlaceAboutSection extends StatefulWidget { + const PlaceAboutSection({ + super.key, + required this.text, + required this.provider, + }); + + final String text; + final String provider; + + @override + State createState() => _PlaceAboutSectionState(); +} + +class _PlaceAboutSectionState extends State { + bool _expanded = false; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final textTheme = Theme.of(context).textTheme; + final style = textTheme.bodyLarge?.copyWith(fontSize: 14, height: 22 / 14); + final text = keepAll(widget.text); + + return _Section( + title: l10n.eventAbout, + gap: 10, + children: [ + LayoutBuilder( + builder: (context, constraints) { + final painter = TextPainter( + text: TextSpan(text: text, style: style), + maxLines: _kAboutCollapsedLines, + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout(maxWidth: constraints.maxWidth); + final overflows = painter.didExceedMaxLines; + painter.dispose(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + text, + style: style, + maxLines: _expanded ? null : _kAboutCollapsedLines, + overflow: _expanded ? null : TextOverflow.ellipsis, + ), + Row( + children: [ + Expanded( + child: Text( + l10n.sourceLabel(widget.provider), + style: textTheme.bodySmall, + ), + ), + if (overflows || _expanded) + TextButton( + onPressed: () => setState(() => _expanded = !_expanded), + style: TextButton.styleFrom( + foregroundColor: AppColors.textSecondary, + padding: EdgeInsets.zero, + textStyle: textTheme.labelLarge, + ), + child: Text(_expanded ? l10n.showLess : l10n.showMore), + ), + ], + ), + ], + ); + }, + ), + ], + ); + } +} + +/// 근처 축제·행사 — 반경 5km·30일 안에 없으면(또는 못 불러오면) 칸을 숨긴다 +class NearbyEventsSection extends ConsumerWidget { + const NearbyEventsSection({super.key, required this.point}); + + final GeoPoint point; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final items = ref.watch(nearbyEventsProvider(point)).value ?? const []; + if (items.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.nearbyEvents, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + for (var i = 0; i < items.length; i++) ...[ + if (i > 0) const Divider(), + EventRow( + item: items[i], + onTap: () => context.push(AppRoutes.eventDetail(items[i].id)), + ), + ], + ], + ), + ); + } +} + +/// 함께 가기 좋은 곳 — 가까운 관광공사·운영자 확인 장소. 없으면 칸을 숨긴다 +class NearbyPlacesSection extends ConsumerWidget { + const NearbyPlacesSection({super.key, required this.placeKey}); + + final NearbyPlaceKey placeKey; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final items = ref.watch(nearbyPlacesProvider(placeKey)).value ?? const []; + if (items.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.nearbyPlaces, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 14), + SizedBox( + height: _kPlaceStripHeight, + child: ListView.separated( + scrollDirection: Axis.horizontal, + // 화면 좌우 여백까지 밀어 보이게 한다 + clipBehavior: Clip.none, + itemCount: items.length, + separatorBuilder: (_, _) => const SizedBox(width: 12), + itemBuilder: (context, index) { + final item = items[index]; + return PlaceCard( + item: item, + onTap: () => context.push(AppRoutes.placeDetail(item.id)), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/flutter/lib/features/place/presentation/widgets/place_tiles.dart b/flutter/lib/features/place/presentation/widgets/place_tiles.dart new file mode 100644 index 0000000..176f3d0 --- /dev/null +++ b/flutter/lib/features/place/presentation/widgets/place_tiles.dart @@ -0,0 +1,225 @@ +import 'package:flutter/material.dart'; + +import 'package:wandergo/core/l10n/l10n.dart'; +import 'package:wandergo/core/theme/app_colors.dart'; +import 'package:wandergo/core/utils/format.dart'; +import 'package:wandergo/features/place/data/models/place_models.dart'; +import 'package:wandergo/features/place/presentation/widgets/place_visuals.dart'; + +/// 검색 목록 사진 크기 (디자인 캔버스 72×72) +const double kPlaceRowImageSize = 72; + +/// 함께 가기 좋은 곳 카드 너비·사진 높이 (디자인 캔버스 148×104) +const double kPlaceCardWidth = 148; +const double kPlaceCardImageHeight = 104; + +/// 검색 목록의 한 줄 — 사진 · 분류·거리 · 이름 · 실내외·영업·반려동물 (구분선은 목록이 그린다) +class PlaceRow extends StatelessWidget { + const PlaceRow({super.key, required this.item, required this.onTap}); + + final PlaceItem item; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final textTheme = Theme.of(context).textTheme; + final meta = [ + ?item.category?.label(l10n), + if (item.distanceM != null) formatDistance(item.distanceM!), + ].join(' · '); + final facts = [?item.setting?.label(l10n), ?openLabel(l10n, item)]; + final pet = petLabel(l10n, item.petPolicy); + + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: Row( + children: [ + PlaceImage( + url: item.imageThumbUrl, + category: item.category, + license: item.imageLicense, + width: kPlaceRowImageSize, + height: kPlaceRowImageSize, + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (meta.isNotEmpty) Text(meta, style: textTheme.bodySmall), + const SizedBox(height: 4), + Text( + item.displayName, + style: textTheme.titleSmall?.copyWith( + fontSize: 16, + height: 22 / 16, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (facts.isNotEmpty || pet != null) ...[ + const SizedBox(height: 4), + Text.rich( + TextSpan( + children: [ + TextSpan(text: facts.join(' · ')), + if (pet != null) ...[ + if (facts.isNotEmpty) const TextSpan(text: ' · '), + petSpan(pet), + ], + ], + ), + style: textTheme.bodyMedium?.copyWith( + color: AppColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ], + ), + ), + ); + } +} + +/// 홈 '가까운 반려동물 동반 장소' 의 한 줄 — 사진 · 이름 · 분류·도보 · 동반 조건·확인일 +class PetPlaceRow extends StatelessWidget { + const PetPlaceRow({super.key, required this.item, required this.onTap}); + + final PlaceItem item; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final textTheme = Theme.of(context).textTheme; + final meta = [ + ?item.category?.label(l10n), + if (item.distanceM != null) walkOrDistance(l10n, item.distanceM!), + ].join(' · '); + final pet = petLabel(l10n, item.petPolicy); + final verifiedAt = item.verifiedAt; + + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + PlaceImage( + url: item.imageThumbUrl, + category: item.category, + license: item.imageLicense, + width: 64, + height: 64, + iconSize: 24, + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.displayName, + style: textTheme.titleSmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (meta.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + meta, + style: textTheme.bodyMedium?.copyWith( + color: AppColors.textSecondary, + ), + ), + ], + if (pet != null) ...[ + const SizedBox(height: 4), + Text.rich( + TextSpan( + children: [ + petSpan(pet, fontSize: 12), + if (verifiedAt != null) + TextSpan( + text: ' · ${l10n.placeVerifiedOn(verifiedAt)}', + ), + ], + ), + style: textTheme.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ], + ), + ), + ); + } +} + +/// 가로 목록 카드 — 사진 · 이름 · 분류·도보 · 반려동물 (테두리 없음) +class PlaceCard extends StatelessWidget { + const PlaceCard({super.key, required this.item, required this.onTap}); + + final PlaceItem item; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final textTheme = Theme.of(context).textTheme; + final meta = [ + ?item.category?.label(l10n), + if (item.distanceM != null) walkOrDistance(l10n, item.distanceM!), + ].join(' · '); + final pet = petLabel(l10n, item.petPolicy); + + return SizedBox( + width: kPlaceCardWidth, + child: InkWell( + onTap: onTap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PlaceImage( + url: item.imageThumbUrl, + category: item.category, + license: item.imageLicense, + width: kPlaceCardWidth, + height: kPlaceCardImageHeight, + ), + const SizedBox(height: 10), + Text( + item.displayName, + style: textTheme.titleSmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + meta, + style: textTheme.bodySmall, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (pet != null) ...[ + const SizedBox(height: 4), + PetLabel(text: pet, fontSize: 12), + ], + ], + ), + ), + ); + } +} diff --git a/flutter/lib/features/place/presentation/widgets/place_visuals.dart b/flutter/lib/features/place/presentation/widgets/place_visuals.dart new file mode 100644 index 0000000..6a55eba --- /dev/null +++ b/flutter/lib/features/place/presentation/widgets/place_visuals.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; + +import 'package:wandergo/core/icons/app_icons.dart'; +import 'package:wandergo/core/l10n/l10n.dart'; +import 'package:wandergo/core/theme/app_colors.dart'; +import 'package:wandergo/core/theme/app_theme.dart'; +import 'package:wandergo/core/utils/format.dart'; +import 'package:wandergo/core/utils/geo.dart'; +import 'package:wandergo/features/place/data/models/place_models.dart'; +import 'package:wandergo/features/place/domain/place_search.dart'; +import 'package:wandergo/shared/models/source_models.dart'; +import 'package:wandergo/shared/widgets/public_image.dart'; + +extension PlaceCategoryLabel on PlaceCategory { + String label(AppLocalizations l10n) => switch (this) { + PlaceCategory.nature => l10n.placeCategoryNature, + PlaceCategory.attraction => l10n.placeCategoryAttraction, + PlaceCategory.culture => l10n.placeCategoryCulture, + PlaceCategory.leisure => l10n.placeCategoryLeisure, + PlaceCategory.food => l10n.placeCategoryFood, + PlaceCategory.cafe => l10n.placeCategoryCafe, + PlaceCategory.bar => l10n.placeCategoryBar, + PlaceCategory.shopping => l10n.placeCategoryShopping, + PlaceCategory.lodging => l10n.placeCategoryLodging, + PlaceCategory.other => l10n.placeCategoryOther, + }; +} + +extension PlaceTabLabel on PlaceTab { + String label(AppLocalizations l10n) => switch (this) { + PlaceTab.all => l10n.searchTabAll, + PlaceTab.sights => l10n.categorySights, + PlaceTab.food => l10n.categoryFood, + PlaceTab.indoor => l10n.categoryIndoor, + PlaceTab.stay => l10n.categoryStay, + PlaceTab.nature => l10n.categoryNature, + }; +} + +extension PlaceSettingLabel on PlaceSetting { + String label(AppLocalizations l10n) => switch (this) { + PlaceSetting.indoor => l10n.placeSettingIndoor, + PlaceSetting.outdoor => l10n.placeSettingOutdoor, + PlaceSetting.both => l10n.placeSettingBoth, + }; +} + +extension PetSizeLabel on PetSize { + String label(AppLocalizations l10n) => switch (this) { + PetSize.small => l10n.petSizeSmall, + PetSize.medium => l10n.petSizeMedium, + PetSize.large => l10n.petSizeLarge, + }; +} + +/// 반려동물 동반 글자 — 동반할 수 없거나 모르면 null +String? petLabel(AppLocalizations l10n, PetPolicy policy) => switch (policy) { + PetPolicy.allowed => l10n.petAllowed, + PetPolicy.partial => l10n.petPartial, + PetPolicy.notAllowed || PetPolicy.unknown => null, +}; + +/// 영업 상태 글자 — 영업 중 · 지금은 영업 안 함 · 영업시간 미확인. 단정할 수 없으면 null +String? openLabel(AppLocalizations l10n, PlaceItem item) { + final openNow = item.openNow; + if (openNow != null) return openNow ? l10n.placeOpenNow : l10n.placeClosedNow; + return item.hoursUnknown ? l10n.placeHoursUnknownShort : null; +} + +/// 짧은 거리 — 가까우면 "도보 8분", 멀면 "2.4km" +String walkOrDistance(AppLocalizations l10n, num meters) => meters <= kWalkMaxM + ? l10n.walkMinutesShort(walkMinutes(meters)) + : formatDistance(meters); + +/// 긴 거리 — 가까우면 "도보 8분 · 650m", 멀면 "2.4km" +String walkAndDistance(AppLocalizations l10n, num meters) => meters <= kWalkMaxM + ? l10n.walkDetail(walkMinutes(meters), formatDistance(meters)) + : formatDistance(meters); + +/// 분류별 자리 표시 색·아이콘 (디자인 캔버스의 자리 표시 톤) +ImagePlaceholder placePlaceholderOf(PlaceCategory? category) => + switch (category) { + PlaceCategory.nature => ( + background: const Color(0xFFD7E9E3), + foreground: const Color(0xFF24584E), + icon: Icons.park_outlined, + ), + PlaceCategory.attraction => ( + background: const Color(0xFFDCE3F0), + foreground: const Color(0xFF34405A), + icon: Icons.photo_camera_outlined, + ), + PlaceCategory.culture => ( + background: const Color(0xFFF1E4D3), + foreground: const Color(0xFF6B4A2B), + icon: Icons.museum_outlined, + ), + PlaceCategory.leisure => ( + background: const Color(0xFFCFE3F5), + foreground: const Color(0xFF1F4A70), + icon: Icons.attractions_outlined, + ), + PlaceCategory.food => ( + background: const Color(0xFFF5DCD3), + foreground: const Color(0xFF6E3322), + icon: Icons.restaurant_outlined, + ), + PlaceCategory.cafe => ( + background: const Color(0xFFE6E1F5), + foreground: const Color(0xFF433373), + icon: Icons.local_cafe_outlined, + ), + PlaceCategory.bar => ( + background: const Color(0xFF2E3550), + foreground: const Color(0xFFC9CDE0), + icon: Icons.local_bar_outlined, + ), + PlaceCategory.shopping => ( + background: const Color(0xFFF3DCE4), + foreground: const Color(0xFF6A2E44), + icon: Icons.shopping_bag_outlined, + ), + PlaceCategory.lodging => ( + background: const Color(0xFFE3EEF7), + foreground: const Color(0xFF34405A), + icon: Icons.hotel_outlined, + ), + PlaceCategory.other || null => ( + background: AppColors.placeholder, + foreground: AppColors.textTertiary, + icon: Icons.place_outlined, + ), + }; + +/// 장소 사진 — 사진이 없으면 분류별 자리 표시, Type3 은 자르지 않는다([PublicImage]) +class PlaceImage extends StatelessWidget { + const PlaceImage({ + super.key, + required this.url, + required this.category, + required this.license, + this.width, + this.height, + this.radius = kRadius, + this.iconSize = 28, + }); + + final String? url; + final PlaceCategory? category; + final ImageLicense? license; + final double? width; + final double? height; + final double radius; + final double iconSize; + + @override + Widget build(BuildContext context) { + return PublicImage( + url: url, + license: license, + placeholder: placePlaceholderOf(category), + width: width, + height: height, + radius: radius, + iconSize: iconSize, + ); + } +} + +/// 반려동물 동반 글자 — 꽉 찬 발바닥 + 초록 굵은 글자(배지 없이 글자에 녹인다) +class PetLabel extends StatelessWidget { + const PetLabel({super.key, required this.text, this.fontSize = 13}); + + final String text; + final double fontSize; + + @override + Widget build(BuildContext context) { + return Text.rich( + petSpan(text, fontSize: fontSize), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } +} + +/// 다른 글자 사이에 넣는 반려동물 동반 글자 +InlineSpan petSpan(String text, {double fontSize = 13}) => TextSpan( + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsets.only(right: 4), + child: AppIcon( + AppIconData.pawSolid, + size: fontSize, + color: AppColors.pet, + ), + ), + ), + TextSpan( + text: text, + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.w600, + color: AppColors.pet, + ), + ), + ], +); diff --git a/flutter/lib/l10n/app_en.arb b/flutter/lib/l10n/app_en.arb index 04f62a9..1010cb0 100644 --- a/flutter/lib/l10n/app_en.arb +++ b/flutter/lib/l10n/app_en.arb @@ -47,8 +47,97 @@ "linkOpenFailed": "Couldn't open the link.", "sourceLabel": "Source: {provider}", "sourceAsOf": "Source: {provider} · as of {date}", - "footerSources": "Tourism data: Korea Tourism Organization TourAPI (KOGL Type 1)\nPerformance data: Korea Arts Management Service, KOPIS (www.kopis.or.kr)\nExhibition data: Korea Culture Information Service Agency, Culture Portal", + "footerSources": "Tourism data: Korea Tourism Organization TourAPI (KOGL Type 1)\nShop data: Small Enterprise and Market Service, commercial district data\nPerformance data: Korea Arts Management Service, KOPIS (www.kopis.or.kr)\nExhibition data: Korea Culture Information Service Agency, Culture Portal", "footerNotice": "Place and opening details can change. Please check before you go.", "comingSoonTitle": "Coming soon", - "comingSoonBody": "We're still building this screen." + "comingSoonBody": "We're still building this screen.", + "back": "Back", + "searchHint": "Search places by name", + "searchClear": "Clear search", + "categoryNavLabel": "Categories", + "categorySights": "Sights", + "categoryFestival": "Festivals", + "categoryPet": "With pets", + "categoryFood": "Food & cafés", + "categoryStay": "Stays", + "categoryIndoor": "Indoor & art", + "categoryNature": "Walks & nature", + "homePetPlaces": "Pet-friendly places nearby", + "homePetPlacesEmpty": "No pet-friendly places nearby yet.", + "searchTabAll": "All", + "filterPet": "Dogs OK", + "searchCount": "{count, plural, =1{1 place} other{{count} places}}", + "searchSortNearest": "Nearest first", + "searchEmptyTitle": "No places match", + "searchEmptyBody": "Try another search word, or switch the category to All.", + "searchEmptyPetBody": "Turn off Dogs OK to see more places.", + "searchRemovePet": "Show without Dogs OK", + "placeSources": "Place data: Korea Tourism Organization TourAPI (KOGL)\nShop data: Small Enterprise and Market Service, commercial district data", + "placeCategoryNature": "Nature & parks", + "placeCategoryAttraction": "Sights", + "placeCategoryCulture": "Culture & art", + "placeCategoryLeisure": "Leisure", + "placeCategoryFood": "Restaurant", + "placeCategoryCafe": "Café", + "placeCategoryBar": "Bar", + "placeCategoryShopping": "Shopping", + "placeCategoryLodging": "Stay", + "placeCategoryOther": "Other", + "placeSettingIndoor": "Indoor", + "placeSettingOutdoor": "Outdoor", + "placeSettingBoth": "Indoor & outdoor", + "placeOpenNow": "Open now", + "placeClosedNow": "Closed now", + "placeHoursUnknownShort": "Hours not confirmed", + "placeHoursUnknown": "Hours not confirmed · check before you go", + "placeHoursVaries": "Hours vary by shop or facility. Check before you go.", + "petAllowed": "Dogs OK", + "petPartial": "Dogs OK in some areas", + "petNotAllowed": "No pets", + "petSizeSmall": "Small", + "petSizeMedium": "Medium", + "petSizeLarge": "Large", + "petSizesLabel": "Dog sizes allowed", + "placeVerifiedOn": "Checked {date}", + "petSourcePublic": "Source: {provider} pet travel info", + "petCheckOnSite": "Check the signs on site before you visit.", + "walkMinutesShort": "{minutes} min walk", + "walkDetail": "{minutes} min walk · {distance}", + "distanceFromHere": "{detail} from you", + "distanceFromPlace": "{detail} from {place}", + "placeGlance": "At a glance", + "glanceParking": "Parking", + "glanceFree": "Free entry", + "glanceAlwaysOpen": "Open 24h", + "placePetSection": "Visiting with pets", + "placeInfoSection": "Getting there & visiting", + "infoAddress": "Address", + "infoHours": "Hours", + "infoRest": "Closed", + "infoFee": "Admission", + "infoParking": "Parking", + "infoParkingFee": "Parking fee", + "infoSpendTime": "Time needed", + "infoReservation": "Booking", + "infoFirstMenu": "Signature dish", + "infoTreatMenu": "Menu", + "infoOpenPeriod": "Season", + "infoSeason": "Best time", + "infoInquiry": "Inquiries", + "infoPhone": "Phone", + "infoHomepage": "Website", + "infoOfficial": "Official page", + "actionCopy": "Copy", + "actionCall": "Call", + "addressCopied": "Address copied.", + "openWebsite": "Open website (external)", + "hotlineTitle": "Need help? Call 1330", + "hotlineBody": "24/7 travel help in English, 日本語, 中文", + "showMore": "Show more", + "showLess": "Show less", + "nearbyEvents": "Festivals & events nearby", + "nearbyPlaces": "Good to go with", + "photoCredit": "Photo: {provider} · {license}", + "koglType1": "KOGL Type 1", + "koglType3": "KOGL Type 3" } diff --git a/flutter/lib/l10n/app_ko.arb b/flutter/lib/l10n/app_ko.arb index 5cbc7c4..afc69cd 100644 --- a/flutter/lib/l10n/app_ko.arb +++ b/flutter/lib/l10n/app_ko.arb @@ -14,7 +14,9 @@ "@languageButtonLabel": { "description": "언어 버튼의 화면 읽기 프로그램용 이름", "placeholders": { - "language": {"type": "String"} + "language": { + "type": "String" + } } }, "languageSheetTitle": "언어", @@ -36,7 +38,9 @@ "baseLocationButtonLabel": "기준 위치 변경, 현재 {place}", "@baseLocationButtonLabel": { "placeholders": { - "place": {"type": "String"} + "place": { + "type": "String" + } } }, "homeTitle": "이번 주말, 어디 갈까요?", @@ -62,18 +66,178 @@ "sourceLabel": "출처: {provider}", "@sourceLabel": { "placeholders": { - "provider": {"type": "String"} + "provider": { + "type": "String" + } } }, "sourceAsOf": "출처: {provider} · {date} 기준", "@sourceAsOf": { "placeholders": { - "provider": {"type": "String"}, - "date": {"type": "String"} + "provider": { + "type": "String" + }, + "date": { + "type": "String" + } } }, - "footerSources": "관광 정보 출처: 한국관광공사 TourAPI · 공공누리 제1유형\n공연 정보 출처: (재)예술경영지원센터 공연예술통합전산망(www.kopis.or.kr)\n전시 정보 출처: 한국문화정보원 문화포털", + "footerSources": "관광 정보 출처: 한국관광공사 TourAPI · 공공누리 제1유형\n상가 정보 출처: 소상공인시장진흥공단 상가(상권)정보\n공연 정보 출처: (재)예술경영지원센터 공연예술통합전산망(www.kopis.or.kr)\n전시 정보 출처: 한국문화정보원 문화포털", "footerNotice": "장소·영업 정보는 바뀔 수 있으니 방문 전에 확인하세요.", "comingSoonTitle": "곧 만나요", - "comingSoonBody": "이 화면은 아직 만드는 중이에요." + "comingSoonBody": "이 화면은 아직 만드는 중이에요.", + "back": "뒤로", + "searchHint": "장소 이름으로 검색", + "searchClear": "검색어 지우기", + "categoryNavLabel": "카테고리", + "categorySights": "관광지", + "categoryFestival": "축제", + "categoryPet": "반려동물", + "categoryFood": "맛집·카페", + "categoryStay": "숙소", + "categoryIndoor": "실내·전시", + "categoryNature": "산책·자연", + "homePetPlaces": "가까운 반려동물 동반 장소", + "homePetPlacesEmpty": "근처에 반려동물과 함께 갈 수 있는 곳이 아직 없어요.", + "searchTabAll": "전체", + "filterPet": "반려견 동반", + "searchCount": "{count}곳", + "@searchCount": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "searchSortNearest": "가까운 순", + "searchEmptyTitle": "조건에 맞는 곳이 없어요", + "searchEmptyBody": "검색어를 바꾸거나 분류를 ‘전체’로 바꿔 보세요.", + "searchEmptyPetBody": "‘반려견 동반’을 빼면 더 많은 곳을 볼 수 있어요.", + "searchRemovePet": "반려견 동반 빼고 보기", + "placeSources": "장소 정보 출처: 한국관광공사 TourAPI · 공공누리\n상가 정보 출처: 소상공인시장진흥공단 상가(상권)정보", + "placeCategoryNature": "자연·공원", + "placeCategoryAttraction": "관광지", + "placeCategoryCulture": "문화·전시", + "placeCategoryLeisure": "레저·체험", + "placeCategoryFood": "식당", + "placeCategoryCafe": "카페", + "placeCategoryBar": "술집", + "placeCategoryShopping": "쇼핑", + "placeCategoryLodging": "숙소", + "placeCategoryOther": "기타", + "placeSettingIndoor": "실내", + "placeSettingOutdoor": "실외", + "placeSettingBoth": "실내·실외", + "placeOpenNow": "영업 중", + "placeClosedNow": "지금은 영업 안 함", + "placeHoursUnknownShort": "영업시간 미확인", + "placeHoursUnknown": "영업시간 미확인 · 방문 전 확인", + "placeHoursVaries": "점포·시설마다 달라요. 방문 전에 확인하세요.", + "petAllowed": "반려견 동반", + "petPartial": "일부 구역 동반", + "petNotAllowed": "반려동물 동반 불가", + "petSizeSmall": "소형견", + "petSizeMedium": "중형견", + "petSizeLarge": "대형견", + "petSizesLabel": "동반 가능한 크기", + "placeVerifiedOn": "{date} 확인", + "@placeVerifiedOn": { + "placeholders": { + "date": { + "type": "String" + } + } + }, + "petSourcePublic": "출처 {provider} 반려동물 동반여행 정보", + "@petSourcePublic": { + "placeholders": { + "provider": { + "type": "String" + } + } + }, + "petCheckOnSite": "방문 전 현장 안내를 꼭 확인하세요.", + "walkMinutesShort": "도보 {minutes}분", + "@walkMinutesShort": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "walkDetail": "도보 {minutes}분 · {distance}", + "@walkDetail": { + "placeholders": { + "minutes": { + "type": "int" + }, + "distance": { + "type": "String" + } + } + }, + "distanceFromHere": "현재 위치에서 {detail}", + "@distanceFromHere": { + "placeholders": { + "detail": { + "type": "String" + } + } + }, + "distanceFromPlace": "{place}에서 {detail}", + "@distanceFromPlace": { + "placeholders": { + "place": { + "type": "String" + }, + "detail": { + "type": "String" + } + } + }, + "placeGlance": "한눈에 보기", + "glanceParking": "주차 가능", + "glanceFree": "입장 무료", + "glanceAlwaysOpen": "상시 개방", + "placePetSection": "반려동물 동반 정보", + "placeInfoSection": "이용 안내", + "infoAddress": "주소", + "infoHours": "운영시간", + "infoRest": "쉬는 날", + "infoFee": "입장료", + "infoParking": "주차", + "infoParkingFee": "주차 요금", + "infoSpendTime": "관람 시간", + "infoReservation": "예약", + "infoFirstMenu": "대표 메뉴", + "infoTreatMenu": "메뉴", + "infoOpenPeriod": "개장 기간", + "infoSeason": "이용 시기", + "infoInquiry": "문의", + "infoPhone": "전화", + "infoHomepage": "홈페이지", + "infoOfficial": "공식 채널", + "actionCopy": "복사", + "actionCall": "전화", + "addressCopied": "주소를 복사했어요.", + "openWebsite": "홈페이지 열기 (외부 사이트)", + "hotlineTitle": "관광통역안내 1330", + "hotlineBody": "영어·일본어·중국어 24시간 안내", + "showMore": "더 보기", + "showLess": "접기", + "nearbyEvents": "근처 축제·행사", + "nearbyPlaces": "함께 가기 좋은 곳", + "photoCredit": "사진 {provider} · {license}", + "@photoCredit": { + "placeholders": { + "provider": { + "type": "String" + }, + "license": { + "type": "String" + } + } + }, + "koglType1": "공공누리 제1유형", + "koglType3": "공공누리 제3유형" } diff --git a/flutter/lib/shared/widgets/section_header.dart b/flutter/lib/shared/widgets/section_header.dart new file mode 100644 index 0000000..3f7ce77 --- /dev/null +++ b/flutter/lib/shared/widgets/section_header.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +import 'package:wandergo/core/icons/app_icons.dart'; +import 'package:wandergo/core/l10n/l10n.dart'; +import 'package:wandergo/core/theme/app_colors.dart'; + +/// 섹션 제목 + (있으면) 오른쪽 '전체 보기 >' +class SectionHeader extends StatelessWidget { + const SectionHeader({super.key, required this.title, this.onSeeAll}); + + final String title; + + /// null 이면 '전체 보기' 를 숨긴다 + final VoidCallback? onSeeAll; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final onSeeAll = this.onSeeAll; + return Row( + children: [ + Expanded(child: Text(title, style: textTheme.titleMedium)), + if (onSeeAll != null) + TextButton( + onPressed: onSeeAll, + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + textStyle: textTheme.bodyMedium, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(context.l10n.seeAll), + const SizedBox(width: 2), + const AppIcon( + AppIconData.chevronRight, + size: 14, + color: AppColors.textTertiary, + ), + ], + ), + ), + ], + ); + } +} diff --git a/flutter/test/core/utils/format_test.dart b/flutter/test/core/utils/format_test.dart index 6f33d80..11a5c71 100644 --- a/flutter/test/core/utils/format_test.dart +++ b/flutter/test/core/utils/format_test.dart @@ -50,4 +50,14 @@ void main() { expect(shortRegion('대구광역시'), isNull); }); }); + + group('formatSourceDate', () { + test('상가정보 기준년월 YYYYMM 은 YYYY-MM 으로', () { + expect(formatSourceDate('202609'), '2026-09'); + }); + + test('날짜는 그대로', () { + expect(formatSourceDate('2026-06-26'), '2026-06-26'); + }); + }); } diff --git a/flutter/test/core/utils/geo_test.dart b/flutter/test/core/utils/geo_test.dart new file mode 100644 index 0000000..47bd417 --- /dev/null +++ b/flutter/test/core/utils/geo_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:wandergo/core/utils/geo.dart'; + +void main() { + group('distanceMeters', () { + test('같은 좌표는 0', () { + expect(distanceMeters(35.87, 128.59, 35.87, 128.59), 0); + }); + + test('대구 도심 동성로 ↔ 김광석다시그리기길 약 1.55km', () { + // 백엔드(PostGIS) 가 돌려준 거리 1552m 와 몇 m 안에서 맞는다 + final m = distanceMeters( + 35.87021, + 128.59409, + 35.8614155786, + 128.6074607301, + ); + expect(m, closeTo(1552, 10)); + }); + + test('순서를 바꿔도 같다', () { + expect( + distanceMeters(35.8276, 128.6177, 35.83582, 128.57911), + closeTo(distanceMeters(35.83582, 128.57911, 35.8276, 128.6177), 1e-6), + ); + }); + }); + + group('walkMinutes', () { + test('시속 4km 로 올림한다', () { + expect(walkMinutes(650), 10); + expect(walkMinutes(67), 2); + expect(walkMinutes(66), 1); + }); + + test('아주 가까워도 최소 1분', () { + expect(walkMinutes(0), 1); + expect(walkMinutes(10), 1); + }); + }); +} diff --git a/flutter/test/features/place_models_test.dart b/flutter/test/features/place_models_test.dart new file mode 100644 index 0000000..9155a61 --- /dev/null +++ b/flutter/test/features/place_models_test.dart @@ -0,0 +1,177 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:wandergo/features/place/data/models/place_models.dart'; +import 'package:wandergo/features/place/domain/place_facts.dart'; +import 'package:wandergo/features/place/domain/place_search.dart'; +import 'package:wandergo/shared/models/source_models.dart'; + +/// 개발 백엔드 실제 응답(2026-11-04)에서 가져온 모양 +Map _listItem({ + String category = 'cafe', + String source = 'store', + String petPolicy = 'unknown', +}) => { + 'id': '5cb981e1-dfab-497d-b00d-ddac3c962f1e', + 'name': '마실커피', + 'branchName': '동성로점', + 'category': category, + 'source': source, + 'address': '대구광역시 중구 중앙대로 380', + 'lat': 35.8669069452959, + 'lng': 128.593680108517, + 'distanceM': 53, + 'imageThumbUrl': null, + 'imageLicense': null, + 'hoursOrigin': 'unknown', + 'openNow': null, + 'petPolicy': petPolicy, + 'petOrigin': 'unknown', + 'setting': null, + 'verifiedAt': null, +}; + +void main() { + group('PlaceItem.fromJson', () { + test('목록 한 건을 읽고 지점명을 이름에 붙인다', () { + final item = PlaceItem.fromJson(_listItem()); + expect(item.displayName, '마실커피 동성로점'); + expect(item.category, PlaceCategory.cafe); + expect(item.source, PlaceSource.store); + expect(item.distanceM, 53); + expect(item.openNow, isNull); + expect(item.hoursUnknown, isTrue); + expect(item.petPolicy, PetPolicy.unknown); + }); + + test('모르는 코드값은 null·unknown 으로 읽는다(앱이 죽지 않는다)', () { + final item = PlaceItem.fromJson( + _listItem(category: 'spaceport', source: 'x', petPolicy: 'maybe'), + ); + expect(item.category, isNull); + expect(item.source, isNull); + expect(item.petPolicy, PetPolicy.unknown); + }); + + test('not_allowed 는 동반 불가, partial 은 동반 가능으로 본다', () { + expect(PetPolicy.parse('not_allowed'), PetPolicy.notAllowed); + expect(PetPolicy.notAllowed.isPetFriendly, isFalse); + expect(PetPolicy.parse('partial').isPetFriendly, isTrue); + }); + }); + + group('PlaceDetail.fromJson', () { + test('상세 — 시간·반려동물·이용 안내·출처를 읽고 빈 값은 없음으로 본다', () { + final detail = PlaceDetail.fromJson({ + ..._listItem(category: 'attraction', source: 'tour'), + 'branchName': null, + 'name': '김광석다시그리기길', + 'imageLicense': 'Type1', + 'hoursOrigin': 'public', + 'openNow': true, + 'petPolicy': 'allowed', + 'petOrigin': 'public', + 'addressJibun': null, + 'phone': null, + 'homepage': 'https://tour.daegu.go.kr/index.do', + 'overview': '', + 'imageUrl': 'https://example.com/a.jpg', + 'hours': {'text': '상시 개방', 'restText': '연중무휴', 'status': 'always_open'}, + 'pet': {'sizes': null, 'conditions': '전 견종 동반 가능 · 목줄 착용'}, + 'extra': {'parking': '불가능', 'infocenter': '053-627-1337', 'fee': ' '}, + 'curation': null, + 'attribution': {'provider': '한국관광공사', 'asOf': '2026-06-26'}, + }); + expect(detail.item.displayName, '김광석다시그리기길'); + expect(detail.item.imageLicense, ImageLicense.type1); + expect(detail.overview, isNull); + expect(detail.hours.status, HoursStatus.alwaysOpen); + expect(detail.hours.restText, '연중무휴'); + expect(detail.pet.sizes, isNull); + expect(splitConditions(detail.pet.conditions), ['전 견종 동반 가능', '목줄 착용']); + expect(detail.extra, {'parking': '불가능', 'infocenter': '053-627-1337'}); + expect(detail.curation, isNull); + expect(detail.attribution.provider, '한국관광공사'); + }); + + test('운영자 확인 크기·추천 이유를 읽는다', () { + final detail = PlaceDetail.fromJson({ + ..._listItem(), + 'hours': {'text': null, 'restText': null, 'status': 'unknown'}, + 'pet': { + 'sizes': ['small', 'medium', 'huge'], + 'conditions': null, + }, + 'extra': null, + 'curation': {'reason': '골목 산책하기 좋아요.', 'officialUrl': null}, + 'attribution': {'provider': '소상공인시장진흥공단', 'asOf': '202609'}, + }); + expect(detail.pet.sizes, [PetSize.small, PetSize.medium]); + expect(detail.extra, isEmpty); + expect(detail.curation?.reason, '골목 산책하기 좋아요.'); + }); + }); + + group('PlacePage.fromJson', () { + test('items·pageInfo(total 포함)를 읽는다', () { + final page = PlacePage.fromJson({ + 'items': [_listItem()], + 'pageInfo': {'hasNext': true, 'nextCursor': 'abc', 'total': 4500}, + }); + expect(page.items, hasLength(1)); + expect(page.hasNext, isTrue); + expect(page.nextCursor, 'abc'); + expect(page.total, 4500); + }); + }); + + group('place_facts', () { + test('주차 — 확실할 때만 가능·불가, 애매하면 모름', () { + expect(parkingAvailable('가능'), isTrue); + expect(parkingAvailable('주차가능(승용차 20대)'), isTrue); + expect(parkingAvailable('불가능'), isFalse); + expect(parkingAvailable('없음'), isFalse); + expect(parkingAvailable('인근 공영주차장 이용'), isNull); + expect(parkingAvailable(null), isNull); + }); + + test('입장 무료 — 유료가 섞이면 무료로 보지 않는다', () { + expect(isFreeEntry('무료'), isTrue); + expect(isFreeEntry('무료(일부 체험 유료)'), isFalse); + expect(isFreeEntry('성인 3,000원'), isFalse); + expect(isFreeEntry(null), isFalse); + }); + + test('조건 — " · "·줄바꿈으로 나누고, 단어 안의 가운뎃점은 두다', () { + expect(splitConditions('목줄 착용 · 실내 전시·온실 시설은 동반 불가\n배변봉투 지참'), [ + '목줄 착용', + '실내 전시·온실 시설은 동반 불가', + '배변봉투 지참', + ]); + expect(splitConditions(null), isEmpty); + }); + + test('금지 조건 — 불가·금지·제한, "제한 없음"은 아니다', () { + expect(isRestriction('실내 동반 불가'), isTrue); + expect(isRestriction('맹견 출입 금지'), isTrue); + expect(isRestriction('15kg 이상 제한'), isTrue); + expect(isRestriction('크기 제한 없음'), isFalse); + expect(isRestriction('수성못 산책로 이용'), isFalse); + }); + }); + + group('PlaceQuery', () { + test('검색어 앞뒤 공백은 같은 조건으로 본다', () { + expect(const PlaceQuery(text: ' 김광석 '), const PlaceQuery(text: '김광석')); + expect(const PlaceQuery(text: ' ').queryOrNull, isNull); + }); + + test('탭은 백엔드 분류 여러 개로 바뀐다', () { + expect(PlaceTab.food.categories, [ + PlaceCategory.food, + PlaceCategory.cafe, + PlaceCategory.bar, + ]); + expect(PlaceTab.all.categories, isEmpty); + expect(PlaceTab.parse('nope'), PlaceTab.all); + }); + }); +} diff --git a/flutter/test/widget_test.dart b/flutter/test/widget_test.dart index 8cae972..8cf3195 100644 --- a/flutter/test/widget_test.dart +++ b/flutter/test/widget_test.dart @@ -10,6 +10,8 @@ import 'package:wandergo/features/event/data/models/event_models.dart'; import 'package:wandergo/features/event/data/repositories/event_repository.dart'; 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'; /// 2026-11-04(수) 한국 정오 — 이번 주말은 11-07~11-08 final DateTime _now = DateTime.utc(2026, 11, 4, 3); @@ -60,6 +62,97 @@ class _FakeEventRepository implements EventRepository { ); } +PlaceItem _place( + String id, + String name, { + String? branchName, + PlaceCategory category = PlaceCategory.nature, + PlaceSource source = PlaceSource.tour, + PetPolicy petPolicy = PetPolicy.unknown, + ValueOrigin hoursOrigin = ValueOrigin.public, + bool? openNow, + double distanceM = 260, +}) => PlaceItem( + id: id, + name: name, + branchName: branchName, + category: category, + source: source, + address: '대구광역시 중구 경상감영길 99', + lat: 35.8716, + lng: 128.5925, + distanceM: distanceM, + hoursOrigin: hoursOrigin, + openNow: openNow, + petPolicy: petPolicy, + petOrigin: petPolicy == PetPolicy.unknown + ? ValueOrigin.unknown + : ValueOrigin.public, +); + +final PlaceItem _petPark = _place( + 'p-pet', + '경상감영공원', + petPolicy: PetPolicy.allowed, + openNow: true, +); + +final PlaceItem _storeCafe = _place( + 'p-cafe', + '마실커피', + branchName: '동성로점', + category: PlaceCategory.cafe, + source: PlaceSource.store, + hoursOrigin: ValueOrigin.unknown, + distanceM: 53, +); + +/// 가짜 장소 API — 받은 조건을 기록하고, 반려동물 조건이면 [petPlaces] 를 준다 +class _FakePlaceRepository implements PlaceRepository { + _FakePlaceRepository({this.places = const [], this.petPlaces = const []}); + + final List places; + final List petPlaces; + final List<({String? query, List categories, bool petOnly})> + calls = []; + + @override + Future search({ + String? query, + List categories = const [], + bool petOnly = false, + GeoPoint? near, + int? radiusM, + int limit = 20, + String? cursor, + }) async { + calls.add((query: query, categories: categories, petOnly: petOnly)); + final items = petOnly ? petPlaces : places; + return PlacePage(items: items, hasNext: false, total: items.length); + } + + @override + Future getPlace(String id) async { + if (id == _petPark.id) { + return PlaceDetail( + item: _petPark, + attribution: const Attribution(provider: '한국관광공사', asOf: '2026-06-26'), + overview: '대구 도심 한가운데의 공원', + hours: const PlaceHours(text: '상시 개방', status: HoursStatus.alwaysOpen), + pet: const PlacePet(conditions: '목줄 착용 · 실내 시설은 동반 불가'), + extra: const {'parking': '불가능', 'infocenter': '053-000-1111'}, + ); + } + return PlaceDetail( + item: places.firstWhere((p) => p.id == id), + attribution: const Attribution( + provider: '소상공인시장진흥공단 상가(상권)정보', + asOf: '202609', + ), + ); + } +} + class _FakeLocationService implements LocationService { const _FakeLocationService({this.granted = true}); @@ -77,6 +170,7 @@ Future<_FakeEventRepository> _pumpApp( WidgetTester tester, { Map prefs = const {}, _FakeEventRepository? repo, + _FakePlaceRepository? places, bool locationGranted = true, }) async { SharedPreferences.setMockInitialValues({'app_locale': 'ko', ...prefs}); @@ -87,6 +181,9 @@ Future<_FakeEventRepository> _pumpApp( overrides: [ sharedPreferencesProvider.overrideWithValue(preferences), eventRepositoryProvider.overrideWithValue(events), + placeRepositoryProvider.overrideWithValue( + places ?? _FakePlaceRepository(), + ), locationServiceProvider.overrideWithValue( _FakeLocationService(granted: locationGranted), ), @@ -106,6 +203,12 @@ Finder _text(String text) => find.byWidgetPredicate( (w.data ?? w.textSpan?.toPlainText() ?? '').replaceAll('⁠', '') == text, ); +/// 긴 화면(홈 아래 칸·상세 전체)을 스크롤 없이 확인하려고 화면을 길게 늘린다 +Future _useTallScreen(WidgetTester tester) async { + await tester.binding.setSurfaceSize(const Size(800, 2600)); + addTearDown(() => tester.binding.setSurfaceSize(null)); +} + void main() { testWidgets('첫 실행: 동네를 고르면 홈으로 가고, 그 동네 기준 이번 주말 행사를 보인다', (tester) async { final repo = await _pumpApp( @@ -153,6 +256,7 @@ void main() { }); testWidgets('행사를 누르면 상세에 시간·전화·출처가 보인다', (tester) async { + await _useTallScreen(tester); await _pumpApp( tester, prefs: {'onboarding_done': true}, @@ -179,4 +283,110 @@ void main() { expect(find.text('Home'), findsOneWidget); expect(find.text('My courses'), findsOneWidget); }); + + testWidgets('홈 반려동물 칸의 전체 보기는 반려견 동반 검색을 열고, 장소를 누르면 동반 조건이 보인다', ( + tester, + ) async { + await _useTallScreen(tester); + final places = _FakePlaceRepository(petPlaces: [_petPark]); + await _pumpApp(tester, prefs: {'onboarding_done': true}, places: places); + + expect(find.text('가까운 반려동물 동반 장소'), findsOneWidget); + expect(find.text('자연·공원 · 도보 4분'), findsOneWidget); + expect(places.calls.single.petOnly, isTrue); + + await tester.tap(find.text('전체 보기')); + await tester.pumpAndSettle(); + + expect(places.calls.last.petOnly, isTrue); + expect(find.text('1곳'), findsOneWidget); + expect(find.text('경상감영공원'), findsOneWidget); + + await tester.tap(find.text('경상감영공원')); + await tester.pumpAndSettle(); + + expect(find.text('반려동물 동반 정보'), findsOneWidget); + expect(_text('목줄 착용'), findsOneWidget); + expect(_text('실내 시설은 동반 불가'), findsOneWidget); + // 상시 개방은 한눈에 보기에 오르고, 주차 원문이 불가능이면 오르지 않는다 + expect(find.text('상시 개방'), findsWidgets); + expect(find.text('주차 가능'), findsNothing); + expect(find.text('출처: 한국관광공사 · 2026-06-26 기준'), findsOneWidget); + }); + + testWidgets('검색: 입력을 멈추면 검색어로 찾고, 분류 탭은 백엔드 분류로 바꿔 보낸다', (tester) async { + final places = _FakePlaceRepository(places: [_storeCafe]); + await _pumpApp(tester, prefs: {'onboarding_done': true}, places: places); + + await tester.tap(find.text('장소 이름으로 검색')); + await tester.pumpAndSettle(); + expect(places.calls.last.query, isNull); + + await tester.enterText(find.byType(TextField), '마실'); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pumpAndSettle(); + expect(places.calls.last.query, '마실'); + + await tester.tap(find.text('맛집·카페')); + await tester.pumpAndSettle(); + expect(places.calls.last.query, '마실'); + expect(places.calls.last.categories, [ + PlaceCategory.food, + PlaceCategory.cafe, + PlaceCategory.bar, + ]); + expect(find.text('마실커피 동성로점'), findsOneWidget); + expect(find.text('영업시간 미확인'), findsOneWidget); + }); + + testWidgets('조건에 맞는 곳이 없으면 반려견 동반을 빼고 다시 찾을 수 있다', (tester) async { + final places = _FakePlaceRepository(places: [_storeCafe]); + await _pumpApp(tester, prefs: {'onboarding_done': true}, places: places); + + await tester.tap(find.text('반려동물')); + await tester.pumpAndSettle(); + + expect(_text('조건에 맞는 곳이 없어요'), findsOneWidget); + await tester.tap(find.text('반려견 동반 빼고 보기')); + await tester.pumpAndSettle(); + + expect(places.calls.last.petOnly, isFalse); + expect(find.text('마실커피 동성로점'), findsOneWidget); + }); + + testWidgets('장소 상세: 영업시간을 모르면 미확인으로, 상가정보 출처는 기준년월로 보인다', (tester) async { + await _useTallScreen(tester); + final places = _FakePlaceRepository(places: [_storeCafe]); + await _pumpApp(tester, prefs: {'onboarding_done': true}, places: places); + + await tester.tap(find.text('장소 이름으로 검색')); + await tester.pumpAndSettle(); + await tester.tap(find.text('마실커피 동성로점')); + await tester.pumpAndSettle(); + + expect(_text('영업시간 미확인 · 방문 전 확인'), findsOneWidget); + expect(find.textContaining('대구 도심에서 도보'), findsOneWidget); + expect(find.text('출처: 소상공인시장진흥공단 상가(상권)정보 · 2026-09 기준'), findsOneWidget); + // 반려동물 정보가 없으면 칸을 숨기고, 1330 은 영어 화면에만 보인다 + expect(find.text('반려동물 동반 정보'), findsNothing); + expect(find.text('관광통역안내 1330'), findsNothing); + }); + + testWidgets('영어 화면의 장소 상세에는 1330 관광통역안내가 보인다', (tester) async { + await _useTallScreen(tester); + final places = _FakePlaceRepository(places: [_storeCafe]); + await _pumpApp( + tester, + prefs: {'onboarding_done': true, 'app_locale': 'en'}, + places: places, + ); + + await tester.tap(find.text('Search places by name')); + await tester.pumpAndSettle(); + await tester.tap(find.text('마실커피 동성로점')); + await tester.pumpAndSettle(); + + expect(find.text('Need help? Call 1330'), findsOneWidget); + expect(_text('Hours not confirmed · check before you go'), findsOneWidget); + }); } -- 2.52.0 From c21fbbd973f1ee1fcd64b874bc26e7b4e19349d9 Mon Sep 17 00:00:00 2001 From: ttipo Date: Wed, 4 Nov 2026 17:27:58 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(flutter):=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=95=84=EB=9E=98=EA=B0=80=20=EB=82=B4=EB=B9=84=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EB=B0=94=EC=97=90=20=EA=B0=80=EB=A0=A4?= =?UTF-8?q?=EC=A7=80=EA=B3=A0=20=EC=83=81=EC=84=B8=20=EC=9C=84=EA=B0=80=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=ED=91=9C=EC=8B=9C=EC=A4=84=EA=B3=BC=20?= =?UTF-8?q?=EA=B2=B9=EC=B9=98=EB=8A=94=20=EA=B2=83=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실기기(Android 16)에서 확인했다. Android 15 부터 앱이 화면 끝까지 그려지는데, 목록에 여백을 직접 준 화면은 시스템 여백이 빠져 마지막 출처 줄이 내비게이션 바에 가렸다 (장소 상세 · 행사 상세 · 행사 목록). 장소 상세는 사진 위에서 상태 표시줄 글자가 검은색이라 잘 안 보였고, 내리면 본문 제목이 상태 표시줄과 겹쳤다. 사진 위에서는 흰 글자와 위쪽 그늘을 두고, 사진을 지나면 흰 제목 줄 (뒤로 · 장소 이름)을 깐다. 상단 바가 없는 화면의 기본값(검은 글자)은 앱 뿌리에서 정한다. Co-Authored-By: Claude Opus 5.5 --- flutter/lib/app.dart | 8 + flutter/lib/core/theme/system_bars.dart | 15 ++ .../presentation/pages/event_detail_page.dart | 5 +- .../presentation/pages/event_list_page.dart | 8 +- .../presentation/pages/place_detail_page.dart | 231 ++++++++++++++---- 5 files changed, 219 insertions(+), 48 deletions(-) create mode 100644 flutter/lib/core/theme/system_bars.dart diff --git a/flutter/lib/app.dart b/flutter/lib/app.dart index 6e26dfd..2bef2bb 100644 --- a/flutter/lib/app.dart +++ b/flutter/lib/app.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:wandergo/core/l10n/l10n.dart'; import 'package:wandergo/core/l10n/locale_provider.dart'; import 'package:wandergo/core/router/app_router.dart'; import 'package:wandergo/core/theme/app_theme.dart'; +import 'package:wandergo/core/theme/system_bars.dart'; /// 앱 뿌리 — 테마 · 언어 · 화면 이동 class WanderGoApp extends ConsumerWidget { @@ -21,6 +23,12 @@ class WanderGoApp extends ConsumerWidget { supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, routerConfig: ref.watch(routerProvider), + // 상단 바가 없는 화면의 상태 표시줄 기본값 — 흰 바탕이라 검은 글자. + // 사진 위처럼 다르게 보여야 하는 화면은 안쪽에서 AnnotatedRegion 으로 덮어쓴다 + builder: (context, child) => AnnotatedRegion( + value: kStatusBarDark, + child: child ?? const SizedBox.shrink(), + ), ); } } diff --git a/flutter/lib/core/theme/system_bars.dart b/flutter/lib/core/theme/system_bars.dart new file mode 100644 index 0000000..5a8f6d8 --- /dev/null +++ b/flutter/lib/core/theme/system_bars.dart @@ -0,0 +1,15 @@ +import 'package:flutter/services.dart'; + +/// 상태 표시줄 — 밝은 바탕 위 검은 글자 (내비게이션 바 설정은 건드리지 않는다) +const SystemUiOverlayStyle kStatusBarDark = SystemUiOverlayStyle( + statusBarColor: Color(0x00000000), + statusBarIconBrightness: Brightness.dark, + statusBarBrightness: Brightness.light, +); + +/// 상태 표시줄 — 사진 위 흰 글자 +const SystemUiOverlayStyle kStatusBarLight = SystemUiOverlayStyle( + statusBarColor: Color(0x00000000), + statusBarIconBrightness: Brightness.light, + statusBarBrightness: Brightness.dark, +); diff --git a/flutter/lib/features/event/presentation/pages/event_detail_page.dart b/flutter/lib/features/event/presentation/pages/event_detail_page.dart index cefdd43..0266936 100644 --- a/flutter/lib/features/event/presentation/pages/event_detail_page.dart +++ b/flutter/lib/features/event/presentation/pages/event_detail_page.dart @@ -71,7 +71,10 @@ class _DetailBody extends StatelessWidget { ); return ListView( - padding: const EdgeInsets.only(bottom: 32), + // 시스템 내비게이션 바 아래로 그려지므로(Android 15+) 그 높이만큼 더 띄운다 + padding: EdgeInsets.only( + bottom: 32 + MediaQuery.paddingOf(context).bottom, + ), children: [ if (imageUrl != null) EventImage( diff --git a/flutter/lib/features/event/presentation/pages/event_list_page.dart b/flutter/lib/features/event/presentation/pages/event_list_page.dart index 88b999e..c058c2e 100644 --- a/flutter/lib/features/event/presentation/pages/event_list_page.dart +++ b/flutter/lib/features/event/presentation/pages/event_list_page.dart @@ -101,7 +101,13 @@ class _EventListPageState extends ConsumerState { ); } return ListView.separated( - padding: const EdgeInsets.symmetric(horizontal: kPagePadding), + // 시스템 내비게이션 바 아래로 그려지므로(Android 15+) 그 높이만큼 더 띄운다 + padding: EdgeInsets.fromLTRB( + kPagePadding, + 0, + kPagePadding, + MediaQuery.paddingOf(context).bottom, + ), itemCount: _items.length + 1, separatorBuilder: (_, index) => index < _items.length - 1 ? const Divider() : const SizedBox.shrink(), diff --git a/flutter/lib/features/place/presentation/pages/place_detail_page.dart b/flutter/lib/features/place/presentation/pages/place_detail_page.dart index fd0bd35..11010bf 100644 --- a/flutter/lib/features/place/presentation/pages/place_detail_page.dart +++ b/flutter/lib/features/place/presentation/pages/place_detail_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -6,6 +7,7 @@ import 'package:wandergo/core/icons/app_icons.dart'; import 'package:wandergo/core/l10n/l10n.dart'; import 'package:wandergo/core/theme/app_colors.dart'; import 'package:wandergo/core/theme/app_theme.dart'; +import 'package:wandergo/core/theme/system_bars.dart'; import 'package:wandergo/core/utils/format.dart'; import 'package:wandergo/core/utils/geo.dart'; import 'package:wandergo/core/utils/keep_all.dart'; @@ -69,61 +71,182 @@ class PlaceDetailPage extends ConsumerWidget { } } -class _DetailBody extends StatelessWidget { +/// 대표 사진 주소 — 큰 사진이 없으면 작은 사진 +String? _photoOf(PlaceDetail detail) => + detail.imageUrl ?? detail.item.imageThumbUrl; + +double _heroHeightOf(PlaceDetail detail) => + _photoOf(detail) == null ? _kHeroHeightNoPhoto : _kHeroHeight; + +class _DetailBody extends StatefulWidget { const _DetailBody({required this.detail}); final PlaceDetail detail; @override - Widget build(BuildContext context) { - final item = detail.item; + State<_DetailBody> createState() => _DetailBodyState(); +} + +class _DetailBodyState extends State<_DetailBody> { + final ScrollController _controller = ScrollController(); + + /// 사진을 지나 내려왔는지 — 위에 흰 제목 줄을 깐다 + bool _pastHero = false; + + @override + void initState() { + super.initState(); + _controller.addListener(_onScroll); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onScroll() { final topInset = MediaQuery.paddingOf(context).top; - return Stack( - children: [ - ListView( - padding: EdgeInsets.zero, - children: [ - _Hero(detail: detail), - Transform.translate( - offset: const Offset(0, -_kSheetOverlap), - child: Container( - decoration: const BoxDecoration( - color: AppColors.background, - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - padding: const EdgeInsets.fromLTRB( - kPagePadding, - 24, - kPagePadding, - 28, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _TitleBlock(detail: detail), - PlaceGlance(detail: detail), - if (item.petPolicy != PetPolicy.unknown) - PlacePetSection(detail: detail), - PlaceInfoSection(detail: detail), - if (detail.overview != null) - PlaceAboutSection( - text: detail.overview!, - provider: detail.attribution.provider, - ), - NearbyEventsSection(point: (lat: item.lat, lng: item.lng)), - NearbyPlacesSection( - placeKey: (id: item.id, lat: item.lat, lng: item.lng), + final threshold = + _heroHeightOf(widget.detail) - + _kSheetOverlap - + topInset - + kToolbarHeight; + final pastHero = _controller.offset > threshold; + if (pastHero != _pastHero) setState(() => _pastHero = pastHero); + } + + @override + Widget build(BuildContext context) { + final detail = widget.detail; + final item = detail.item; + final insets = MediaQuery.paddingOf(context); + // 사진 위에서는 흰 글자, 흰 바탕(제목 줄·자리 표시) 위에서는 검은 글자 + final onPhoto = !_pastHero && _photoOf(detail) != null; + return AnnotatedRegion( + value: onPhoto ? kStatusBarLight : kStatusBarDark, + child: Stack( + children: [ + ListView( + controller: _controller, + // 시스템 내비게이션 바 아래로 그려지므로(Android 15+) 그 높이만큼 띄운다 + padding: EdgeInsets.only(bottom: insets.bottom), + children: [ + _Hero(detail: detail), + Transform.translate( + offset: const Offset(0, -_kSheetOverlap), + child: Container( + decoration: const BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.vertical( + top: Radius.circular(20), ), - _SourceFooter(attribution: detail.attribution), - ], + ), + padding: const EdgeInsets.fromLTRB( + kPagePadding, + 24, + kPagePadding, + 28, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _TitleBlock(detail: detail), + PlaceGlance(detail: detail), + if (item.petPolicy != PetPolicy.unknown) + PlacePetSection(detail: detail), + PlaceInfoSection(detail: detail), + if (detail.overview != null) + PlaceAboutSection( + text: detail.overview!, + provider: detail.attribution.provider, + ), + NearbyEventsSection( + point: (lat: item.lat, lng: item.lng), + ), + NearbyPlacesSection( + placeKey: (id: item.id, lat: item.lat, lng: item.lng), + ), + _SourceFooter(attribution: detail.attribution), + ], + ), ), ), + ], + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: _TopBar( + title: item.displayName, + topInset: insets.top, + pastHero: _pastHero, ), - ], + ), + ], + ), + ); + } +} + +/// 위쪽 줄 — 사진 위에서는 흰 원 뒤로 버튼만, 사진을 지나면 흰 바탕 + 뒤로 + 이름 +class _TopBar extends StatelessWidget { + const _TopBar({ + required this.title, + required this.topInset, + required this.pastHero, + }); + + final String title; + final double topInset; + final bool pastHero; + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: topInset + kToolbarHeight, + padding: EdgeInsets.only(top: topInset, left: 4, right: kPagePadding), + decoration: BoxDecoration( + color: pastHero ? AppColors.background : Colors.transparent, + border: Border( + bottom: BorderSide( + color: pastHero ? AppColors.divider : Colors.transparent, + ), ), - // 내려도 늘 보이는 뒤로 버튼 - Positioned(top: topInset + 12, left: 12, child: const _BackButton()), - ], + ), + child: Row( + children: [ + if (pastHero) + IconButton( + tooltip: context.l10n.back, + onPressed: () => context.pop(), + icon: const AppIcon( + AppIconData.chevronLeft, + color: AppColors.text, + ), + ) + else + const Padding( + padding: EdgeInsets.only(left: 8), + child: _BackButton(), + ), + const SizedBox(width: 4), + Expanded( + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: pastHero ? 1 : 0, + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).appBarTheme.titleTextStyle, + ), + ), + ), + ], + ), ); } } @@ -158,9 +281,9 @@ class _Hero extends StatelessWidget { Widget build(BuildContext context) { final l10n = context.l10n; final item = detail.item; - final url = detail.imageUrl ?? item.imageThumbUrl; + final url = _photoOf(detail); final license = item.imageLicense; - final height = url == null ? _kHeroHeightNoPhoto : _kHeroHeight; + final height = _heroHeightOf(detail); return SizedBox( height: height, child: Stack( @@ -174,6 +297,22 @@ class _Hero extends StatelessWidget { radius: 0, iconSize: 40, ), + if (url != null) + Positioned( + left: 0, + right: 0, + top: 0, + height: MediaQuery.paddingOf(context).top + kToolbarHeight + 24, + child: const DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0x59161A23), Color(0x00161A23)], + ), + ), + ), + ), if (url != null && license != null) ...[ const Positioned( left: 0, -- 2.52.0 From 377181eade65536555cfbc8023c23fb3733dad43 Mon Sep 17 00:00:00 2001 From: ttipo Date: Wed, 4 Nov 2026 17:27:58 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(flutter):=20=ED=99=88=20=EC=B6=95?= =?UTF-8?q?=EC=A0=9C=C2=B7=ED=96=89=EC=82=AC=20=EB=B0=98=EA=B2=BD=EC=9D=84?= =?UTF-8?q?=20120km=20=EC=97=90=EC=84=9C=2050km=20=EB=A1=9C=20=EC=A4=84?= =?UTF-8?q?=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 120km 는 관광공사 행사뿐이던 2026-11-03 에 대구 행사가 너무 적어 경북까지 넓힌 값이다. KOPIS·문화포털이 들어온 뒤로는 대구 안에서도 충분히 차고(도심 기준 이번 주말 50km 32건 · 120km 42건), 120km 면 영덕·영주처럼 차로 2시간 거리까지 나왔다. 50km 는 군위를 포함한 대구 전역과 경산·칠곡까지다(2026-11-04 결정). Co-Authored-By: Claude Opus 5.5 --- docs/design/place-db.md | 4 +++- .../event/presentation/providers/event_providers.dart | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/design/place-db.md b/docs/design/place-db.md index b5bf835..568379d 100644 --- a/docs/design/place-db.md +++ b/docs/design/place-db.md @@ -380,7 +380,9 @@ node dist/commands/place-sync.js tour-kor | `GET /api/events/{id}` | 소개·공연 시간·요금·장소·홈페이지·전화·출처. 숨김이면 RES_001 | - **화면 규칙**(2026-11-03 합의): - - 홈 '이번 주말 축제·행사' = `from`=토 · `to`=일 · 동네 중심 좌표 · `radius`=120000 → 대구 행사가 먼저, 다음 경북(거리 표시 "대구에서 85km"). + - 홈 '이번 주말 축제·행사' = `from`=토 · `to`=일 · 동네 중심 좌표 · `radius`=50000 → 대구 전역(군위 포함)과 경산·칠곡 등 바로 옆 도시까지 거리순. + 처음(2026-11-03)에는 관광공사 행사뿐이라 120km(경북)였다. KOPIS·문화포털이 들어온 뒤 대구 도심 기준 이번 주말 50km 32건 · 120km 42건이 되었고, + 120km 면 영덕·영주(차로 2시간)까지 나와 2026-11-04 50km 로 줄였다. - **비면** 같은 반경으로 `from`=오늘 · `to`=오늘+30일을 불러 제목을 '다가오는 축제·행사'로 바꿔 보여 준다(빈 칸을 두지 않는다). - 장소 상세 '근처 축제·행사' = 장소 좌표 + 기본 반경(5km), 없으면 칸을 숨긴다. - 계약 테스트: `backend/test/festival.e2e-spec.ts` — 2030년 날짜·동해 좌표 픽스처라 실행 날짜와 무관하다. diff --git a/flutter/lib/features/event/presentation/providers/event_providers.dart b/flutter/lib/features/event/presentation/providers/event_providers.dart index 04337fe..deacc6e 100644 --- a/flutter/lib/features/event/presentation/providers/event_providers.dart +++ b/flutter/lib/features/event/presentation/providers/event_providers.dart @@ -6,8 +6,10 @@ import 'package:wandergo/features/event/data/models/event_models.dart'; import 'package:wandergo/features/event/data/repositories/event_repository.dart'; import 'package:wandergo/features/location/presentation/providers/base_location_provider.dart'; -/// 홈 축제 칸 반경 — 대구 근교(경북)까지 거리순(대구 먼저). 2026-11-03 합의 -const int kHomeEventRadiusM = 120000; +/// 홈 축제 칸 반경 — 대구 전역(군위 포함)과 경산·칠곡 같은 바로 옆 도시까지, 거리순. +/// 2026-11-03 에는 관광공사 행사뿐이라 120km(경북)였으나, KOPIS·문화포털이 들어와 +/// 대구 안에서도 충분히 차서 2026-11-04 50km 로 줄였다(120km 면 영덕·영주 등 차로 2시간 거리까지 나왔다). +const int kHomeEventRadiusM = 50000; /// 주말이 비었을 때 대신 보이는 '다가오는' 기간(일) const int kUpcomingDays = 30; @@ -32,7 +34,7 @@ class HomeEvents { final bool isUpcoming; } -/// 홈 '이번 주말 축제·행사' — 기준 위치 반경 120km 거리순, 비면 같은 반경 30일 +/// 홈 '이번 주말 축제·행사' — 기준 위치 반경 50km 거리순, 비면 같은 반경 30일 final homeEventsProvider = FutureProvider((ref) async { final base = await ref.watch(basePointProvider.future); final repo = ref.watch(eventRepositoryProvider); -- 2.52.0