feat(flutter): 앱 뼈대와 홈 이번 주말 축제·행사 화면

데이터 기반(장소·행사 조회 API)이 생겨 앱 화면을 붙이기 시작한다.
디자인 캔버스의 1~7번(시작·둘러보기) 중 지금 API 로 바로 되는 것부터 만들었다.

- 앱 뼈대: 디자인 캔버스의 색·글자 크기·선 아이콘을 옮긴 테마, go_router 하단 탭
  (홈·지도·내 코스·내 정보 — 지도·내 코스·내 정보는 자리만), 한/영 문구(arb, 4개 언어 전제)
- 첫 실행: 현재 위치를 쓰거나 대구 동네(도심·수성못·앞산 카페거리)를 고른다.
  위치를 못 쓰면 대구 도심을 기준으로 시작하고 알린다. 좌표는 저장하지 않는다
- 홈 '이번 주말 축제·행사': 기준 위치 반경 120km 거리순, 비면 다가오는 30일 (2026-11-03 합의)
- 행사 전체 목록(커서 페이지)·상세(시간·요금·예매·전화·소개·출처)
- 한국어 문단은 단어 단위 줄바꿈(keepAll) — Flutter 기본은 글자 단위로 끊는다
- 공공누리 Type3 사진은 자르지 않고, 꼬리말에 관광공사·KOPIS·문화포털 출처를 적는다
- 한/영 생성물(lib/l10n/generated)은 flutter pub get 이 만들므로 git 에서 뺀다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 14:51:02 +09:00
parent 170be20b6d
commit 0f306e71ba
59 changed files with 3876 additions and 83 deletions
@@ -0,0 +1,92 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:wandergo/features/event/data/models/event_models.dart';
import 'package:wandergo/features/location/domain/base_location.dart';
void main() {
group('EventPage.fromJson', () {
test('백엔드 목록 응답(data)을 읽는다 — 숫자는 정수·실수 모두 받는다', () {
final page = EventPage.fromJson({
'items': [
{
'id': 'a1',
'name': '수성빛예술제',
'kind': 'festival',
'startDate': '2026-11-27',
'endDate': '2026-12-06',
'genre': null,
'ongoing': false,
'address': '대구광역시 수성구 두산동',
'eventPlace': '수성못 일원',
'lat': 35.8282,
'lng': 128,
'distanceM': 1240,
'feeText': '무료',
'imageThumbUrl': null,
'imageLicense': 'Type3',
},
],
'pageInfo': {'hasNext': true, 'nextCursor': 'abc', 'total': null},
});
expect(page.hasNext, isTrue);
expect(page.nextCursor, 'abc');
final item = page.items.single;
expect(item.kind, EventKind.festival);
expect(item.lng, 128.0);
expect(item.distanceM, 1240.0);
expect(item.imageLicense, ImageLicense.type3);
});
test('모르는 분류·사진 유형은 null 로 둔다(앱이 죽지 않게)', () {
final item = EventItem.fromJson({
'id': 'a2',
'name': '새 분류',
'kind': 'workshop',
'startDate': '2026-11-07',
'endDate': '2026-11-07',
'ongoing': true,
'imageLicense': 'Type9',
});
expect(item.kind, isNull);
expect(item.imageLicense, isNull);
});
});
group('EventDetail.fromJson', () {
test('상세 필드와 출처를 읽는다', () {
final detail = EventDetail.fromJson({
'id': 'b1',
'name': '라보엠',
'kind': 'performance',
'startDate': '2026-11-07',
'endDate': '2026-11-07',
'genre': '오페라',
'ongoing': false,
'overview': '소개',
'playtimeText': '토요일(19:30)',
'homepage': null,
'phone': '053-000-0000',
'bookingUrl': 'https://tickets.test/1',
'imageUrl': 'https://kopis.or.kr/poster.jpg',
'attribution': {'provider': 'KOPIS', 'asOf': null},
});
expect(detail.item.genre, '오페라');
expect(detail.bookingUrl, 'https://tickets.test/1');
expect(detail.attribution.provider, 'KOPIS');
});
});
group('BaseLocationChoice', () {
test('저장 문자열로 오가도 같은 값이다', () {
const choices = [
BaseLocationChoice.current(),
BaseLocationChoice.neighborhood(Neighborhood.suseongmot),
];
for (final c in choices) {
expect(BaseLocationChoice.fromStorageKey(c.storageKey), c);
}
expect(BaseLocationChoice.fromStorageKey('seoul'), isNull);
expect(BaseLocationChoice.fromStorageKey(null), isNull);
});
});
}