generated from ttipo/project-base
Compare commits
2 Commits
4bd142a9b7
...
377181eade
| Author | SHA1 | Date | |
|---|---|---|---|
| 377181eade | |||
| c21fbbd973 |
@@ -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년 날짜·동해 좌표 픽스처라 실행 날짜와 무관하다.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user