generated from ttipo/project-base
feat(flutter): 장소 검색·상세 화면과 홈 검색창·카테고리·반려동물 장소 칸
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 21m21s
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 12m16s
CI / Frontend (lint · type-check · test) (pull_request) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Has been cancelled
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 21m21s
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 12m16s
CI / Frontend (lint · type-check · test) (pull_request) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Has been cancelled
디자인 캔버스 4(검색)·6·7(장소 상세) 중 지금 장소 API 로 되는 부분을 붙였다. - 검색은 반경 20km(백엔드 최대) 거리순. 대구 도심에서 대구 전역이 들어온다. - 영업시간을 모르는 곳은 "영업시간 미확인 · 방문 전 확인"으로 보인다. - 한눈에 보기는 공공데이터 원문으로 확실한 것만 올린다(주차 '인근 공영주차장' 같은 애매한 값은 빼기). - 함께 가기 좋은 곳에는 확인 안 된 상가정보 매장을 넣지 않는다(2026-11-02 합의). - 1330 관광통역안내는 영어 화면에만(2026-10-29 합의). 검색의 '축제' 탭과 실내·무료·주차·이번 주말 조건은 데이터가 없어 넣지 않았다 (행사 API 검색어 조건 · 운영자 확인 장소 · 날씨). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
Reference in New Issue
Block a user