2 Commits

Author SHA1 Message Date
ttipo 377181eade fix(flutter): 홈 축제·행사 반경을 120km 에서 50km 로 줄임
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 11m11s
CI / Frontend (lint · type-check · test) (pull_request) Successful in 10m27s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 10m44s
CI (Flutter) / Flutter (analyze · test) (pull_request) Successful in 20m26s
CI / Backend (lint · test · e2e · build) (push) Successful in 11m56s
CI (Flutter) / Flutter (analyze · test) (push) Successful in 20m16s
CI / Frontend (lint · type-check · test) (push) Successful in 10m49s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 10m29s
120km 는 관광공사 행사뿐이던 2026-11-03 에 대구 행사가 너무 적어 경북까지 넓힌 값이다.
KOPIS·문화포털이 들어온 뒤로는 대구 안에서도 충분히 차고(도심 기준 이번 주말 50km 32건 ·
120km 42건), 120km 면 영덕·영주처럼 차로 2시간 거리까지 나왔다.
50km 는 군위를 포함한 대구 전역과 경산·칠곡까지다(2026-11-04 결정).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:27:58 +09:00
ttipo c21fbbd973 fix(flutter): 화면 아래가 내비게이션 바에 가려지고 상세 위가 상태 표시줄과 겹치는 것 수정
실기기(Android 16)에서 확인했다. Android 15 부터 앱이 화면 끝까지 그려지는데,
목록에 여백을 직접 준 화면은 시스템 여백이 빠져 마지막 출처 줄이 내비게이션 바에 가렸다
(장소 상세 · 행사 상세 · 행사 목록).

장소 상세는 사진 위에서 상태 표시줄 글자가 검은색이라 잘 안 보였고, 내리면 본문 제목이
상태 표시줄과 겹쳤다. 사진 위에서는 흰 글자와 위쪽 그늘을 두고, 사진을 지나면 흰 제목 줄
(뒤로 · 장소 이름)을 깐다. 상단 바가 없는 화면의 기본값(검은 글자)은 앱 뿌리에서 정한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:27:58 +09:00
7 changed files with 227 additions and 52 deletions
+3 -1
View File
@@ -380,7 +380,9 @@ node dist/commands/place-sync.js tour-kor
| `GET /api/events/{id}` | 소개·공연 시간·요금·장소·홈페이지·전화·출처. 숨김이면 RES_001 |
- **화면 규칙**(2026-11-03 합의):
- 홈 '이번 주말 축제·행사' = `from`=토 · `to`=일 · 동네 중심 좌표 · `radius`=120000 → 대구 행사가 먼저, 다음 경북(거리 표시 "대구에서 85km").
- 홈 '이번 주말 축제·행사' = `from`=토 · `to`=일 · 동네 중심 좌표 · `radius`=50000 → 대구 전역(군위 포함)과 경산·칠곡 등 바로 옆 도시까지 거리순.
처음(2026-11-03)에는 관광공사 행사뿐이라 120km(경북)였다. KOPIS·문화포털이 들어온 뒤 대구 도심 기준 이번 주말 50km 32건 · 120km 42건이 되었고,
120km 면 영덕·영주(차로 2시간)까지 나와 2026-11-04 50km 로 줄였다.
- **비면** 같은 반경으로 `from`=오늘 · `to`=오늘+30일을 불러 제목을 '다가오는 축제·행사'로 바꿔 보여 준다(빈 칸을 두지 않는다).
- 장소 상세 '근처 축제·행사' = 장소 좌표 + 기본 반경(5km), 없으면 칸을 숨긴다.
- 계약 테스트: `backend/test/festival.e2e-spec.ts` — 2030년 날짜·동해 좌표 픽스처라 실행 날짜와 무관하다.
+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(),
@@ -6,8 +6,10 @@ import 'package:wandergo/features/event/data/models/event_models.dart';
import 'package:wandergo/features/event/data/repositories/event_repository.dart';
import 'package:wandergo/features/location/presentation/providers/base_location_provider.dart';
/// 홈 축제 칸 반경 — 대구 근교(경북)까지 거리순(대구 먼저). 2026-11-03 합의
const int kHomeEventRadiusM = 120000;
/// 홈 축제 칸 반경 — 대구 전역(군위 포함)과 경산·칠곡 같은 바로 옆 도시까지, 거리순.
/// 2026-11-03 에는 관광공사 행사뿐이라 120km(경북)였으나, KOPIS·문화포털이 들어와
/// 대구 안에서도 충분히 차서 2026-11-04 50km 로 줄였다(120km 면 영덕·영주 등 차로 2시간 거리까지 나왔다).
const int kHomeEventRadiusM = 50000;
/// 주말이 비었을 때 대신 보이는 '다가오는' 기간(일)
const int kUpcomingDays = 30;
@@ -32,7 +34,7 @@ class HomeEvents {
final bool isUpcoming;
}
/// 홈 '이번 주말 축제·행사' — 기준 위치 반경 120km 거리순, 비면 같은 반경 30일
/// 홈 '이번 주말 축제·행사' — 기준 위치 반경 50km 거리순, 비면 같은 반경 30일
final homeEventsProvider = FutureProvider<HomeEvents>((ref) async {
final base = await ref.watch(basePointProvider.future);
final repo = ref.watch(eventRepositoryProvider);
@@ -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,