generated from ttipo/project-base
feat(flutter): 장소 검색·상세 화면과 홈 검색창·카테고리·반려동물 장소 칸
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 21m21s
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 12m16s
CI / Frontend (lint · type-check · test) (pull_request) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Has been cancelled
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 21m21s
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 12m16s
CI / Frontend (lint · type-check · test) (pull_request) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Has been cancelled
디자인 캔버스 4(검색)·6·7(장소 상세) 중 지금 장소 API 로 되는 부분을 붙였다. - 검색은 반경 20km(백엔드 최대) 거리순. 대구 도심에서 대구 전역이 들어온다. - 영업시간을 모르는 곳은 "영업시간 미확인 · 방문 전 확인"으로 보인다. - 한눈에 보기는 공공데이터 원문으로 확실한 것만 올린다(주차 '인근 공영주차장' 같은 애매한 값은 빼기). - 함께 가기 좋은 곳에는 확인 안 된 상가정보 매장을 넣지 않는다(2026-11-02 합의). - 1330 관광통역안내는 영어 화면에만(2026-10-29 합의). 검색의 '축제' 탭과 실내·무료·주차·이번 주말 조건은 데이터가 없어 넣지 않았다 (행사 API 검색어 조건 · 운영자 확인 장소 · 날씨). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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))`
|
||||
|
||||
+7
-5
@@ -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()`.
|
||||
|
||||
@@ -25,7 +25,58 @@ enum AppIconData {
|
||||
'<path d="M12 20.5s-6.5-5.6-6.5-11a6.5 6.5 0 0 1 13 0c0 5.4-6.5 11-6.5 11z"{fill}/><circle cx="12" cy="9.5" r="2.25"/>',
|
||||
),
|
||||
chevronDown('<path d="M7 10l5 5 5-5"/>'),
|
||||
chevronRight('<path d="M10 7l5 5-5 5"/>');
|
||||
chevronRight('<path d="M10 7l5 5-5 5"/>'),
|
||||
chevronLeft('<path d="M14.5 5.5L8 12l6.5 6.5"/>'),
|
||||
search('<circle cx="11" cy="11" r="6.5"/><path d="M20 20l-4.25-4.25"/>'),
|
||||
close('<path d="M6 6l12 12M18 6L6 18"/>'),
|
||||
check('<path d="M5 12.5l4.5 4.5L19 7.5"/>'),
|
||||
info(
|
||||
'<circle cx="12" cy="12" r="8.5"/><path d="M12 7.75v5"/><path d="M12 16.25h.01" stroke-width="2.5"/>',
|
||||
),
|
||||
ban('<circle cx="12" cy="12" r="8.5"/><path d="M9 9l6 6M15 9l-6 6"/>'),
|
||||
clock('<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>'),
|
||||
parking(
|
||||
'<rect x="4" y="4" width="16" height="16" rx="4"/><path d="M10 16.5v-9h3a2.75 2.75 0 0 1 0 5.5h-3"/>',
|
||||
),
|
||||
ticket(
|
||||
'<path d="M4 8a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v1.75a2.25 2.25 0 0 0 0 4.5V16a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-1.75a2.25 2.25 0 0 0 0-4.5z"/><path d="M14.5 6v2M14.5 11v2M14.5 16v2"/>',
|
||||
),
|
||||
externalLink(
|
||||
'<path d="M14 4.5h5.5V10"/><path d="M19.5 4.5L11 13"/><path d="M18.5 14v4.5a1 1 0 0 1-1 1h-12a1 1 0 0 1-1-1v-12a1 1 0 0 1 1-1H10"/>',
|
||||
),
|
||||
|
||||
/// 반려동물 — 선 + 16% 채움(한눈에 보기)
|
||||
paw(
|
||||
'<ellipse cx="5.6" cy="11.1" rx="1.6" ry="2" transform="rotate(-24 5.6 11.1)"{fill}/><ellipse cx="9.4" cy="6.9" rx="1.65" ry="2.1" transform="rotate(-8 9.4 6.9)"{fill}/><ellipse cx="14.6" cy="6.9" rx="1.65" ry="2.1" transform="rotate(8 14.6 6.9)"{fill}/><ellipse cx="18.4" cy="11.1" rx="1.6" ry="2" transform="rotate(24 18.4 11.1)"{fill}/><path d="M12 12.6c-2.55 0-5.5 3.1-5.5 5.45 0 1.5 1.15 2.2 2.5 2.2 1.2 0 1.9-.6 3-.6s1.8.6 3 .6c1.35 0 2.5-.7 2.5-2.2 0-2.35-2.95-5.45-5.5-5.45z"{fill}/>',
|
||||
),
|
||||
|
||||
/// 반려동물 — 꽉 찬 작은 발바닥(목록 글자 앞)
|
||||
pawSolid(
|
||||
'<g fill="currentColor" stroke="none"><ellipse cx="5.4" cy="11" rx="2.1" ry="2.5" transform="rotate(-24 5.4 11)"/><ellipse cx="9.3" cy="6.6" rx="2.15" ry="2.6" transform="rotate(-8 9.3 6.6)"/><ellipse cx="14.7" cy="6.6" rx="2.15" ry="2.6" transform="rotate(8 14.7 6.6)"/><ellipse cx="18.6" cy="11" rx="2.1" ry="2.5" transform="rotate(24 18.6 11)"/><path d="M12 12.6c-2.55 0-5.5 3.1-5.5 5.45 0 1.5 1.15 2.2 2.5 2.2 1.2 0 1.9-.6 3-.6s1.8.6 3 .6c1.35 0 2.5-.7 2.5-2.2 0-2.35-2.95-5.45-5.5-5.45z"/></g>',
|
||||
),
|
||||
|
||||
// 홈 카테고리 — 늘 같은 색 16% 로 채운다(선 1.6, 30px)
|
||||
categorySights(
|
||||
'<rect x="3.25" y="7.25" width="17.5" height="12.5" rx="3" fill="currentColor" fill-opacity=".16"/><path d="M8.5 7.25l1.1-1.9a1.2 1.2 0 0 1 1.04-.6h2.72a1.2 1.2 0 0 1 1.04.6l1.1 1.9"/><circle cx="12" cy="13.5" r="3.5"/><circle cx="17.25" cy="10" r="0.7" fill="currentColor" stroke="none"/>',
|
||||
),
|
||||
categoryFestival(
|
||||
'<path d="M3.75 20.25l4.6-10.9 6.3 6.3z" fill="currentColor" fill-opacity=".16"/><path d="M10.75 7.75c.25-1.75 2.5-2.25 2.5-4"/><path d="M16.25 13.25c1.75-.25 2.25-2.5 4-2.5"/><circle cx="17" cy="7" r="1" fill="currentColor" stroke="none"/><circle cx="20" cy="3.75" r="0.9" fill="currentColor" stroke="none"/><circle cx="20.5" cy="16.25" r="0.9" fill="currentColor" stroke="none"/>',
|
||||
),
|
||||
categoryPet(
|
||||
'<g fill="currentColor" fill-opacity=".16"><ellipse cx="5.6" cy="11.1" rx="1.6" ry="2" transform="rotate(-24 5.6 11.1)"/><ellipse cx="9.4" cy="6.9" rx="1.65" ry="2.1" transform="rotate(-8 9.4 6.9)"/><ellipse cx="14.6" cy="6.9" rx="1.65" ry="2.1" transform="rotate(8 14.6 6.9)"/><ellipse cx="18.4" cy="11.1" rx="1.6" ry="2" transform="rotate(24 18.4 11.1)"/><path d="M12 12.6c-2.55 0-5.5 3.1-5.5 5.45 0 1.5 1.15 2.2 2.5 2.2 1.2 0 1.9-.6 3-.6s1.8.6 3 .6c1.35 0 2.5-.7 2.5-2.2 0-2.35-2.95-5.45-5.5-5.45z"/></g>',
|
||||
),
|
||||
categoryFood(
|
||||
'<path d="M5.25 9.5h11v4.25a5 5 0 0 1-5 5h-1a5 5 0 0 1-5-5z" fill="currentColor" fill-opacity=".16"/><path d="M16.25 11h1.4a2.35 2.35 0 0 1 0 4.7h-1.4"/><path d="M4 20.75h13.5"/><path d="M8 4.25v2M10.75 4.25v2M13.5 4.25v2"/>',
|
||||
),
|
||||
categoryStay(
|
||||
'<path d="M3.5 5.5v14"/><path d="M10 16.5v-3.75A1.75 1.75 0 0 1 11.75 11h6.75a2 2 0 0 1 2 2v3.5" fill="currentColor" fill-opacity=".16"/><path d="M3.5 16.5h17v3"/><rect x="5.25" y="13.5" width="3.75" height="3" rx="1.5"/>',
|
||||
),
|
||||
categoryIndoor(
|
||||
'<path d="M3.5 9.25L12 4.5l8.5 4.75z" fill="currentColor" fill-opacity=".16"/><path d="M5 11h14"/><path d="M6.75 11v6M10.25 11v6M13.75 11v6M17.25 11v6"/><path d="M5 17h14"/><path d="M3.5 19.5h17"/>',
|
||||
),
|
||||
categoryNature(
|
||||
'<path d="M12 3.5a4.5 4.5 0 0 1 4.4 3.6 3.75 3.75 0 0 1-.15 7.4H7.75a3.75 3.75 0 0 1-.15-7.4A4.5 4.5 0 0 1 12 3.5z" fill="currentColor" fill-opacity=".16"/><path d="M12 20.5v-6"/><path d="M8.5 20.5h7"/>',
|
||||
);
|
||||
|
||||
const AppIconData(this.body);
|
||||
|
||||
|
||||
@@ -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<GoRouter>((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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'}) {
|
||||
|
||||
@@ -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());
|
||||
@@ -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<void> _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),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
/// 장소 — 백엔드 PlaceListItemDto / PlaceDetailDto 와 1:1 대응한다.
|
||||
library;
|
||||
|
||||
import 'package:wandergo/shared/models/source_models.dart';
|
||||
|
||||
/// 코드값을 enum 으로 읽는다. 모르는 값이면 null — 백엔드가 값을 늘려도 앱이 죽지 않게 한다
|
||||
T? _parseCode<T extends Enum>(
|
||||
List<T> 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<String, dynamic> 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<String, dynamic>? 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<PetSize>? sizes;
|
||||
|
||||
/// 조건 원문 — "목줄 착용 · 배변봉투 지참" 처럼 ' · ' 로 이어진다
|
||||
final String? conditions;
|
||||
|
||||
factory PlacePet.fromJson(Map<String, dynamic>? json) {
|
||||
if (json == null) return const PlacePet();
|
||||
final sizes = json['sizes'] as List<dynamic>?;
|
||||
return PlacePet(
|
||||
sizes: sizes?.map(PetSize.parse).whereType<PetSize>().toList(),
|
||||
conditions: _text(json['conditions']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 운영자 추천 정보 — 추천(O)하고 확인한 장소만
|
||||
class PlaceCuration {
|
||||
const PlaceCuration({this.reason, this.officialUrl});
|
||||
|
||||
/// 추천 이유(운영자가 직접 쓴 글)
|
||||
final String? reason;
|
||||
|
||||
/// 업체 공식 채널
|
||||
final String? officialUrl;
|
||||
|
||||
factory PlaceCuration.fromJson(Map<String, dynamic> 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<String, String> extra;
|
||||
final PlaceCuration? curation;
|
||||
|
||||
factory PlaceDetail.fromJson(Map<String, dynamic> json) {
|
||||
final extra = json['extra'] as Map<String, dynamic>?;
|
||||
final curation = json['curation'] as Map<String, dynamic>?;
|
||||
return PlaceDetail(
|
||||
item: PlaceItem.fromJson(json),
|
||||
attribution: Attribution.fromJson(
|
||||
json['attribution'] as Map<String, dynamic>,
|
||||
),
|
||||
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<String, dynamic>?),
|
||||
pet: PlacePet.fromJson(json['pet'] as Map<String, dynamic>?),
|
||||
extra: {
|
||||
for (final entry in (extra ?? const <String, dynamic>{}).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<PlaceItem> items;
|
||||
final bool hasNext;
|
||||
final String? nextCursor;
|
||||
|
||||
/// 조건에 맞는 전체 수. 계산 비용이 크면 null
|
||||
final int? total;
|
||||
|
||||
factory PlacePage.fromJson(Map<String, dynamic> json) {
|
||||
final pageInfo = json['pageInfo'] as Map<String, dynamic>;
|
||||
return PlacePage(
|
||||
items: (json['items'] as List<dynamic>)
|
||||
.map((e) => PlaceItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
hasNext: pageInfo['hasNext'] as bool? ?? false,
|
||||
nextCursor: pageInfo['nextCursor'] as String?,
|
||||
total: pageInfo['total'] as int?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<PlacePage> search({
|
||||
String? query,
|
||||
List<PlaceCategory> categories = const [],
|
||||
bool petOnly = false,
|
||||
GeoPoint? near,
|
||||
int? radiusM,
|
||||
int limit = 20,
|
||||
String? cursor,
|
||||
}) async {
|
||||
final response = await _dio.get<Map<String, dynamic>>(
|
||||
'/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<PlaceDetail> getPlace(String id) async {
|
||||
final response = await _dio.get<Map<String, dynamic>>('/places/$id');
|
||||
return PlaceDetail.fromJson(response.data!);
|
||||
}
|
||||
}
|
||||
|
||||
final placeRepositoryProvider = Provider<PlaceRepository>(
|
||||
(ref) => PlaceRepository(ref.watch(dioProvider)),
|
||||
);
|
||||
@@ -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<String> 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);
|
||||
@@ -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<PlaceCategory> 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);
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<PlaceSearchPage> createState() => _PlaceSearchPageState();
|
||||
}
|
||||
|
||||
class _PlaceSearchPageState extends ConsumerState<PlaceSearchPage> {
|
||||
late final TextEditingController _controller;
|
||||
late PlaceQuery _query;
|
||||
Timer? _debounce;
|
||||
|
||||
final List<PlaceItem> _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<void> _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<String> onChanged;
|
||||
final ValueChanged<String> 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<PlaceTab> 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<bool> 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<bool> 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<PlaceDetail, String>(
|
||||
(ref, id) => ref.watch(placeRepositoryProvider).getPlace(id),
|
||||
);
|
||||
|
||||
/// 홈 '가까운 반려동물 동반 장소' — 기준 위치에서 가까운 순
|
||||
final homePetPlacesProvider = FutureProvider<List<PlaceItem>>((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<List<EventItem>, 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<List<PlaceItem>, 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();
|
||||
});
|
||||
@@ -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<Widget> 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<String, String> 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<void> _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 = <Widget>[
|
||||
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<PlaceAboutSection> createState() => _PlaceAboutSectionState();
|
||||
}
|
||||
|
||||
class _PlaceAboutSectionState extends State<PlaceAboutSection> {
|
||||
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)),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
+171
-7
@@ -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유형"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<String, dynamic> _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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<PlaceItem> places;
|
||||
final List<PlaceItem> petPlaces;
|
||||
final List<({String? query, List<PlaceCategory> categories, bool petOnly})>
|
||||
calls = [];
|
||||
|
||||
@override
|
||||
Future<PlacePage> search({
|
||||
String? query,
|
||||
List<PlaceCategory> 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<PlaceDetail> 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<String, Object> 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<void> _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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user