generated from ttipo/project-base
feat(flutter): 축제·행사 칸을 공연·행사로 바꾸고 카드에 종류와 세로 포스터를 보임 #18
@@ -417,11 +417,13 @@ node dist/commands/place-sync.js tour-kor
|
||||
| `GET /api/events/{id}` | 소개·공연 시간·요금·장소·홈페이지·전화·출처. 숨김이면 RES_001 |
|
||||
|
||||
- **화면 규칙**(2026-11-03 합의):
|
||||
- 홈 '이번 주말 축제·행사' = `from`=토 · `to`=일 · 동네 중심 좌표 · `radius`=50000 → 대구 전역(군위 포함)과 경산·칠곡 등 바로 옆 도시까지 거리순.
|
||||
- 홈 '이번 주말 공연·행사' = `from`=토 · `to`=일 · 동네 중심 좌표 · `radius`=50000 → 대구 전역(군위 포함)과 경산·칠곡 등 바로 옆 도시까지 거리순.
|
||||
처음(2026-11-03)에는 관광공사 행사뿐이라 120km(경북)였다. KOPIS·문화포털이 들어온 뒤 대구 도심 기준 이번 주말 50km 32건 · 120km 42건이 되었고,
|
||||
120km 면 영덕·영주(차로 2시간)까지 나와 2026-11-04 50km 로 줄였다.
|
||||
- **비면** 같은 반경으로 `from`=오늘 · `to`=오늘+30일을 불러 제목을 '다가오는 축제·행사'로 바꿔 보여 준다(빈 칸을 두지 않는다).
|
||||
- 장소 상세 '근처 축제·행사' = 장소 좌표 + 기본 반경(5km), 없으면 칸을 숨긴다.
|
||||
- **비면** 같은 반경으로 `from`=오늘 · `to`=오늘+30일을 불러 제목을 '다가오는 공연·행사'로 바꿔 보여 준다(빈 칸을 두지 않는다).
|
||||
- 장소 상세 '근처 공연·행사' = 장소 좌표 + 기본 반경(5km), 없으면 칸을 숨긴다.
|
||||
- 이름은 처음에 '축제·행사'였다. KOPIS 공연이 들어오며 칸의 대부분이 공연이 되어, 축제 목록으로 오해하지 않게 '공연·행사'로 바꿨다(2026-11-12 사용자 결정, 홈 카테고리 칸 포함).
|
||||
카드 아래 줄 앞에 종류(공연 · 축제 · 행사 · 전시)를 굵게 보이고, 포스터가 대부분 세로라 썸네일을 5:7 로 보인다.
|
||||
- 계약 테스트: `backend/test/festival.e2e-spec.ts` — 2030년 날짜·동해 좌표 픽스처라 실행 날짜와 무관하다.
|
||||
|
||||
### 12.1 KOPIS 공연 (구현됨)
|
||||
|
||||
@@ -41,7 +41,7 @@ abstract final class AppColors {
|
||||
/// 사진이 없을 때의 자리 표시
|
||||
static const Color placeholder = Color(0xFFF5F6FA);
|
||||
|
||||
/// 축제·행사 날짜 글자
|
||||
/// 공연·행사 날짜 글자
|
||||
static const Color eventDate = Color(0xFF8A5A00);
|
||||
|
||||
/// 반려동물 동반 글자(발바닥 아이콘과 함께)
|
||||
|
||||
@@ -30,11 +30,11 @@ class HomeEvents {
|
||||
/// 조회한 기간 — '전체 보기' 가 같은 기간을 연다
|
||||
final DateRange range;
|
||||
|
||||
/// 주말이 비어 '다가오는 축제·행사' 로 바꿔 보이는지
|
||||
/// 주말이 비어 '다가오는 공연·행사' 로 바꿔 보이는지
|
||||
final bool isUpcoming;
|
||||
}
|
||||
|
||||
/// 홈 '이번 주말 축제·행사' — 기준 위치 반경 50km 거리순, 비면 같은 반경 30일
|
||||
/// 홈 '이번 주말 공연·행사' — 기준 위치 반경 50km 거리순, 비면 같은 반경 30일
|
||||
final homeEventsProvider = FutureProvider<HomeEvents>((ref) async {
|
||||
final base = await ref.watch(basePointProvider.future);
|
||||
final repo = ref.watch(eventRepositoryProvider);
|
||||
|
||||
@@ -6,11 +6,21 @@ import 'package:wandergo/core/utils/format.dart';
|
||||
import 'package:wandergo/features/event/data/models/event_models.dart';
|
||||
import 'package:wandergo/features/event/presentation/widgets/event_visuals.dart';
|
||||
|
||||
/// 홈 가로 목록 카드 너비·사진 높이 (디자인 캔버스 148×110)
|
||||
const double kEventCardWidth = 148;
|
||||
const double kEventCardImageHeight = 110;
|
||||
/// 홈 가로 목록 카드 너비·사진 높이 — 세로 포스터 비율(5:7)
|
||||
const double kEventCardWidth = 120;
|
||||
const double kEventCardImageHeight = kEventCardWidth / kEventPosterRatio;
|
||||
|
||||
/// 홈 가로 목록의 한 칸 — 사진 · 기간(갈색) · 이름 · 지역·거리 (테두리 없음)
|
||||
/// 사진 아래 여백 · 기간 · 이름 · 분류·지역 세 줄의 높이
|
||||
const double _kEventCardTextHeight = 72;
|
||||
|
||||
/// 홈 가로 목록 높이 — 사진 + 글자 세 줄
|
||||
const double kEventCardHeight = kEventCardImageHeight + _kEventCardTextHeight;
|
||||
|
||||
/// 세로 목록의 작은 사진 — 같은 포스터 비율
|
||||
const double _kEventRowImageWidth = 60;
|
||||
const double _kEventRowImageHeight = _kEventRowImageWidth / kEventPosterRatio;
|
||||
|
||||
/// 홈 가로 목록의 한 칸 — 포스터 · 기간(갈색) · 이름 · 분류·지역·거리 (테두리 없음)
|
||||
class EventCard extends StatelessWidget {
|
||||
const EventCard({super.key, required this.item, required this.onTap});
|
||||
|
||||
@@ -55,12 +65,7 @@ class EventCard extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
eventMeta(item),
|
||||
style: textTheme.bodySmall,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
EventMetaText(item: item),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -68,7 +73,7 @@ class EventCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 세로 목록의 한 줄 — 작은 사진 + 기간 · 이름 · 분류·지역·거리, 아래 구분선
|
||||
/// 세로 목록의 한 줄 — 작은 포스터 + 기간 · 이름 · 분류·지역·거리, 아래 구분선
|
||||
class EventRow extends StatelessWidget {
|
||||
const EventRow({super.key, required this.item, required this.onTap});
|
||||
|
||||
@@ -79,11 +84,6 @@ class EventRow extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final kind = item.kind?.label(l10n);
|
||||
final meta = [
|
||||
?kind,
|
||||
eventMeta(item),
|
||||
].where((s) => s.isNotEmpty).join(' · ');
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
@@ -94,8 +94,8 @@ class EventRow extends StatelessWidget {
|
||||
url: item.imageThumbUrl,
|
||||
kind: item.kind,
|
||||
license: item.imageLicense,
|
||||
width: 64,
|
||||
height: 64,
|
||||
width: _kEventRowImageWidth,
|
||||
height: _kEventRowImageHeight,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
@@ -123,12 +123,7 @@ class EventRow extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
meta,
|
||||
style: textTheme.bodySmall,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
EventMetaText(item: item),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.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/utils/format.dart';
|
||||
import 'package:wandergo/features/event/data/models/event_models.dart';
|
||||
@@ -48,6 +49,42 @@ String eventMeta(EventItem item) {
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/// 포스터 비율 — 축제·공연·행사 포스터가 대부분 세로(A판 1:1.41)라 5:7 로 보인다(2026-11-12)
|
||||
const double kEventPosterRatio = 5 / 7;
|
||||
|
||||
/// 목록 메타 줄 — "공연 · 수성구 · 1.2km". 홈 행사 칸이 대부분 공연이라 분류를 굵게 앞에 둔다(2026-11-12)
|
||||
class EventMetaText extends StatelessWidget {
|
||||
const EventMetaText({super.key, required this.item});
|
||||
|
||||
final EventItem item;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = Theme.of(context).textTheme.bodySmall;
|
||||
final kind = item.kind?.label(context.l10n);
|
||||
final rest = eventMeta(item);
|
||||
return Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
if (kind != null)
|
||||
TextSpan(
|
||||
text: kind,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
if (kind != null && rest.isNotEmpty) const TextSpan(text: ' · '),
|
||||
if (rest.isNotEmpty) TextSpan(text: rest),
|
||||
],
|
||||
),
|
||||
style: style,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 행사 사진 — 사진이 없으면 행사 종류별 자리 표시를 보인다([PublicImage]).
|
||||
class EventImage extends StatelessWidget {
|
||||
const EventImage({
|
||||
|
||||
@@ -21,10 +21,7 @@ import 'package:wandergo/shared/widgets/language_button.dart';
|
||||
import 'package:wandergo/shared/widgets/load_error.dart';
|
||||
import 'package:wandergo/shared/widgets/section_header.dart';
|
||||
|
||||
/// 홈 가로 목록 높이 — 사진 110 + 여백·글자 세 줄
|
||||
const double _kEventStripHeight = 186;
|
||||
|
||||
/// 3 홈 — 기준 위치 · 제목 · 검색창 · 카테고리 · 이번 주말 축제·행사 · 가까운 반려동물 동반 장소 · 출처.
|
||||
/// 3 홈 — 기준 위치 · 제목 · 검색창 · 카테고리 · 이번 주말 공연·행사 · 가까운 반려동물 동반 장소 · 출처.
|
||||
/// 배너·AI·추천 코스·지역 바로가기·로그인 버튼은 해당 기능을 만들 때 붙인다.
|
||||
class HomePage extends ConsumerWidget {
|
||||
const HomePage({super.key});
|
||||
@@ -195,7 +192,7 @@ class _BaseLocationButton extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 이번 주말 축제·행사 — 비면 다가오는 축제·행사
|
||||
/// 이번 주말 공연·행사 — 비면 다가오는 공연·행사
|
||||
class _EventsSection extends ConsumerWidget {
|
||||
const _EventsSection();
|
||||
|
||||
@@ -239,7 +236,7 @@ class _EventsSection extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
AsyncData(:final value) => SizedBox(
|
||||
height: _kEventStripHeight,
|
||||
height: kEventCardHeight,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: kPagePadding),
|
||||
@@ -275,7 +272,7 @@ class _EventStripSkeleton extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: _kEventStripHeight,
|
||||
height: kEventCardHeight,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
|
||||
@@ -7,7 +7,7 @@ const int kPlaceSearchRadiusM = 20000;
|
||||
/// 검색 화면의 분류 탭 — 백엔드 분류 여러 개를 사람이 고르는 단위로 묶는다.
|
||||
///
|
||||
/// 디자인의 '축제' 탭은 행사 API 에 검색어 조건이 없어 아직 넣지 않았다
|
||||
/// (홈 카테고리의 '축제'는 행사 목록으로 간다).
|
||||
/// (홈 카테고리의 '공연·행사'는 행사 목록으로 간다).
|
||||
enum PlaceTab {
|
||||
all([]),
|
||||
sights([PlaceCategory.attraction, PlaceCategory.leisure]),
|
||||
|
||||
@@ -14,7 +14,7 @@ import 'package:wandergo/features/place/domain/place_search.dart';
|
||||
/// 홈 '가까운 반려동물 동반 장소' 에 보이는 수
|
||||
const int kHomePetPlaceLimit = 3;
|
||||
|
||||
/// 장소 상세 '근처 축제·행사' 반경(m) — 없으면 칸을 숨긴다(2026-11-03 합의)
|
||||
/// 장소 상세 '근처 공연·행사' 반경(m) — 없으면 칸을 숨긴다(2026-11-03 합의)
|
||||
const int kNearbyEventRadiusM = 5000;
|
||||
const int kNearbyEventLimit = 3;
|
||||
|
||||
@@ -41,7 +41,7 @@ final homePetPlacesProvider = FutureProvider<List<PlaceItem>>((ref) async {
|
||||
return page.items;
|
||||
});
|
||||
|
||||
/// 장소 상세 '근처 축제·행사' — 그 장소 반경 5km, 오늘부터 30일
|
||||
/// 장소 상세 '근처 공연·행사' — 그 장소 반경 5km, 오늘부터 30일
|
||||
final nearbyEventsProvider = FutureProvider.family<List<EventItem>, GeoPoint>((
|
||||
ref,
|
||||
point,
|
||||
|
||||
@@ -631,7 +631,7 @@ class _PlaceAboutSectionState extends State<PlaceAboutSection> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 근처 축제·행사 — 반경 5km·30일 안에 없으면(또는 못 불러오면) 칸을 숨긴다
|
||||
/// 근처 공연·행사 — 반경 5km·30일 안에 없으면(또는 못 불러오면) 칸을 숨긴다
|
||||
class NearbyEventsSection extends ConsumerWidget {
|
||||
const NearbyEventsSection({super.key, required this.point});
|
||||
|
||||
|
||||
@@ -27,9 +27,9 @@
|
||||
"locationOutsideServiceArea": "WanderGo only covers Daegu for now, so we're showing downtown Daegu.",
|
||||
"baseLocationButtonLabel": "Change base location, current {place}",
|
||||
"homeTitle": "Where to this weekend?",
|
||||
"homeWeekendEvents": "Festivals & events this weekend",
|
||||
"homeUpcomingEvents": "Upcoming festivals & events",
|
||||
"homeEventsEmpty": "No festivals or events nearby yet.",
|
||||
"homeWeekendEvents": "Shows & events this weekend",
|
||||
"homeUpcomingEvents": "Upcoming shows & events",
|
||||
"homeEventsEmpty": "No shows or events nearby yet.",
|
||||
"seeAll": "See all",
|
||||
"retry": "Try again",
|
||||
"loadFailed": "Couldn't load this.",
|
||||
@@ -57,7 +57,7 @@
|
||||
"searchClear": "Clear search",
|
||||
"categoryNavLabel": "Categories",
|
||||
"categorySights": "Sights",
|
||||
"categoryFestival": "Festivals",
|
||||
"categoryFestival": "Shows & events",
|
||||
"categoryPet": "With pets",
|
||||
"categoryFood": "Food & cafés",
|
||||
"categoryStay": "Stays",
|
||||
@@ -136,7 +136,7 @@
|
||||
"hotlineBody": "24/7 travel help in English, 日本語, 中文",
|
||||
"showMore": "Show more",
|
||||
"showLess": "Show less",
|
||||
"nearbyEvents": "Festivals & events nearby",
|
||||
"nearbyEvents": "Shows & events nearby",
|
||||
"nearbyPlaces": "Good to go with",
|
||||
"photoCredit": "Photo: {provider} · {license}",
|
||||
"koglType1": "KOGL Type 1",
|
||||
|
||||
@@ -45,9 +45,9 @@
|
||||
}
|
||||
},
|
||||
"homeTitle": "이번 주말, 어디 갈까요?",
|
||||
"homeWeekendEvents": "이번 주말 축제·행사",
|
||||
"homeUpcomingEvents": "다가오는 축제·행사",
|
||||
"homeEventsEmpty": "근처에 예정된 축제·행사가 아직 없어요.",
|
||||
"homeWeekendEvents": "이번 주말 공연·행사",
|
||||
"homeUpcomingEvents": "다가오는 공연·행사",
|
||||
"homeEventsEmpty": "근처에 예정된 공연·행사가 아직 없어요.",
|
||||
"seeAll": "전체 보기",
|
||||
"retry": "다시 시도",
|
||||
"loadFailed": "불러오지 못했어요.",
|
||||
@@ -92,7 +92,7 @@
|
||||
"searchClear": "검색어 지우기",
|
||||
"categoryNavLabel": "카테고리",
|
||||
"categorySights": "관광지",
|
||||
"categoryFestival": "축제",
|
||||
"categoryFestival": "공연·행사",
|
||||
"categoryPet": "반려동물",
|
||||
"categoryFood": "맛집·카페",
|
||||
"categoryStay": "숙소",
|
||||
@@ -226,7 +226,7 @@
|
||||
"hotlineBody": "영어·일본어·중국어 24시간 안내",
|
||||
"showMore": "더 보기",
|
||||
"showLess": "접기",
|
||||
"nearbyEvents": "근처 축제·행사",
|
||||
"nearbyEvents": "근처 공연·행사",
|
||||
"nearbyPlaces": "함께 가기 좋은 곳",
|
||||
"photoCredit": "사진 {provider} · {license}",
|
||||
"@photoCredit": {
|
||||
|
||||
@@ -255,9 +255,10 @@ void main() {
|
||||
|
||||
expect(_text('이번 주말, 어디 갈까요?'), findsOneWidget);
|
||||
expect(find.text('수성못'), findsOneWidget);
|
||||
expect(find.text('이번 주말 축제·행사'), findsOneWidget);
|
||||
expect(find.text('이번 주말 공연·행사'), findsOneWidget);
|
||||
expect(find.text('수성빛예술제'), findsOneWidget);
|
||||
expect(find.text('수성구 · 1.2km'), findsOneWidget);
|
||||
// 홈 행사 칸이 대부분 공연이라 분류를 지역 앞에 보인다
|
||||
expect(find.text('축제 · 수성구 · 1.2km'), findsOneWidget);
|
||||
expect(repo.calls.single.near, Neighborhood.suseongmot.point);
|
||||
});
|
||||
|
||||
@@ -271,14 +272,14 @@ void main() {
|
||||
expect(_text('현재 위치를 가져오지 못해 대구 도심을 기준으로 보여 드려요.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('주말 행사가 없으면 다가오는 축제·행사(30일)로 바꿔 보인다', (tester) async {
|
||||
testWidgets('주말 행사가 없으면 다가오는 공연·행사(30일)로 바꿔 보인다', (tester) async {
|
||||
final repo = await _pumpApp(
|
||||
tester,
|
||||
prefs: {'onboarding_done': true},
|
||||
repo: _FakeEventRepository(upcoming: [_event('e2', '대구 재즈 페스티벌')]),
|
||||
);
|
||||
|
||||
expect(find.text('다가오는 축제·행사'), findsOneWidget);
|
||||
expect(find.text('다가오는 공연·행사'), findsOneWidget);
|
||||
expect(find.text('대구 재즈 페스티벌'), findsOneWidget);
|
||||
expect(repo.calls.map((c) => c.range).toList(), [
|
||||
(from: '2026-11-07', to: '2026-11-08'),
|
||||
|
||||
Reference in New Issue
Block a user