generated from ttipo/project-base
fix(flutter): 화면 아래가 내비게이션 바에 가려지고 상세 위가 상태 표시줄과 겹치는 것 수정
실기기(Android 16)에서 확인했다. Android 15 부터 앱이 화면 끝까지 그려지는데, 목록에 여백을 직접 준 화면은 시스템 여백이 빠져 마지막 출처 줄이 내비게이션 바에 가렸다 (장소 상세 · 행사 상세 · 행사 목록). 장소 상세는 사진 위에서 상태 표시줄 글자가 검은색이라 잘 안 보였고, 내리면 본문 제목이 상태 표시줄과 겹쳤다. 사진 위에서는 흰 글자와 위쪽 그늘을 두고, 사진을 지나면 흰 제목 줄 (뒤로 · 장소 이름)을 깐다. 상단 바가 없는 화면의 기본값(검은 글자)은 앱 뿌리에서 정한다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user