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
+53
View File
@@ -0,0 +1,53 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:wandergo/core/utils/format.dart';
void main() {
// 앱에서는 flutter_localizations 가 지금 언어의 날짜 표기 데이터를 준비한다
setUpAll(() => initializeDateFormatting('en'));
group('formatDistance', () {
test('1km 미만은 m, 100km 미만은 소수 한 자리 km, 그보다 멀면 정수 km', () {
expect(formatDistance(850), '850m');
expect(formatDistance(999.6), '1.0km');
expect(formatDistance(1240), '1.2km');
expect(formatDistance(85300), '85.3km');
expect(formatDistance(123456), '123km');
});
});
group('formatDateRange', () {
test('한국어는 월.일, 하루짜리는 한 날짜만', () {
expect(formatDateRange('2026-10-31', '2026-11-02'), '10.31–11.2');
expect(formatDateRange('2026-11-08', '2026-11-08'), '11.8');
});
test('영어는 그 언어의 월·일 표기', () {
expect(
formatDateRange('2026-10-31', '2026-11-02', localeName: 'en'),
'Oct 31 – Nov 2',
);
});
test('형식이 틀리면 원문을 그대로 보인다', () {
expect(
formatDateRange('2026.10.31', '2026.11.02'),
'2026.10.31–2026.11.02',
);
});
});
group('shortRegion', () {
test('시·도 다음 단어(구·군·시)를 꺼낸다', () {
expect(shortRegion('대구광역시 수성구 두산동 512'), '수성구');
expect(shortRegion('경상북도 포항시 북구 해안로 1'), '포항시');
expect(shortRegion('경북 경주시 첨성로 1'), '경주시');
});
test('시·도로 시작하지 않거나 비어 있으면 null', () {
expect(shortRegion(null), isNull);
expect(shortRegion('수성못 일원'), isNull);
expect(shortRegion('대구광역시'), isNull);
});
});
}
@@ -0,0 +1,22 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:wandergo/core/utils/keep_all.dart';
void main() {
group('keepAll', () {
test('단어 안의 글자 사이에만 줄바꿈 금지 글자를 넣는다', () {
expect(keepAll('어디 갈지'), '어⁠디 갈⁠지');
});
test('공백·줄바꿈은 그대로 두고, 보이는 글자는 바뀌지 않는다', () {
const text = '어디 갈지,\n같이 짜 볼까요?';
final result = keepAll(text);
expect(result.replaceAll('⁠', ''), text);
expect(result.split('\n'), hasLength(2));
});
test('빈 문자열·한 글자는 그대로', () {
expect(keepAll(''), '');
expect(keepAll('홈'), '홈');
});
});
}
+54
View File
@@ -0,0 +1,54 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:wandergo/core/utils/weekend.dart';
/// 한국 시각으로 만든 순간(UTC 로 바꿔 넘긴다)
DateTime kst(int y, int m, int d, [int h = 12]) =>
DateTime.utc(y, m, d, h).subtract(const Duration(hours: 9));
void main() {
group('weekendRange', () {
test('평일이면 다가오는 토·일', () {
// 2026-11-04 수요일
expect(weekendRange(kst(2026, 11, 4)), (
from: '2026-11-07',
to: '2026-11-08',
));
});
test('토요일이면 오늘·내일, 일요일이면 오늘 하루', () {
expect(weekendRange(kst(2026, 11, 7)), (
from: '2026-11-07',
to: '2026-11-08',
));
expect(weekendRange(kst(2026, 11, 8)), (
from: '2026-11-08',
to: '2026-11-08',
));
});
test('기기 시간대와 상관없이 한국 날짜로 계산한다', () {
// UTC 로는 아직 토요일 15:30 이지만 한국은 일요일 00:30 — 일요일 하루만
expect(weekendRange(DateTime.utc(2026, 11, 7, 15, 30)), (
from: '2026-11-08',
to: '2026-11-08',
));
});
test('월을 넘어가는 주말', () {
// 2026-10-30 금요일 → 10-31(토)·11-01(일)
expect(weekendRange(kst(2026, 10, 30)), (
from: '2026-10-31',
to: '2026-11-01',
));
});
});
group('upcomingRange', () {
test('오늘을 포함해 N일', () {
expect(upcomingRange(kst(2026, 11, 4), days: 30), (
from: '2026-11-04',
to: '2026-12-03',
));
});
});
}
+2 -2
View File
@@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:tma_app/core/api/api_exception.dart';
import 'package:tma_app/core/api/error_lexicon.dart';
import 'package:wandergo/core/api/api_exception.dart';
import 'package:wandergo/core/api/error_lexicon.dart';
void main() {
group('ErrorLexicon', () {
@@ -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);
});
});
}
+173 -6
View File
@@ -1,15 +1,182 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tma_app/main.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wandergo/app.dart';
import 'package:wandergo/core/clock.dart';
import 'package:wandergo/core/storage/preferences.dart';
import 'package:wandergo/core/utils/weekend.dart';
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/data/location_service.dart';
import 'package:wandergo/features/location/domain/base_location.dart';
/// 2026-11-04(수) 한국 정오 — 이번 주말은 11-07~11-08
final DateTime _now = DateTime.utc(2026, 11, 4, 3);
EventItem _event(
String id,
String name, {
EventKind kind = EventKind.festival,
}) => EventItem(
id: id,
name: name,
kind: kind,
startDate: '2026-11-07',
endDate: '2026-11-08',
ongoing: false,
address: '대구광역시 수성구 두산동',
distanceM: 1240,
);
/// 가짜 행사 API — 받은 조건을 기록하고, 주말·다가오는 기간에 따라 다른 목록을 준다
class _FakeEventRepository implements EventRepository {
_FakeEventRepository({this.weekend = const [], this.upcoming = const []});
final List<EventItem> weekend;
final List<EventItem> upcoming;
final List<({DateRange range, GeoPoint? near})> calls = [];
@override
Future<EventPage> search({
required DateRange range,
GeoPoint? near,
int? radiusM,
int limit = 20,
String? cursor,
}) async {
calls.add((range: range, near: near));
final isWeekend = range == weekendRange(_now);
return EventPage(items: isWeekend ? weekend : upcoming, hasNext: false);
}
@override
Future<EventDetail> getEvent(String id) async => EventDetail(
item: _event(id, '수성빛예술제'),
attribution: const Attribution(provider: '한국관광공사', asOf: '2026-10-15'),
overview: '수성못에서 열리는 빛 축제',
playtimeText: '17:00~22:00',
phone: '053-000-0000',
);
}
class _FakeLocationService implements LocationService {
const _FakeLocationService({this.granted = true});
final bool granted;
@override
Future<bool> requestPermission() async => granted;
@override
Future<GeoPoint?> currentPoint() async =>
granted ? (lat: 35.85, lng: 128.6) : null;
}
Future<_FakeEventRepository> _pumpApp(
WidgetTester tester, {
Map<String, Object> prefs = const {},
_FakeEventRepository? repo,
bool locationGranted = true,
}) async {
SharedPreferences.setMockInitialValues({'app_locale': 'ko', ...prefs});
final preferences = await SharedPreferences.getInstance();
final events = repo ?? _FakeEventRepository();
await tester.pumpWidget(
ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(preferences),
eventRepositoryProvider.overrideWithValue(events),
locationServiceProvider.overrideWithValue(
_FakeLocationService(granted: locationGranted),
),
clockProvider.overrideWithValue(() => _now),
],
child: const WanderGoApp(),
),
);
await tester.pumpAndSettle();
return events;
}
/// 화면 글자는 단어 단위 줄바꿈용 보이지 않는 글자를 품고 있어, 그 글자를 빼고 찾는다
Finder _text(String text) => find.byWidgetPredicate(
(w) =>
w is Text &&
(w.data ?? w.textSpan?.toPlainText() ?? '').replaceAll('⁠', '') == text,
);
void main() {
testWidgets('앱이 정상적으로 렌더링된다', (WidgetTester tester) async {
await tester.pumpWidget(
const ProviderScope(child: MyApp()),
testWidgets('첫 실행: 동네를 고르면 홈으로 가고, 그 동네 기준 이번 주말 행사를 보인다', (tester) async {
final repo = await _pumpApp(
tester,
repo: _FakeEventRepository(weekend: [_event('e1', '수성빛예술제')]),
);
expect(find.byType(MaterialApp), findsOneWidget);
expect(find.byType(Scaffold), findsOneWidget);
expect(_text('어디 갈지,\n같이 짜 볼까요?'), findsOneWidget);
await tester.tap(find.text('지역 직접 고르기'));
await tester.pumpAndSettle();
await tester.tap(find.text('수성못'));
await tester.pumpAndSettle();
expect(_text('이번 주말, 어디 갈까요?'), findsOneWidget);
expect(find.text('수성못'), findsOneWidget);
expect(find.text('이번 주말 축제·행사'), findsOneWidget);
expect(find.text('수성빛예술제'), findsOneWidget);
expect(find.text('수성구 · 1.2km'), findsOneWidget);
expect(repo.calls.single.near, Neighborhood.suseongmot.point);
});
testWidgets('위치 권한을 거부하면 대구 도심을 기준으로 시작하고 안내한다', (tester) async {
await _pumpApp(tester, locationGranted: false);
await tester.tap(find.text('현재 위치 사용하기'));
await tester.pumpAndSettle();
expect(find.text('대구 도심'), findsOneWidget);
expect(_text('현재 위치를 가져오지 못해 대구 도심을 기준으로 보여 드려요.'), findsOneWidget);
});
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(repo.calls.map((c) => c.range).toList(), [
(from: '2026-11-07', to: '2026-11-08'),
(from: '2026-11-04', to: '2026-12-03'),
]);
});
testWidgets('행사를 누르면 상세에 시간·전화·출처가 보인다', (tester) async {
await _pumpApp(
tester,
prefs: {'onboarding_done': true},
repo: _FakeEventRepository(weekend: [_event('e1', '수성빛예술제')]),
);
await tester.tap(find.text('수성빛예술제'));
await tester.pumpAndSettle();
expect(find.byType(BackButton), findsOneWidget);
expect(_text('17:00~22:00'), findsOneWidget);
expect(find.textContaining('053-000-0000'), findsOneWidget);
expect(find.text('출처: 한국관광공사 · 2026-10-15 기준'), findsOneWidget);
});
testWidgets('언어를 English 로 바꾸면 탭 이름이 영어로 바뀐다', (tester) async {
await _pumpApp(tester, prefs: {'onboarding_done': true});
await tester.tap(find.text('KO'));
await tester.pumpAndSettle();
await tester.tap(find.text('English'));
await tester.pumpAndSettle();
expect(find.text('Home'), findsOneWidget);
expect(find.text('My courses'), findsOneWidget);
});
}