feat(flutter): 장소 검색·상세 화면과 홈 검색창·카테고리·반려동물 장소 칸
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 21m21s
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 12m16s
CI / Frontend (lint · type-check · test) (pull_request) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Has been cancelled

디자인 캔버스 4(검색)·6·7(장소 상세) 중 지금 장소 API 로 되는 부분을 붙였다.

- 검색은 반경 20km(백엔드 최대) 거리순. 대구 도심에서 대구 전역이 들어온다.
- 영업시간을 모르는 곳은 "영업시간 미확인 · 방문 전 확인"으로 보인다.
- 한눈에 보기는 공공데이터 원문으로 확실한 것만 올린다(주차 '인근 공영주차장' 같은 애매한 값은 빼기).
- 함께 가기 좋은 곳에는 확인 안 된 상가정보 매장을 넣지 않는다(2026-11-02 합의).
- 1330 관광통역안내는 영어 화면에만(2026-10-29 합의).

검색의 '축제' 탭과 실내·무료·주차·이번 주말 조건은 데이터가 없어 넣지 않았다
(행사 API 검색어 조건 · 운영자 확인 장소 · 날씨).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 16:44:56 +09:00
parent 8f711c1706
commit 4bd142a9b7
29 changed files with 3816 additions and 45 deletions
@@ -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,
),
),
],
);