feat(flutter): 장소 검색·상세 화면과 홈 검색창·카테고리·반려동물 장소 칸 #3

Merged
ttipo merged 4 commits from feat/flutter-places into main 2026-11-04 20:25:40 +09:00
29 changed files with 3816 additions and 45 deletions
Showing only changes of commit 4bd142a9b7 - Show all commits
+1
View File
@@ -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
View File
@@ -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()`.
+52 -1
View File
@@ -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);
+22
View File
@@ -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);
+25
View File
@@ -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();
}
}
+12
View File
@@ -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);
}
+6
View File
@@ -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'}) {
+27
View File
@@ -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,
),
),
],
);
+91 -2
View File
@@ -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
View File
@@ -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,
),
],
),
),
],
);
}
}
+10
View File
@@ -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');
});
});
}
+41
View File
@@ -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);
});
});
}
+210
View File
@@ -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);
});
}