diff --git a/flutter/lib/app.dart b/flutter/lib/app.dart index 6e26dfd..2bef2bb 100644 --- a/flutter/lib/app.dart +++ b/flutter/lib/app.dart @@ -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( + value: kStatusBarDark, + child: child ?? const SizedBox.shrink(), + ), ); } } diff --git a/flutter/lib/core/theme/system_bars.dart b/flutter/lib/core/theme/system_bars.dart new file mode 100644 index 0000000..5a8f6d8 --- /dev/null +++ b/flutter/lib/core/theme/system_bars.dart @@ -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, +); diff --git a/flutter/lib/features/event/presentation/pages/event_detail_page.dart b/flutter/lib/features/event/presentation/pages/event_detail_page.dart index cefdd43..0266936 100644 --- a/flutter/lib/features/event/presentation/pages/event_detail_page.dart +++ b/flutter/lib/features/event/presentation/pages/event_detail_page.dart @@ -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( diff --git a/flutter/lib/features/event/presentation/pages/event_list_page.dart b/flutter/lib/features/event/presentation/pages/event_list_page.dart index 88b999e..c058c2e 100644 --- a/flutter/lib/features/event/presentation/pages/event_list_page.dart +++ b/flutter/lib/features/event/presentation/pages/event_list_page.dart @@ -101,7 +101,13 @@ class _EventListPageState extends ConsumerState { ); } 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(), diff --git a/flutter/lib/features/place/presentation/pages/place_detail_page.dart b/flutter/lib/features/place/presentation/pages/place_detail_page.dart index fd0bd35..11010bf 100644 --- a/flutter/lib/features/place/presentation/pages/place_detail_page.dart +++ b/flutter/lib/features/place/presentation/pages/place_detail_page.dart @@ -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( + 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,