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

Merged
ttipo merged 4 commits from feat/flutter-places into main 2026-11-04 20:25:40 +09:00
5 changed files with 219 additions and 48 deletions
Showing only changes of commit c21fbbd973 - Show all commits
+8
View File
@@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:wandergo/core/l10n/l10n.dart';
import 'package:wandergo/core/l10n/locale_provider.dart';
import 'package:wandergo/core/router/app_router.dart';
import 'package:wandergo/core/theme/app_theme.dart';
import 'package:wandergo/core/theme/system_bars.dart';
/// 앱 뿌리 — 테마 · 언어 · 화면 이동
class WanderGoApp extends ConsumerWidget {
@@ -21,6 +23,12 @@ class WanderGoApp extends ConsumerWidget {
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
routerConfig: ref.watch(routerProvider),
// 상단 바가 없는 화면의 상태 표시줄 기본값 — 흰 바탕이라 검은 글자.
// 사진 위처럼 다르게 보여야 하는 화면은 안쪽에서 AnnotatedRegion 으로 덮어쓴다
builder: (context, child) => AnnotatedRegion<SystemUiOverlayStyle>(
value: kStatusBarDark,
child: child ?? const SizedBox.shrink(),
),
);
}
}
+15
View File
@@ -0,0 +1,15 @@
import 'package:flutter/services.dart';
/// 상태 표시줄 — 밝은 바탕 위 검은 글자 (내비게이션 바 설정은 건드리지 않는다)
const SystemUiOverlayStyle kStatusBarDark = SystemUiOverlayStyle(
statusBarColor: Color(0x00000000),
statusBarIconBrightness: Brightness.dark,
statusBarBrightness: Brightness.light,
);
/// 상태 표시줄 — 사진 위 흰 글자
const SystemUiOverlayStyle kStatusBarLight = SystemUiOverlayStyle(
statusBarColor: Color(0x00000000),
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark,
);
@@ -71,7 +71,10 @@ class _DetailBody extends StatelessWidget {
);
return ListView(
padding: const EdgeInsets.only(bottom: 32),
// 시스템 내비게이션 바 아래로 그려지므로(Android 15+) 그 높이만큼 더 띄운다
padding: EdgeInsets.only(
bottom: 32 + MediaQuery.paddingOf(context).bottom,
),
children: [
if (imageUrl != null)
EventImage(
@@ -101,7 +101,13 @@ class _EventListPageState extends ConsumerState<EventListPage> {
);
}
return ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: kPagePadding),
// 시스템 내비게이션 바 아래로 그려지므로(Android 15+) 그 높이만큼 더 띄운다
padding: EdgeInsets.fromLTRB(
kPagePadding,
0,
kPagePadding,
MediaQuery.paddingOf(context).bottom,
),
itemCount: _items.length + 1,
separatorBuilder: (_, index) =>
index < _items.length - 1 ? const Divider() : const SizedBox.shrink(),
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
@@ -6,6 +7,7 @@ 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/theme/system_bars.dart';
import 'package:wandergo/core/utils/format.dart';
import 'package:wandergo/core/utils/geo.dart';
import 'package:wandergo/core/utils/keep_all.dart';
@@ -69,61 +71,182 @@ class PlaceDetailPage extends ConsumerWidget {
}
}
class _DetailBody extends StatelessWidget {
/// 대표 사진 주소 — 큰 사진이 없으면 작은 사진
String? _photoOf(PlaceDetail detail) =>
detail.imageUrl ?? detail.item.imageThumbUrl;
double _heroHeightOf(PlaceDetail detail) =>
_photoOf(detail) == null ? _kHeroHeightNoPhoto : _kHeroHeight;
class _DetailBody extends StatefulWidget {
const _DetailBody({required this.detail});
final PlaceDetail detail;
@override
Widget build(BuildContext context) {
final item = detail.item;
State<_DetailBody> createState() => _DetailBodyState();
}
class _DetailBodyState extends State<_DetailBody> {
final ScrollController _controller = ScrollController();
/// 사진을 지나 내려왔는지 — 위에 흰 제목 줄을 깐다
bool _pastHero = false;
@override
void initState() {
super.initState();
_controller.addListener(_onScroll);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onScroll() {
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),
final threshold =
_heroHeightOf(widget.detail) -
_kSheetOverlap -
topInset -
kToolbarHeight;
final pastHero = _controller.offset > threshold;
if (pastHero != _pastHero) setState(() => _pastHero = pastHero);
}
@override
Widget build(BuildContext context) {
final detail = widget.detail;
final item = detail.item;
final insets = MediaQuery.paddingOf(context);
// 사진 위에서는 흰 글자, 흰 바탕(제목 줄·자리 표시) 위에서는 검은 글자
final onPhoto = !_pastHero && _photoOf(detail) != null;
return AnnotatedRegion<SystemUiOverlayStyle>(
value: onPhoto ? kStatusBarLight : kStatusBarDark,
child: Stack(
children: [
ListView(
controller: _controller,
// 시스템 내비게이션 바 아래로 그려지므로(Android 15+) 그 높이만큼 띄운다
padding: EdgeInsets.only(bottom: insets.bottom),
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),
),
_SourceFooter(attribution: detail.attribution),
],
),
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: 0,
left: 0,
right: 0,
child: _TopBar(
title: item.displayName,
topInset: insets.top,
pastHero: _pastHero,
),
],
),
],
),
);
}
}
/// 위쪽 줄 — 사진 위에서는 흰 원 뒤로 버튼만, 사진을 지나면 흰 바탕 + 뒤로 + 이름
class _TopBar extends StatelessWidget {
const _TopBar({
required this.title,
required this.topInset,
required this.pastHero,
});
final String title;
final double topInset;
final bool pastHero;
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 150),
height: topInset + kToolbarHeight,
padding: EdgeInsets.only(top: topInset, left: 4, right: kPagePadding),
decoration: BoxDecoration(
color: pastHero ? AppColors.background : Colors.transparent,
border: Border(
bottom: BorderSide(
color: pastHero ? AppColors.divider : Colors.transparent,
),
),
// 내려도 늘 보이는 뒤로 버튼
Positioned(top: topInset + 12, left: 12, child: const _BackButton()),
],
),
child: Row(
children: [
if (pastHero)
IconButton(
tooltip: context.l10n.back,
onPressed: () => context.pop(),
icon: const AppIcon(
AppIconData.chevronLeft,
color: AppColors.text,
),
)
else
const Padding(
padding: EdgeInsets.only(left: 8),
child: _BackButton(),
),
const SizedBox(width: 4),
Expanded(
child: AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: pastHero ? 1 : 0,
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).appBarTheme.titleTextStyle,
),
),
),
],
),
);
}
}
@@ -158,9 +281,9 @@ class _Hero extends StatelessWidget {
Widget build(BuildContext context) {
final l10n = context.l10n;
final item = detail.item;
final url = detail.imageUrl ?? item.imageThumbUrl;
final url = _photoOf(detail);
final license = item.imageLicense;
final height = url == null ? _kHeroHeightNoPhoto : _kHeroHeight;
final height = _heroHeightOf(detail);
return SizedBox(
height: height,
child: Stack(
@@ -174,6 +297,22 @@ class _Hero extends StatelessWidget {
radius: 0,
iconSize: 40,
),
if (url != null)
Positioned(
left: 0,
right: 0,
top: 0,
height: MediaQuery.paddingOf(context).top + kToolbarHeight + 24,
child: const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x59161A23), Color(0x00161A23)],
),
),
),
),
if (url != null && license != null) ...[
const Positioned(
left: 0,