diff --git a/.claude/skills/flutter/SKILL.md b/.claude/skills/flutter/SKILL.md
index eb3d68f..ad7b124 100644
--- a/.claude/skills/flutter/SKILL.md
+++ b/.claude/skills/flutter/SKILL.md
@@ -29,6 +29,7 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
| 테마·색 | `lib/core/theme/` | `AppColors` · `AppTheme` — 색·글자 크기를 화면에 새로 하드코딩하지 않는다 |
| 한/영 문구 | `lib/l10n/*.arb` | `context.l10n.키`. 생성물 `lib/l10n/generated/` 는 git 제외 — `flutter pub get` 이 만든다 |
| 한국어 줄바꿈 | `lib/core/utils/keep_all.dart` | 한국어 문단은 `keepAll()` 로 단어 단위 줄바꿈 (Flutter 기본은 글자 단위) |
+| 공공데이터 사진 | `lib/shared/widgets/public_image.dart` | 공공누리 Type3 은 자르지 않는다. 사진이 없으면 분류별 자리 표시. 출처 모델은 `shared/models/source_models.dart` |
> **확장 시 따르는 규칙** (아래 "프로젝트 구조"는 목표 구조 — 기능 추가 시 점진적으로 채운다):
> - **상태관리**: `features/user` 의 model→repository→provider 패턴을 복제. 위젯에서 `ref.watch(userProvider(id))`
diff --git a/flutter/README.md b/flutter/README.md
index df38640..0a75a1b 100644
--- a/flutter/README.md
+++ b/flutter/README.md
@@ -43,14 +43,15 @@ lib/
│ ├── l10n/ context.l10n · 앱 언어 선택
│ ├── storage/ shared_preferences (민감하지 않은 설정만)
│ ├── icons/ 디자인 캔버스의 선 아이콘(SVG)
-│ └── utils/ 순수 함수 — 거리·기간 표기, 주말 계산, 한국어 줄바꿈(keepAll)
+│ └── utils/ 순수 함수 — 거리·기간 표기, 주말 계산, 직선 거리·도보 시간, 한국어 줄바꿈(keepAll)
├── features/
│ ├── onboarding/ 첫 실행 · 위치 권한
│ ├── location/ 기준 위치(현재 위치 / 대구 동네)
-│ ├── home/ 홈
-│ └── event/ 축제·공연·행사·전시 목록·상세
+│ ├── home/ 홈 — 검색창·카테고리·축제·반려동물 장소 칸
+│ ├── event/ 축제·공연·행사·전시 목록·상세
+│ └── place/ 장소 검색·상세 (domain/ 에 검색 조건·공공데이터 원문 해석)
├── l10n/ app_ko.arb · app_en.arb (생성물 generated/ 는 git 제외)
-└── shared/ 여러 화면이 쓰는 위젯·유틸
+└── shared/ 여러 화면이 쓰는 위젯·유틸·출처 모델(공공누리 유형·출처 표기)
```
## 규칙 메모
@@ -58,5 +59,6 @@ lib/
- 화면 문구는 `lib/l10n/*.arb` 에만 쓴다. 출시는 한국어·영어, 설계는 en/ja/zh-Hans/zh-Hant 까지 전제.
- 한국어 문단은 `keepAll()` 을 거쳐 단어 단위로 줄바꿈한다(Flutter 기본은 글자 단위).
- 카드·배지 대신 흰 바탕 위 평면 목록과 구분선, 색 글자로 메타 정보를 보인다.
-- 공공누리 Type3 사진은 자르지 않는다(`EventImage` 가 contain 으로 보인다).
+- 공공누리 Type3 사진은 자르지 않는다(`PublicImage` 가 contain 으로 보인다 — 행사·장소 사진 모두 이 위젯을 거친다).
+- 영업시간을 모르는 장소는 "영업시간 미확인 · 방문 전 확인"으로 보이고, 확인 안 된 상가정보 매장은 "함께 가기 좋은 곳"에 고르지 않는다.
- 실패 문구는 에러 사전(`error_lexicon.g.dart`)에서만 가져온다 — `errorMessageOf()`.
diff --git a/flutter/lib/core/icons/app_icons.dart b/flutter/lib/core/icons/app_icons.dart
index 98bec59..ab466f9 100644
--- a/flutter/lib/core/icons/app_icons.dart
+++ b/flutter/lib/core/icons/app_icons.dart
@@ -25,7 +25,58 @@ enum AppIconData {
'',
),
chevronDown(''),
- chevronRight('');
+ chevronRight(''),
+ chevronLeft(''),
+ search(''),
+ close(''),
+ check(''),
+ info(
+ '',
+ ),
+ ban(''),
+ clock(''),
+ parking(
+ '',
+ ),
+ ticket(
+ '',
+ ),
+ externalLink(
+ '',
+ ),
+
+ /// 반려동물 — 선 + 16% 채움(한눈에 보기)
+ paw(
+ '',
+ ),
+
+ /// 반려동물 — 꽉 찬 작은 발바닥(목록 글자 앞)
+ pawSolid(
+ '',
+ ),
+
+ // 홈 카테고리 — 늘 같은 색 16% 로 채운다(선 1.6, 30px)
+ categorySights(
+ '',
+ ),
+ categoryFestival(
+ '',
+ ),
+ categoryPet(
+ '',
+ ),
+ categoryFood(
+ '',
+ ),
+ categoryStay(
+ '',
+ ),
+ categoryIndoor(
+ '',
+ ),
+ categoryNature(
+ '',
+ );
const AppIconData(this.body);
diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart
index e82ed3e..6e1ea7d 100644
--- a/flutter/lib/core/router/app_router.dart
+++ b/flutter/lib/core/router/app_router.dart
@@ -10,6 +10,9 @@ import 'package:wandergo/features/event/presentation/pages/event_list_page.dart'
import 'package:wandergo/features/home/presentation/pages/home_page.dart';
import 'package:wandergo/features/onboarding/presentation/pages/onboarding_page.dart';
import 'package:wandergo/features/onboarding/presentation/providers/onboarding_provider.dart';
+import 'package:wandergo/features/place/domain/place_search.dart';
+import 'package:wandergo/features/place/presentation/pages/place_detail_page.dart';
+import 'package:wandergo/features/place/presentation/pages/place_search_page.dart';
import 'package:wandergo/shared/widgets/coming_soon_page.dart';
/// 화면 이동 — 첫 실행을 마치기 전에는 어느 경로든 첫 실행 화면으로 보낸다.
@@ -87,6 +90,25 @@ final routerProvider = Provider((ref) {
builder: (context, state) =>
EventDetailPage(id: state.pathParameters['id']!),
),
+ GoRoute(
+ path: AppRoutes.search,
+ builder: (context, state) {
+ final query = state.uri.queryParameters;
+ return PlaceSearchPage(
+ initialQuery: PlaceQuery(
+ text: query['q'] ?? '',
+ tab: PlaceTab.parse(query['tab']),
+ petOnly: query['pet'] == '1',
+ ),
+ autofocus: query['focus'] == '1',
+ );
+ },
+ ),
+ GoRoute(
+ path: AppRoutes.placeDetailPattern,
+ builder: (context, state) =>
+ PlaceDetailPage(id: state.pathParameters['id']!),
+ ),
],
);
ref.onDispose(router.dispose);
diff --git a/flutter/lib/core/router/app_routes.dart b/flutter/lib/core/router/app_routes.dart
index 4f71464..7986d49 100644
--- a/flutter/lib/core/router/app_routes.dart
+++ b/flutter/lib/core/router/app_routes.dart
@@ -1,4 +1,5 @@
import 'package:wandergo/core/utils/weekend.dart';
+import 'package:wandergo/features/place/domain/place_search.dart';
/// 화면 경로 — 앱 전체에서 이 상수만 쓴다
abstract final class AppRoutes {
@@ -22,4 +23,28 @@ abstract final class AppRoutes {
if (isUpcoming) 'upcoming': '1',
},
).toString();
+
+ /// 장소 검색 — ?q=&tab=&pet=1&focus=1
+ static const String search = '/search';
+ static const String placeDetailPattern = '/places/:id';
+
+ static String placeDetail(String id) => '/places/$id';
+
+ /// [focus] 면 들어가자마자 검색어를 입력할 수 있게 한다(홈 검색창)
+ static String placeSearch({
+ PlaceQuery query = const PlaceQuery(),
+ bool focus = false,
+ }) {
+ final params = {
+ 'q': ?query.queryOrNull,
+ if (query.tab != PlaceTab.all) 'tab': query.tab.name,
+ if (query.petOnly) 'pet': '1',
+ if (focus) 'focus': '1',
+ };
+ // 빈 맵을 넘기면 끝에 '?' 가 붙는다
+ return Uri(
+ path: search,
+ queryParameters: params.isEmpty ? null : params,
+ ).toString();
+ }
}
diff --git a/flutter/lib/core/theme/app_colors.dart b/flutter/lib/core/theme/app_colors.dart
index 42bf366..79b0fc6 100644
--- a/flutter/lib/core/theme/app_colors.dart
+++ b/flutter/lib/core/theme/app_colors.dart
@@ -46,4 +46,16 @@ abstract final class AppColors {
/// 반려동물 동반 글자(발바닥 아이콘과 함께)
static const Color pet = Color(0xFF0B6157);
+
+ /// 금지·불가 표시 아이콘(예: 반려동물 동반 불가 구역)
+ static const Color danger = Color(0xFFB42318);
+
+ /// 칩 테두리(선택 전)
+ static const Color chipBorder = Color(0xFFD9DCE6);
+
+ /// 선택된 칩 테두리 — 배경은 [brandSoft]
+ static const Color chipBorderSelected = Color(0xFFFFC2C4);
+
+ /// 카테고리 아이콘 아래 이름
+ static const Color label = Color(0xFF2E3340);
}
diff --git a/flutter/lib/core/utils/format.dart b/flutter/lib/core/utils/format.dart
index 355e5aa..98ac795 100644
--- a/flutter/lib/core/utils/format.dart
+++ b/flutter/lib/core/utils/format.dart
@@ -29,6 +29,12 @@ String toYmd(DateTime date) {
return '${date.year}-$mm-$dd';
}
+/// 출처 기준 시점 표시 — 상가정보 기준년월 'YYYYMM' 은 'YYYY-MM' 으로, 나머지는 그대로.
+String formatSourceDate(String asOf) {
+ final m = RegExp(r'^(\d{4})(\d{2})$').firstMatch(asOf);
+ return m == null ? asOf : '${m[1]}-${m[2]}';
+}
+
/// 행사 기간 표시 — 한국어는 "10.31–11.2", 하루짜리는 "11.8".
/// 다른 언어는 그 언어의 월·일 표기를 쓴다(영어 "Oct 31 – Nov 2").
String formatDateRange(String start, String end, {String localeName = 'ko'}) {
diff --git a/flutter/lib/core/utils/geo.dart b/flutter/lib/core/utils/geo.dart
new file mode 100644
index 0000000..e54a773
--- /dev/null
+++ b/flutter/lib/core/utils/geo.dart
@@ -0,0 +1,27 @@
+import 'dart:math' as math;
+
+/// 지구 평균 반지름(m)
+const double _earthRadiusM = 6371000;
+
+/// 걷는 속도 — 시속 4km(분당 약 67m)로 어림한다
+const double _walkMetersPerMinute = 4000 / 60;
+
+/// 이보다 멀면 도보 시간 대신 거리만 보인다(m)
+const double kWalkMaxM = 2000;
+
+/// 두 좌표 사이의 직선 거리(m) — 하버사인 공식
+double distanceMeters(double lat1, double lng1, double lat2, double lng2) {
+ double rad(double deg) => deg * math.pi / 180;
+ final dLat = rad(lat2 - lat1);
+ final dLng = rad(lng2 - lng1);
+ final a =
+ math.pow(math.sin(dLat / 2), 2) +
+ math.cos(rad(lat1)) *
+ math.cos(rad(lat2)) *
+ math.pow(math.sin(dLng / 2), 2);
+ return 2 * _earthRadiusM * math.asin(math.min(1, math.sqrt(a)));
+}
+
+/// 걸어서 걸리는 시간(분, 올림) — 직선 거리 기준의 어림값이라 최소 1분
+int walkMinutes(num meters) =>
+ math.max(1, (meters / _walkMetersPerMinute).ceil());
diff --git a/flutter/lib/features/home/presentation/pages/home_page.dart b/flutter/lib/features/home/presentation/pages/home_page.dart
index acb07ce..500e02c 100644
--- a/flutter/lib/features/home/presentation/pages/home_page.dart
+++ b/flutter/lib/features/home/presentation/pages/home_page.dart
@@ -10,24 +10,34 @@ import 'package:wandergo/core/theme/app_theme.dart';
import 'package:wandergo/core/utils/keep_all.dart';
import 'package:wandergo/features/event/presentation/providers/event_providers.dart';
import 'package:wandergo/features/event/presentation/widgets/event_card.dart';
+import 'package:wandergo/features/home/presentation/widgets/category_grid.dart';
+import 'package:wandergo/features/home/presentation/widgets/home_search_bar.dart';
+import 'package:wandergo/features/home/presentation/widgets/pet_places_section.dart';
import 'package:wandergo/features/location/domain/base_location.dart';
import 'package:wandergo/features/location/presentation/providers/base_location_provider.dart';
import 'package:wandergo/features/location/presentation/widgets/region_sheet.dart';
+import 'package:wandergo/features/place/presentation/providers/place_providers.dart';
import 'package:wandergo/shared/widgets/language_button.dart';
import 'package:wandergo/shared/widgets/load_error.dart';
+import 'package:wandergo/shared/widgets/section_header.dart';
/// 홈 가로 목록 높이 — 사진 110 + 여백·글자 세 줄
const double _kEventStripHeight = 186;
-/// 3 홈 — 지금은 기준 위치 · 제목 · 이번 주말 축제·행사 · 출처까지.
-/// 검색창·배너·카테고리·AI·추천 코스·반려동물 장소 칸은 해당 기능을 만들 때 붙인다.
+/// 3 홈 — 기준 위치 · 제목 · 검색창 · 카테고리 · 이번 주말 축제·행사 · 가까운 반려동물 동반 장소 · 출처.
+/// 배너·AI·추천 코스·지역 바로가기·로그인 버튼은 해당 기능을 만들 때 붙인다.
class HomePage extends ConsumerWidget {
const HomePage({super.key});
Future _refresh(WidgetRef ref) async {
// 현재 위치를 쓰는 경우 위치도 다시 받는다
- ref.invalidate(basePointProvider);
- await ref.read(homeEventsProvider.future);
+ ref
+ ..invalidate(basePointProvider)
+ ..invalidate(homePetPlacesProvider);
+ await Future.wait([
+ ref.read(homeEventsProvider.future),
+ ref.read(homePetPlacesProvider.future),
+ ]);
}
@override
@@ -57,11 +67,20 @@ class HomePage extends ConsumerWidget {
const _BaseLocationButton(),
const SizedBox(height: 4),
Text(keepAll(l10n.homeTitle), style: textTheme.titleLarge),
+ const SizedBox(height: 16),
+ const HomeSearchBar(),
+ const SizedBox(height: 28),
+ const CategoryGrid(),
],
),
),
- const SizedBox(height: 24),
+ const SizedBox(height: 32),
const _EventsSection(),
+ const SizedBox(height: 32),
+ const Padding(
+ padding: EdgeInsets.symmetric(horizontal: kPagePadding),
+ child: PetPlacesSection(),
+ ),
const SizedBox(height: 36),
const Padding(
padding: EdgeInsets.symmetric(horizontal: kPagePadding),
@@ -177,35 +196,16 @@ class _EventsSection extends ConsumerWidget {
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: kPagePadding),
- child: Row(
- children: [
- Expanded(child: Text(title, style: textTheme.titleMedium)),
- if (data != null && data.items.isNotEmpty)
- TextButton(
- onPressed: () => context.push(
+ child: SectionHeader(
+ title: title,
+ onSeeAll: data != null && data.items.isNotEmpty
+ ? () => context.push(
AppRoutes.eventList(
data.range,
isUpcoming: data.isUpcoming,
),
- ),
- style: TextButton.styleFrom(
- padding: EdgeInsets.zero,
- textStyle: textTheme.bodyMedium,
- ),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Text(l10n.seeAll),
- const SizedBox(width: 2),
- const AppIcon(
- AppIconData.chevronRight,
- size: 14,
- color: AppColors.textTertiary,
- ),
- ],
- ),
- ),
- ],
+ )
+ : null,
),
),
const SizedBox(height: 8),
diff --git a/flutter/lib/features/home/presentation/widgets/category_grid.dart b/flutter/lib/features/home/presentation/widgets/category_grid.dart
new file mode 100644
index 0000000..02ba698
--- /dev/null
+++ b/flutter/lib/features/home/presentation/widgets/category_grid.dart
@@ -0,0 +1,158 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+
+import 'package:wandergo/core/clock.dart';
+import 'package:wandergo/core/icons/app_icons.dart';
+import 'package:wandergo/core/l10n/l10n.dart';
+import 'package:wandergo/core/router/app_routes.dart';
+import 'package:wandergo/core/theme/app_colors.dart';
+import 'package:wandergo/core/utils/weekend.dart';
+import 'package:wandergo/features/event/presentation/providers/event_providers.dart';
+import 'package:wandergo/features/place/domain/place_search.dart';
+
+/// 한 줄에 놓는 카테고리 수
+const int _kColumns = 4;
+
+/// 홈 카테고리 바로가기 — 아이콘(분류색) · 이름 · 여는 검색 조건.
+///
+/// 디자인의 '데이트'는 운영자 큐레이션 장소가 쌓이면 붙인다(지금은 고를 근거가 없다).
+enum CategoryShortcut {
+ sights(AppIconData.categorySights, Color(0xFF3B7DDD)),
+ festival(AppIconData.categoryFestival, Color(0xFFEE9A1A)),
+ pet(AppIconData.categoryPet, Color(0xFF12A08A)),
+ food(AppIconData.categoryFood, Color(0xFFC0753A)),
+ stay(AppIconData.categoryStay, Color(0xFF7D5BE6)),
+ indoor(AppIconData.categoryIndoor, Color(0xFFD9548F)),
+ nature(AppIconData.categoryNature, Color(0xFF4FA43A));
+
+ const CategoryShortcut(this.icon, this.color);
+
+ final AppIconData icon;
+
+ /// 디자인 캔버스의 분류색 — 선과 16% 채움에 함께 쓴다
+ final Color color;
+
+ String label(AppLocalizations l10n) => switch (this) {
+ sights => l10n.categorySights,
+ festival => l10n.categoryFestival,
+ pet => l10n.categoryPet,
+ food => l10n.categoryFood,
+ stay => l10n.categoryStay,
+ indoor => l10n.categoryIndoor,
+ nature => l10n.categoryNature,
+ };
+
+ /// 누르면 갈 곳 — 축제는 행사 목록(다가오는 30일), 나머지는 검색 조건
+ String location(DateTime now) => switch (this) {
+ festival => AppRoutes.eventList(
+ upcomingRange(now, days: kUpcomingDays),
+ isUpcoming: true,
+ ),
+ pet => AppRoutes.placeSearch(query: const PlaceQuery(petOnly: true)),
+ sights => AppRoutes.placeSearch(
+ query: const PlaceQuery(tab: PlaceTab.sights),
+ ),
+ food => AppRoutes.placeSearch(query: const PlaceQuery(tab: PlaceTab.food)),
+ stay => AppRoutes.placeSearch(query: const PlaceQuery(tab: PlaceTab.stay)),
+ indoor => AppRoutes.placeSearch(
+ query: const PlaceQuery(tab: PlaceTab.indoor),
+ ),
+ nature => AppRoutes.placeSearch(
+ query: const PlaceQuery(tab: PlaceTab.nature),
+ ),
+ };
+}
+
+/// 카테고리 — 4열, 연회색 둥근 칸 안의 분류색 아이콘과 이름
+class CategoryGrid extends ConsumerWidget {
+ const CategoryGrid({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final l10n = context.l10n;
+ const items = CategoryShortcut.values;
+ final rows = [
+ for (var i = 0; i < items.length; i += _kColumns)
+ items.sublist(i, (i + _kColumns).clamp(0, items.length)),
+ ];
+ return Semantics(
+ container: true,
+ label: l10n.categoryNavLabel,
+ child: Column(
+ children: [
+ for (var r = 0; r < rows.length; r++) ...[
+ if (r > 0) const SizedBox(height: 20),
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ for (var c = 0; c < _kColumns; c++)
+ Expanded(
+ child: c < rows[r].length
+ ? _CategoryCell(
+ shortcut: rows[r][c],
+ onTap: () => context.push(
+ rows[r][c].location(ref.read(clockProvider)()),
+ ),
+ )
+ : const SizedBox.shrink(),
+ ),
+ ],
+ ),
+ ],
+ ],
+ ),
+ );
+ }
+}
+
+class _CategoryCell extends StatelessWidget {
+ const _CategoryCell({required this.shortcut, required this.onTap});
+
+ final CategoryShortcut shortcut;
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ final label = shortcut.label(context.l10n);
+ return Semantics(
+ button: true,
+ label: label,
+ excludeSemantics: true,
+ child: InkWell(
+ onTap: onTap,
+ borderRadius: BorderRadius.circular(16),
+ child: Column(
+ children: [
+ Container(
+ width: 56,
+ height: 56,
+ decoration: BoxDecoration(
+ color: AppColors.placeholder,
+ borderRadius: BorderRadius.circular(16),
+ ),
+ alignment: Alignment.center,
+ child: AppIcon(
+ shortcut.icon,
+ size: 30,
+ color: shortcut.color,
+ strokeWidth: 1.6,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ label,
+ textAlign: TextAlign.center,
+ style: const TextStyle(
+ fontSize: 13,
+ height: 18 / 13,
+ fontWeight: FontWeight.w500,
+ color: AppColors.label,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/flutter/lib/features/home/presentation/widgets/home_search_bar.dart b/flutter/lib/features/home/presentation/widgets/home_search_bar.dart
new file mode 100644
index 0000000..6dbfaea
--- /dev/null
+++ b/flutter/lib/features/home/presentation/widgets/home_search_bar.dart
@@ -0,0 +1,55 @@
+import 'package:flutter/material.dart';
+import 'package:go_router/go_router.dart';
+
+import 'package:wandergo/core/icons/app_icons.dart';
+import 'package:wandergo/core/l10n/l10n.dart';
+import 'package:wandergo/core/router/app_routes.dart';
+import 'package:wandergo/core/theme/app_colors.dart';
+import 'package:wandergo/core/theme/app_theme.dart';
+
+/// 홈 검색창 — 누르면 검색 화면을 열고 바로 입력하게 한다(홈에서는 입력하지 않는다)
+class HomeSearchBar extends StatelessWidget {
+ const HomeSearchBar({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ final hint = context.l10n.searchHint;
+ return Semantics(
+ button: true,
+ label: hint,
+ excludeSemantics: true,
+ child: Material(
+ color: AppColors.inputFill,
+ borderRadius: BorderRadius.circular(kRadius),
+ child: InkWell(
+ borderRadius: BorderRadius.circular(kRadius),
+ onTap: () => context.push(AppRoutes.placeSearch(focus: true)),
+ child: SizedBox(
+ height: 48,
+ child: Row(
+ children: [
+ const SizedBox(width: 14),
+ const AppIcon(
+ AppIconData.search,
+ size: 20,
+ color: AppColors.textTertiary,
+ ),
+ const SizedBox(width: 10),
+ Expanded(
+ child: Text(
+ hint,
+ style: const TextStyle(
+ fontSize: 15,
+ height: 21 / 15,
+ color: AppColors.textHint,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/flutter/lib/features/home/presentation/widgets/pet_places_section.dart b/flutter/lib/features/home/presentation/widgets/pet_places_section.dart
new file mode 100644
index 0000000..bc847f5
--- /dev/null
+++ b/flutter/lib/features/home/presentation/widgets/pet_places_section.dart
@@ -0,0 +1,66 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+
+import 'package:wandergo/core/l10n/l10n.dart';
+import 'package:wandergo/core/router/app_routes.dart';
+import 'package:wandergo/core/utils/keep_all.dart';
+import 'package:wandergo/features/place/domain/place_search.dart';
+import 'package:wandergo/features/place/presentation/providers/place_providers.dart';
+import 'package:wandergo/features/place/presentation/widgets/place_tiles.dart';
+import 'package:wandergo/shared/widgets/load_error.dart';
+import 'package:wandergo/shared/widgets/section_header.dart';
+
+/// 홈 '가까운 반려동물 동반 장소' — 기준 위치에서 가까운 3곳, 전체 보기는 반려견 동반 검색
+class PetPlacesSection extends ConsumerWidget {
+ const PetPlacesSection({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final l10n = context.l10n;
+ final places = ref.watch(homePetPlacesProvider);
+ final hasItems = places.value?.isNotEmpty ?? false;
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ SectionHeader(
+ title: l10n.homePetPlaces,
+ onSeeAll: hasItems
+ ? () => context.push(
+ AppRoutes.placeSearch(query: const PlaceQuery(petOnly: true)),
+ )
+ : null,
+ ),
+ switch (places) {
+ AsyncData(:final value) when value.isEmpty => Padding(
+ padding: const EdgeInsets.symmetric(vertical: 8),
+ child: Text(
+ keepAll(l10n.homePetPlacesEmpty),
+ style: Theme.of(context).textTheme.bodyMedium,
+ ),
+ ),
+ AsyncData(:final value) => Column(
+ children: [
+ for (var i = 0; i < value.length; i++) ...[
+ if (i > 0) const Divider(),
+ PetPlaceRow(
+ item: value[i],
+ onTap: () => context.push(AppRoutes.placeDetail(value[i].id)),
+ ),
+ ],
+ ],
+ ),
+ AsyncError(:final error) => LoadError(
+ error: error,
+ onRetry: () => ref.invalidate(homePetPlacesProvider),
+ ),
+ _ => const Padding(
+ padding: EdgeInsets.symmetric(vertical: 24),
+ child: Center(child: CircularProgressIndicator()),
+ ),
+ },
+ ],
+ );
+ }
+}
diff --git a/flutter/lib/features/place/data/models/place_models.dart b/flutter/lib/features/place/data/models/place_models.dart
new file mode 100644
index 0000000..f232f8a
--- /dev/null
+++ b/flutter/lib/features/place/data/models/place_models.dart
@@ -0,0 +1,336 @@
+/// 장소 — 백엔드 PlaceListItemDto / PlaceDetailDto 와 1:1 대응한다.
+library;
+
+import 'package:wandergo/shared/models/source_models.dart';
+
+/// 코드값을 enum 으로 읽는다. 모르는 값이면 null — 백엔드가 값을 늘려도 앱이 죽지 않게 한다
+T? _parseCode(
+ List values,
+ Object? value,
+ String Function(T) code,
+) {
+ for (final v in values) {
+ if (code(v) == value) return v;
+ }
+ return null;
+}
+
+/// 빈 문자열은 값 없음으로 본다(공공데이터는 빈 칸을 ""로 주기도 한다)
+String? _text(Object? value) {
+ if (value is! String) return null;
+ final trimmed = value.trim();
+ return trimmed.isEmpty ? null : trimmed;
+}
+
+/// 자체 분류 (백엔드 PLACE_CATEGORIES)
+enum PlaceCategory {
+ nature,
+ attraction,
+ culture,
+ leisure,
+ food,
+ cafe,
+ bar,
+ shopping,
+ lodging,
+ other;
+
+ static PlaceCategory? parse(Object? value) =>
+ _parseCode(values, value, (c) => c.name);
+}
+
+/// 데이터 출처 — 관광공사 · 소상공인 상가정보
+enum PlaceSource {
+ tour,
+ store;
+
+ static PlaceSource? parse(Object? value) =>
+ _parseCode(values, value, (s) => s.name);
+}
+
+/// 값의 출처 — 운영자 확인 · 공공데이터 · 미확인
+enum ValueOrigin {
+ curation,
+ public,
+ unknown;
+
+ static ValueOrigin parse(Object? value) =>
+ _parseCode(values, value, (o) => o.name) ?? unknown;
+}
+
+/// 반려동물 동반 — 정보가 없으면 '불가'가 아니라 [unknown] 이다
+enum PetPolicy {
+ allowed('allowed'),
+ partial('partial'),
+ notAllowed('not_allowed'),
+ unknown('unknown');
+
+ const PetPolicy(this.code);
+
+ final String code;
+
+ /// 동반할 수 있는 곳(전 구역·일부 구역)
+ bool get isPetFriendly => this == allowed || this == partial;
+
+ static PetPolicy parse(Object? value) =>
+ _parseCode(values, value, (p) => p.code) ?? unknown;
+}
+
+enum PetSize {
+ small,
+ medium,
+ large;
+
+ static PetSize? parse(Object? value) =>
+ _parseCode(values, value, (s) => s.name);
+}
+
+/// 실내/실외 — 운영자가 확인한 장소만 값이 있다
+enum PlaceSetting {
+ indoor,
+ outdoor,
+ both;
+
+ static PlaceSetting? parse(Object? value) =>
+ _parseCode(values, value, (s) => s.name);
+}
+
+/// 영업시간 해석 결과 — varies(점포별 상이)·unknown 이면 시간을 단정하지 않는다
+enum HoursStatus {
+ parsed('parsed'),
+ alwaysOpen('always_open'),
+ varies('varies'),
+ unknown('unknown');
+
+ const HoursStatus(this.code);
+
+ final String code;
+
+ static HoursStatus parse(Object? value) =>
+ _parseCode(values, value, (s) => s.code) ?? unknown;
+}
+
+/// 검색·목록의 장소 한 건
+class PlaceItem {
+ const PlaceItem({
+ required this.id,
+ required this.name,
+ required this.lat,
+ required this.lng,
+ this.branchName,
+ this.category,
+ this.source,
+ this.address,
+ this.distanceM,
+ this.imageThumbUrl,
+ this.imageLicense,
+ this.hoursOrigin = ValueOrigin.unknown,
+ this.openNow,
+ this.petPolicy = PetPolicy.unknown,
+ this.petOrigin = ValueOrigin.unknown,
+ this.setting,
+ this.verifiedAt,
+ });
+
+ final String id;
+ final String name;
+ final String? branchName;
+ final PlaceCategory? category;
+ final PlaceSource? source;
+ final String? address;
+ final double lat;
+ final double lng;
+
+ /// 기준 좌표에서의 거리(m). 좌표 없이 조회하면 null
+ final double? distanceM;
+ final String? imageThumbUrl;
+ final ImageLicense? imageLicense;
+ final ValueOrigin hoursOrigin;
+
+ /// 지금(한국 시간) 영업 중인지. 모르면 null
+ final bool? openNow;
+ final PetPolicy petPolicy;
+ final ValueOrigin petOrigin;
+ final PlaceSetting? setting;
+
+ /// 운영자 확인일 'YYYY-MM-DD'. 확인하지 않았으면 null
+ final String? verifiedAt;
+
+ /// 화면에 보이는 이름 — 지점명이 있으면 붙인다("마실커피 동성로점")
+ String get displayName => branchName == null ? name : '$name $branchName';
+
+ /// 공공데이터·운영자 어느 쪽에서도 영업시간을 모르는 곳 — "영업시간 미확인 · 방문 전 확인"
+ bool get hoursUnknown => hoursOrigin == ValueOrigin.unknown;
+
+ factory PlaceItem.fromJson(Map json) {
+ return PlaceItem(
+ id: json['id'] as String,
+ name: json['name'] as String,
+ branchName: _text(json['branchName']),
+ category: PlaceCategory.parse(json['category']),
+ source: PlaceSource.parse(json['source']),
+ address: _text(json['address']),
+ lat: (json['lat'] as num).toDouble(),
+ lng: (json['lng'] as num).toDouble(),
+ distanceM: (json['distanceM'] as num?)?.toDouble(),
+ imageThumbUrl: _text(json['imageThumbUrl']),
+ imageLicense: ImageLicense.parse(json['imageLicense']),
+ hoursOrigin: ValueOrigin.parse(json['hoursOrigin']),
+ openNow: json['openNow'] as bool?,
+ petPolicy: PetPolicy.parse(json['petPolicy']),
+ petOrigin: ValueOrigin.parse(json['petOrigin']),
+ setting: PlaceSetting.parse(json['setting']),
+ verifiedAt: _text(json['verifiedAt']),
+ );
+ }
+}
+
+/// 영업시간
+class PlaceHours {
+ const PlaceHours({
+ this.text,
+ this.restText,
+ this.status = HoursStatus.unknown,
+ });
+
+ /// 원문(운영자 확인 값이 있으면 그것, 없으면 공공데이터)
+ final String? text;
+
+ /// 쉬는 날 원문
+ final String? restText;
+ final HoursStatus status;
+
+ factory PlaceHours.fromJson(Map? json) {
+ if (json == null) return const PlaceHours();
+ return PlaceHours(
+ text: _text(json['text']),
+ restText: _text(json['restText']),
+ status: HoursStatus.parse(json['status']),
+ );
+ }
+}
+
+/// 반려동물 동반 조건
+class PlacePet {
+ const PlacePet({this.sizes, this.conditions});
+
+ /// 동반 가능한 크기(운영자 확인). 모르면 null
+ final List? sizes;
+
+ /// 조건 원문 — "목줄 착용 · 배변봉투 지참" 처럼 ' · ' 로 이어진다
+ final String? conditions;
+
+ factory PlacePet.fromJson(Map? json) {
+ if (json == null) return const PlacePet();
+ final sizes = json['sizes'] as List?;
+ return PlacePet(
+ sizes: sizes?.map(PetSize.parse).whereType().toList(),
+ conditions: _text(json['conditions']),
+ );
+ }
+}
+
+/// 운영자 추천 정보 — 추천(O)하고 확인한 장소만
+class PlaceCuration {
+ const PlaceCuration({this.reason, this.officialUrl});
+
+ /// 추천 이유(운영자가 직접 쓴 글)
+ final String? reason;
+
+ /// 업체 공식 채널
+ final String? officialUrl;
+
+ factory PlaceCuration.fromJson(Map json) {
+ return PlaceCuration(
+ reason: _text(json['reason']),
+ officialUrl: _text(json['officialUrl']),
+ );
+ }
+}
+
+/// 장소 상세 — 목록 필드에 상세 정보를 더한다
+class PlaceDetail {
+ const PlaceDetail({
+ required this.item,
+ required this.attribution,
+ this.addressJibun,
+ this.phone,
+ this.homepage,
+ this.overview,
+ this.imageUrl,
+ this.hours = const PlaceHours(),
+ this.pet = const PlacePet(),
+ this.extra = const {},
+ this.curation,
+ });
+
+ final PlaceItem item;
+ final Attribution attribution;
+ final String? addressJibun;
+ final String? phone;
+ final String? homepage;
+
+ /// 소개(공공데이터)
+ final String? overview;
+
+ /// 큰 사진
+ final String? imageUrl;
+ final PlaceHours hours;
+ final PlacePet pet;
+
+ /// 이용 안내 원문 — 키는 백엔드 tour-mapper 의 INTRO_FIELDS(fee·parking·firstMenu …)
+ final Map extra;
+ final PlaceCuration? curation;
+
+ factory PlaceDetail.fromJson(Map json) {
+ final extra = json['extra'] as Map?;
+ final curation = json['curation'] as Map?;
+ return PlaceDetail(
+ item: PlaceItem.fromJson(json),
+ attribution: Attribution.fromJson(
+ json['attribution'] as Map,
+ ),
+ addressJibun: _text(json['addressJibun']),
+ phone: _text(json['phone']),
+ homepage: _text(json['homepage']),
+ overview: _text(json['overview']),
+ imageUrl: _text(json['imageUrl']),
+ hours: PlaceHours.fromJson(json['hours'] as Map?),
+ pet: PlacePet.fromJson(json['pet'] as Map?),
+ extra: {
+ for (final entry in (extra ?? const {}).entries)
+ entry.key: ?_text(entry.value),
+ },
+ curation: curation == null ? null : PlaceCuration.fromJson(curation),
+ );
+ }
+}
+
+/// 커서 페이지 — 응답 data 의 {items, pageInfo}
+class PlacePage {
+ const PlacePage({
+ required this.items,
+ required this.hasNext,
+ this.nextCursor,
+ this.total,
+ });
+
+ final List items;
+ final bool hasNext;
+ final String? nextCursor;
+
+ /// 조건에 맞는 전체 수. 계산 비용이 크면 null
+ final int? total;
+
+ factory PlacePage.fromJson(Map json) {
+ final pageInfo = json['pageInfo'] as Map;
+ return PlacePage(
+ items: (json['items'] as List)
+ .map((e) => PlaceItem.fromJson(e as Map))
+ .toList(),
+ hasNext: pageInfo['hasNext'] as bool? ?? false,
+ nextCursor: pageInfo['nextCursor'] as String?,
+ total: pageInfo['total'] as int?,
+ );
+ }
+}
diff --git a/flutter/lib/features/place/data/repositories/place_repository.dart b/flutter/lib/features/place/data/repositories/place_repository.dart
new file mode 100644
index 0000000..aaf3070
--- /dev/null
+++ b/flutter/lib/features/place/data/repositories/place_repository.dart
@@ -0,0 +1,52 @@
+import 'package:dio/dio.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import 'package:wandergo/core/api/dio_provider.dart';
+import 'package:wandergo/features/location/domain/base_location.dart';
+import 'package:wandergo/features/place/data/models/place_models.dart';
+
+/// 장소 API — `GET /places`, `GET /places/{id}` (로그인 없이 쓴다).
+///
+/// 응답 언래핑과 에러 코드 매핑은 dio 인터셉터가 맡는다. 여기서는 파싱만 한다.
+class PlaceRepository {
+ const PlaceRepository(this._dio);
+
+ final Dio _dio;
+
+ /// 장소 검색. [near] 를 주면 [radiusM] 안을 거리순으로, 없으면 이름순으로 준다.
+ /// [categories] 가 비면 전체 분류, [petOnly] 면 반려동물 동반(전 구역·일부 구역) 장소만.
+ Future search({
+ String? query,
+ List categories = const [],
+ bool petOnly = false,
+ GeoPoint? near,
+ int? radiusM,
+ int limit = 20,
+ String? cursor,
+ }) async {
+ final response = await _dio.get