feat(flutter): AI 코스 입력·만드는 중·결과 화면(8–10) 추가

백엔드 AI 코스 API(4–6단계)를 앱에서 쓸 수 있게 화면 8–10 을 붙였다(설계 ai-course §14 7단계).

- 기반: 첫 실행 때 만든 기기 ID 를 안전 저장소에 두고 X-Device-Id 로 보낸다(비로그인 하루 횟수 기준).
  앱 언어를 Accept-Language 로 보내 AI 문구 언어를 맞춘다
- 홈 'AI에게 코스 맡기기' → 입력(8): 문장을 1초 멈추면 조건을 해석해 줄마다 채우고 고칠 수 있다.
  서버와 같은 조건 규칙을 앱에서도 먼저 본다. 하루 횟수를 다 썼으면 만들지 않고 안내한다
- 만드는 중(9): 1.5초마다 진행을 조회한다. 나가면 취소하고(차감 없음), 만들던 생성 id 를 남겨
  앱을 다시 켜거나 AI_004 를 받으면 이어 본다
- 결과(10): 사실 값 · 경고 · 이동 · 바꾸기 후보(한 번에 한 곳, 횟수 안 듦) · 평가 · 다시 만들기(확인 뒤 차감) ·
  출처와 AI 생성 표시. 요청 칸과 다시 만들기는 이 기기에서 입력한 생성일 때만 보인다
- 디자인 캔버스와 맞춤: 결과의 코스 제목은 빼고 'AI 추천 코스'와 요약으로 시작한다(사용자 결정).
  마스코트 그림을 홈 · 입력 · 만드는 중 · 결과 · 횟수 소진에 넣었다
- 화면 전체를 못 불러왔을 때의 공통 오류 화면(그림 · 다시 시도 · 오류 코드와 추적 ID)을 만들어 만드는 중 화면에 쓴다

코스로 저장 · 지도 미리보기 · 홈의 비 소식 줄 · 횟수 소진의 로그인 버튼은 그 기능이 생기면 붙인다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-12 15:48:23 +09:00
parent 8e53974c3f
commit af1a0f26fc
44 changed files with 7864 additions and 19 deletions
+14 -1
View File
@@ -628,5 +628,18 @@ v0.1 의 추천안을 사용자가 모두 그대로 골랐다.
5. Anthropic 연동 — SDK 설치(컨테이너), 프롬프트 v1, 조건 해석. 2026-11-11 반영(§2 끝 · §3.5 끝 · §11) — 서버 기본 AI 가 Anthropic 이 됐다(`AI_PROVIDER=fake` 로 되돌린다)
6. 평가 실행(사용자가 `!`) → 모델 · effort 결정 → 이 문서 갱신. 실행기와 기준선은 2026-11-11 반영(§9 · §9.1).
식사 시간대 고치기(§3.6 2a) · 프롬프트 v2(§3.5) 뒤 다시 재서 2026-11-12 정함(§8 · §9.2) — 코스 초안 Sonnet 5.5, 조건 해석 Haiku 5.5, 둘 다 effort low
7. Flutter 화면 8–10 연결
7. Flutter 화면 8–10 연결 — 두 번에 나눈다(2026-11-12 사용자 결정).
- 7a: 기기 ID(`X-Device-Id`, 첫 실행 때 만든 UUID 를 안전 저장소에) · `Accept-Language`(앱 언어) · API 6개 모델 · 홈 'AI에게 코스 맡기기' · 입력(8) · 만드는 중(9).
입력 화면은 서버의 조건 규칙(`conditionsProblem`)을 앱에서도 먼저 본다. 만드는 중에 나가면 취소하고, 만들던 생성 id 를 기기에 남겨 앱을 다시 켜거나 AI_004 를 받으면 이어 본다.
- 7b: 결과(10) — 사실 값 · 바꾸기 후보 · 평가 · 다시 만들기 · 출처 · AI 생성 표시.
바꾸기는 한 번에 한 곳만 한다. 후보는 원래 앞뒤 장소를 기준으로 검증돼 있어, 두 자리를 함께 바꾸면 그 사이 이동을 알 수 없다(MVP 합의 '한 곳만 바꾸기는 횟수에 들지 않는다'와 같다).
다시 만들기는 하루 횟수가 줄어 한 번 묻고, 지난 결과 id(`previousGenerationId`)를 넘긴다. 요청 칸과 다시 만들기는 이 기기에서 입력한 생성일 때만 보인다(앱을 다시 켜 이어 보면 입력이 없다).
- 이번에 빼는 것: 코스로 저장(로그인 · 코스 API 뒤), 지도 미리보기(지도 SDK 뒤), 홈의 '비 소식이 있으면 실내 코스로 짜기'(홈에서 볼 날씨 API 뒤),
횟수 소진 상태의 '로그인하기' · '직접 코스 짜기'(그 기능 뒤 — 지금은 안내 문구만).
- 시안 맞춤(2026-11-12, 디자인 캔버스 AiInput · AiLoading · AiCourse · States · Main 과 대조): 결과에서 코스 제목을 빼고(사용자 결정) 'AI 추천 코스'와 요약으로 시작한다.
마스코트 그림(`assets/images/character.png`, 시안 그림과 같은 파일)을 홈 84×61 · 입력 80×58 · 만드는 중 150×109 · 결과 72×52 · 횟수 소진 110×80 으로 넣었다.
만드는 중 문구는 "{동네} 근처에서 N곳을 찾았어요" · "{요일} 영업·반려견 동반으로 N곳을 추렸어요"(이 기기에서 입력한 생성일 때. 장소 기준은 이름이 이미 "○○ 근처"라 "○○ 근처에서").
시안과 일부러 다르게 둔 것: 예시 문장은 문장이 비었을 때만 보인다(누르면 쓰던 문장을 바꾸므로) · 바꾸기 설명에 '한 번에 한 곳만'을 더했다 ·
시안의 옅은 회색 #8A90A2 는 아이콘과 기다리는 단계에만 쓰고, 읽어야 하는 글자(글자 수 · 오류 코드 줄)는 대비 때문에 #6B7180 으로 둔다 ·
강수 줄은 "토요일 비 60%"(시안 "토요일 오후") — 오전·오후 범위에 걸칠 때의 규칙을 정하면 붙인다.
8. 출시 준비 — 처리방침(국외 이전) · AI 표시 문구 · Console 한도 · 보관 정리 작업
+32 -9
View File
@@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:wandergo/core/device/device_id.dart';
import 'api_exception.dart';
@@ -15,27 +16,49 @@ class DioClient {
late Dio _dio;
final FlutterSecureStorage _storage = const FlutterSecureStorage();
/// 기기 ID — 처음 한 번만 읽거나 만든다(동시에 여러 요청이 와도 하나만 만든다)
Future<String>? _deviceId;
factory DioClient() {
return _instance;
}
Future<String> _readDeviceId() => _deviceId ??= loadOrCreateDeviceId(
read: () => _storage.read(key: kDeviceIdKey),
write: (id) => _storage.write(key: kDeviceIdKey, value: id),
);
DioClient._internal() {
// 백엔드 글로벌 prefix(/api) 와 일치
final baseUrl = dotenv.maybeGet('API_URL') ?? 'http://localhost:3000/api';
_dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: _kConnectTimeout,
receiveTimeout: _kReceiveTimeout,
));
_dio = Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: _kConnectTimeout,
receiveTimeout: _kReceiveTimeout,
),
);
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
// Token Storage 정책: SecureStorage 에서 JWT 읽어 헤더에 부착
final token = await _storage.read(key: kJwtTokenKey);
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
// 비동기 인터셉터에서 던진 예외는 dio 가 받지 못해 요청이 끝나지 않는다 — 실패로 넘긴다
try {
// Token Storage 정책: SecureStorage 에서 JWT 읽어 헤더에 부착
final token = await _storage.read(key: kJwtTokenKey);
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
// 로그인 없이 쓰는 기능(AI 코스 생성)의 횟수를 기기마다 센다
options.headers[kDeviceIdHeader] = await _readDeviceId();
} catch (error) {
// 다음 요청에서 기기 ID 를 다시 읽는다
_deviceId = null;
return handler.reject(
DioException(requestOptions: options, error: error),
true,
);
}
return handler.next(options);
},
+24
View File
@@ -0,0 +1,24 @@
import 'package:wandergo/core/utils/uuid.dart';
/// 기기 ID 저장 키 — 앱 전체에서 이 상수만 쓴다
const String kDeviceIdKey = 'device_id';
/// 기기 ID 헤더 — 백엔드 `DEVICE_ID_HEADER` 와 같다.
/// 로그인 없이 쓰는 AI 코스 생성의 하루 횟수를 기기마다 센다(설계 ai-course §4).
const String kDeviceIdHeader = 'X-Device-Id';
/// 저장된 기기 ID 를 읽고, 없으면 UUID 를 만들어 저장한다.
///
/// 원문은 기기의 안전 저장소에만 두고(서버는 해시로 저장한다), 앱을 지우면 새로 만들어진다.
/// 저장소는 [read] · [write] 로 넘겨 테스트에서 바꿔 끼운다.
Future<String> loadOrCreateDeviceId({
required Future<String?> Function() read,
required Future<void> Function(String id) write,
String Function() create = uuidV4,
}) async {
final saved = (await read())?.trim();
if (saved != null && saved.isNotEmpty) return saved;
final id = create();
await write(id);
return id;
}
+52
View File
@@ -27,6 +27,7 @@ enum AppIconData {
chevronDown('<path d="M7 10l5 5 5-5"/>'),
chevronRight('<path d="M10 7l5 5-5 5"/>'),
chevronLeft('<path d="M14.5 5.5L8 12l6.5 6.5"/>'),
arrowRight('<path d="M5 12h14"/><path d="M13 6l6 6-6 6"/>'),
search('<circle cx="11" cy="11" r="6.5"/><path d="M20 20l-4.25-4.25"/>'),
close('<path d="M6 6l12 12M18 6L6 18"/>'),
check('<path d="M5 12.5l4.5 4.5L19 7.5"/>'),
@@ -34,6 +35,11 @@ enum AppIconData {
'<circle cx="12" cy="12" r="8.5"/><path d="M12 7.75v5"/><path d="M12 16.25h.01" stroke-width="2.5"/>',
),
ban('<circle cx="12" cy="12" r="8.5"/><path d="M9 9l6 6M15 9l-6 6"/>'),
/// 연결 끊김 — 사선이 그어진 구름(오류 화면)
cloudOff(
'<path d="M4 4l16 16"/><path d="M8.2 7.3A5.5 5.5 0 0 0 7.1 9.02 4 4 0 0 0 7.5 17h9.5"/><path d="M11.3 4.6A5.5 5.5 0 0 1 17.6 8a3.75 3.75 0 0 1 2.4 6.2"/>',
),
clock('<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>'),
parking(
'<rect x="4" y="4" width="16" height="16" rx="4"/><path d="M10 16.5v-9h3a2.75 2.75 0 0 1 0 5.5h-3"/>',
@@ -44,6 +50,52 @@ enum AppIconData {
externalLink(
'<path d="M14 4.5h5.5V10"/><path d="M19.5 4.5L11 13"/><path d="M18.5 14v4.5a1 1 0 0 1-1 1h-12a1 1 0 0 1-1-1v-12a1 1 0 0 1 1-1H10"/>',
),
calendar(
'<rect x="4" y="5.5" width="16" height="14.5" rx="2.5"/><path d="M4 10.25h16"/><path d="M8.5 3.5v4M15.5 3.5v4"/>',
),
people(
'<circle cx="9" cy="8.5" r="3.25"/><path d="M3.5 19.5c0-3.1 2.45-5 5.5-5s5.5 1.9 5.5 5"/><path d="M15.75 5.5a3.25 3.25 0 0 1 0 6.25"/><path d="M17.75 14.75c1.85.55 2.75 2.2 2.75 4.75"/>',
),
walk(
'<circle cx="13.25" cy="4.5" r="1.75"/><path d="M12.25 8.25l-1.5 5.5"/><path d="M10.75 13.75L8.5 17l-1.75 3.5"/><path d="M10.75 13.75l3 2.5.5 4.25"/><path d="M7.25 12l2.75-3.5h2.25l2.5 3 2.5.75"/>',
),
umbrella(
'<path d="M3.5 12.5a8.5 8.5 0 0 1 17 0z"/><path d="M12 12.5v5.75a2 2 0 0 1-4 0"/><path d="M12 4v-.75"/>',
),
plus('<path d="M12 5v14M5 12h14"/>'),
minus('<path d="M5 12h14"/>'),
/// 안내 — 원 안의 i. 원 안의 !(주의)는 [info]
notice(
'<circle cx="12" cy="12" r="8.5"/><path d="M12 11v5"/><path d="M12 7.75h.01" stroke-width="2.5"/>',
),
pencil(
'<path d="M4.5 19.5l1-4.25L15.75 5a2.12 2.12 0 0 1 3 3L8.5 18.5z"/><path d="M14 6.75l3.25 3.25"/>',
),
refresh(
'<path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3L19.5 9"/><path d="M19.5 4.5V9H15"/>',
),
thumbUp(
'<path d="M7.5 10.5v9.25H5a1 1 0 0 1-1-1V11.5a1 1 0 0 1 1-1z"{fill}/><path d="M7.5 10.5l3.55-6.2a1.55 1.55 0 0 1 2.85 1.1l-.8 3.85h5.15a2 2 0 0 1 1.95 2.45l-1.5 6.5a2 2 0 0 1-1.95 1.55H7.5"{fill}/>',
),
thumbDown(
'<g transform="rotate(180 12 12)"><path d="M7.5 10.5v9.25H5a1 1 0 0 1-1-1V11.5a1 1 0 0 1 1-1z"{fill}/><path d="M7.5 10.5l3.55-6.2a1.55 1.55 0 0 1 2.85 1.1l-.8 3.85h5.15a2 2 0 0 1 1.95 2.45l-1.5 6.5a2 2 0 0 1-1.95 1.55H7.5"{fill}/></g>',
),
/// 비 — 꽉 찬 구름과 빗줄기(글자 옆 작은 기호)
rainSolid(
'<path stroke="none" fill="currentColor" d="M7.5 14.5a4 4 0 0 1-.4-7.98A5.5 5.5 0 0 1 17.6 7a3.75 3.75 0 0 1-.1 7.5z"/><path d="M8.5 17.5l-1 2.5M12.5 17.5l-1 2.5M16.5 17.5l-1 2.5"/>',
),
/// AI 가 고른 이유 — 꽉 찬 반짝임(글자 앞 작은 기호)
sparkleSolid(
'<path stroke="none" fill="currentColor" d="M12 3c.7 4.9 3.6 8.3 9 9-5.4.7-8.3 4.1-9 9-.7-4.9-3.6-8.3-9-9 5.4-.7 8.3-4.1 9-9z"/>',
),
/// AI — 반짝임(늘 같은 색 16% 채움)
sparkle(
'<path d="M11 3.5c.55 4.1 2.9 6.45 7 7-4.1.55-6.45 2.9-7 7-.55-4.1-2.9-6.45-7-7 4.1-.55 6.45-2.9 7-7z" fill="currentColor" fill-opacity=".16"/><path d="M18.5 16.5v4M16.5 18.5h4"/>',
),
/// 반려동물 — 선 + 16% 채움(한눈에 보기)
paw(
@@ -1,8 +1,29 @@
import 'dart:ui' show PlatformDispatcher;
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:wandergo/core/l10n/l10n.dart';
import 'package:wandergo/core/storage/preferences.dart';
/// 화면에 실제로 보이는 언어 코드 — 고른 언어, 없으면 기기 언어.
/// 지원하지 않는 언어면 [AppLocalizations.supportedLocales] 의 첫 항목(영어)이다(MaterialApp 과 같은 규칙).
String resolveLanguageCode(Locale? chosen, Locale device) {
final code = (chosen ?? device).languageCode;
const supported = AppLocalizations.supportedLocales;
return supported.any((l) => l.languageCode == code)
? code
: supported.first.languageCode;
}
/// 서버에 보낼 언어(`Accept-Language`) — AI 코스는 이 언어로 제목·이유를 쓴다
final contentLanguageProvider = Provider<String>(
(ref) => resolveLanguageCode(
ref.watch(appLocaleProvider),
PlatformDispatcher.instance.locale,
),
);
/// 사용자가 고른 앱 언어. null 이면 기기 언어를 따른다
/// (지원하지 않는 기기 언어는 영어로 보인다 — AppLocalizations.supportedLocales 의 첫 항목).
final appLocaleProvider = NotifierProvider<AppLocaleNotifier, Locale?>(
+11
View File
@@ -5,6 +5,8 @@ import 'package:go_router/go_router.dart';
import 'package:wandergo/core/l10n/l10n.dart';
import 'package:wandergo/core/router/app_routes.dart';
import 'package:wandergo/core/router/app_shell.dart';
import 'package:wandergo/features/ai_course/presentation/pages/ai_course_generation_page.dart';
import 'package:wandergo/features/ai_course/presentation/pages/ai_course_input_page.dart';
import 'package:wandergo/features/event/presentation/pages/event_detail_page.dart';
import 'package:wandergo/features/event/presentation/pages/event_list_page.dart';
import 'package:wandergo/features/home/presentation/pages/home_page.dart';
@@ -109,6 +111,15 @@ final routerProvider = Provider<GoRouter>((ref) {
builder: (context, state) =>
PlaceDetailPage(id: state.pathParameters['id']!),
),
GoRoute(
path: AppRoutes.aiCourse,
builder: (context, state) => const AiCourseInputPage(),
),
GoRoute(
path: AppRoutes.aiCourseGenerationPattern,
builder: (context, state) =>
AiCourseGenerationPage(id: state.pathParameters['id']!),
),
],
);
ref.onDispose(router.dispose);
+8
View File
@@ -30,6 +30,14 @@ abstract final class AppRoutes {
static String placeDetail(String id) => '/places/$id';
/// AI 코스 — 조건 입력(8)
static const String aiCourse = '/ai-course';
/// AI 코스 — 만드는 중(9) · 결과(10). 진행 상태로 화면을 고른다
static const String aiCourseGenerationPattern = '/ai-course/:id';
static String aiCourseGeneration(String id) => '/ai-course/$id';
/// [focus] 면 들어가자마자 검색어를 입력할 수 있게 한다(홈 검색창)
static String placeSearch({
PlaceQuery query = const PlaceQuery(),
@@ -20,4 +20,7 @@ abstract final class PrefKeys {
/// 기준 위치('current' 또는 동네 이름). 현재 위치 좌표 자체는 저장하지 않는다
static const String baseLocation = 'base_location';
/// 만드는 중인 AI 코스 생성 id — 앱을 다시 켜거나 '이미 만드는 중'(AI_004)을 받으면 이어 본다
static const String pendingGeneration = 'ai_pending_generation';
}
+13
View File
@@ -50,6 +50,13 @@ abstract final class AppColors {
/// 금지·불가 표시 아이콘(예: 반려동물 동반 불가 구역)
static const Color danger = Color(0xFFB42318);
/// 옅은 아이콘 — 꺾쇠 · 더하기 · 빈 화면 · 오류 그림 · 기다리는 단계(시안 #8A90A2).
/// 흰 바탕 대비가 3.2:1 이라 읽어야 하는 글자에는 [textHint] 를 쓴다
static const Color iconMuted = Color(0xFF8A90A2);
/// 진행 막대 바탕
static const Color progressTrack = Color(0xFFF1F2F6);
/// 칩 테두리(선택 전)
static const Color chipBorder = Color(0xFFD9DCE6);
@@ -58,4 +65,10 @@ abstract final class AppColors {
/// 카테고리 아이콘 아래 이름
static const Color label = Color(0xFF2E3340);
/// 강수 예보 글자(AI 조건의 "토요일 비 60%")
static const Color rain = Color(0xFF1B6EC2);
/// 회색 바탕 위 선택된 칸의 얕은 그림자(장소 사이 이동 고르기)
static const Color raisedShadow = Color(0x1F161A23);
}
+28
View File
@@ -0,0 +1,28 @@
import 'dart:math';
/// UUID 한 개의 바이트 수
const int _kUuidBytes = 16;
/// 바이트 하나가 가질 수 있는 값의 수
const int _kByteValues = 256;
/// 버전 자리(7번째 바이트 위 4비트)와 변형 자리(9번째 바이트 위 2비트)
const int _kVersionIndex = 6;
const int _kVariantIndex = 8;
/// 무작위 UUID v4 — 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'(y 는 8 · 9 · a · b).
///
/// 기기 ID 처럼 겹치지 않아야 하는 값에 쓴다. [random] 은 테스트에서 값을 고정할 때만 넘긴다.
String uuidV4([Random? random]) {
final rng = random ?? Random.secure();
final bytes = List<int>.generate(
_kUuidBytes,
(_) => rng.nextInt(_kByteValues),
);
// 버전 4(0100xxxx) · RFC 4122 변형(10xxxxxx)
bytes[_kVersionIndex] = (bytes[_kVersionIndex] & 0x0f) | 0x40;
bytes[_kVariantIndex] = (bytes[_kVariantIndex] & 0x3f) | 0x80;
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-'
'${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}';
}
@@ -0,0 +1,392 @@
/// AI 코스 조건 — 백엔드 CourseConditionsDto · ParseConditionsResponseDto 와 1:1 대응한다(설계 ai-course §2).
library;
import 'package:wandergo/features/location/domain/base_location.dart';
import 'package:wandergo/features/place/data/models/place_models.dart'
show PetSize;
/// 코드값을 enum 으로 읽는다. 모르는 값이면 null — 백엔드가 값을 늘려도 앱이 죽지 않게 한다
T? _parseCode<T extends Enum>(
List<T> values,
Object? value,
String Function(T) code,
) {
for (final v in values) {
if (code(v) == value) return v;
}
return null;
}
/// 코스 지역 — 동네 · 좌표(현재 위치) · 장소("서문시장 근처")
sealed class CourseArea {
const CourseArea();
/// 모르는 모양이면 null
static CourseArea? fromJson(Object? json) {
if (json is! Map<String, dynamic>) return null;
switch (json['type']) {
case 'neighborhood':
final key = json['key'];
for (final n in Neighborhood.values) {
if (n.name == key) return NeighborhoodArea(n);
}
return null;
case 'point':
final lat = json['lat'];
final lng = json['lng'];
if (lat is! num || lng is! num) return null;
return PointArea(lat: lat.toDouble(), lng: lng.toDouble());
case 'place':
final placeId = json['placeId'];
return placeId is String ? PlaceArea(placeId) : null;
}
return null;
}
Map<String, Object> toJson();
}
/// 동네(도심 · 수성못 · 앞산)
final class NeighborhoodArea extends CourseArea {
const NeighborhoodArea(this.neighborhood);
final Neighborhood neighborhood;
@override
Map<String, Object> toJson() => {
'type': 'neighborhood',
'key': neighborhood.name,
};
@override
bool operator ==(Object other) =>
other is NeighborhoodArea && other.neighborhood == neighborhood;
@override
int get hashCode => neighborhood.hashCode;
}
/// 현재 위치 좌표 — 대구 안이어야 만들 수 있다
final class PointArea extends CourseArea {
const PointArea({required this.lat, required this.lng});
final double lat;
final double lng;
@override
Map<String, Object> toJson() => {'type': 'point', 'lat': lat, 'lng': lng};
@override
bool operator ==(Object other) =>
other is PointArea && other.lat == lat && other.lng == lng;
@override
int get hashCode => Object.hash(lat, lng);
}
/// 장소 DB 의 장소 근처
final class PlaceArea extends CourseArea {
const PlaceArea(this.placeId);
final String placeId;
@override
Map<String, Object> toJson() => {'type': 'place', 'placeId': placeId};
@override
bool operator ==(Object other) =>
other is PlaceArea && other.placeId == placeId;
@override
int get hashCode => placeId.hashCode;
}
enum PetKind {
dog,
cat,
other;
static PetKind? parse(Object? value) =>
_parseCode(values, value, (k) => k.name);
}
/// 함께 가는 반려동물
class PetCondition {
const PetCondition({required this.kind, this.size, this.count = 1});
/// 모르는 종류면 null
static PetCondition? fromJson(Object? json) {
if (json is! Map<String, dynamic>) return null;
final kind = PetKind.parse(json['kind']);
if (kind == null) return null;
final count = json['count'];
return PetCondition(
kind: kind,
size: PetSize.parse(json['size']),
count: count is int && count > 0 ? count : 1,
);
}
final PetKind kind;
/// 개가 아니거나 모르면 null
final PetSize? size;
final int count;
Map<String, Object?> toJson() => {
'kind': kind.name,
'size': size?.name,
'count': count,
};
@override
bool operator ==(Object other) =>
other is PetCondition &&
other.kind == kind &&
other.size == size &&
other.count == count;
@override
int get hashCode => Object.hash(kind, size, count);
}
/// 장소 사이 이동 수단
enum Mobility {
walk,
transit,
car;
static Mobility? parse(Object? value) =>
_parseCode(values, value, (m) => m.name);
}
/// 생성 조건(사용자가 확인한 값). 날짜·시각은 한국 기준 'YYYY-MM-DD' · 'HH:mm'(끝은 '24:00' 까지)
class CourseConditions {
const CourseConditions({
required this.area,
required this.date,
required this.start,
required this.end,
required this.party,
required this.pet,
required this.mobility,
required this.indoorIfRain,
});
/// 모양이 틀리면 null(지역 · 날짜 · 시각이 없으면 만들 수 없다)
static CourseConditions? fromJson(Object? json) {
if (json is! Map<String, dynamic>) return null;
final area = CourseArea.fromJson(json['area']);
final date = json['date'];
final start = json['start'];
final end = json['end'];
if (area == null || date is! String || start is! String || end is! String) {
return null;
}
final party = json['party'];
return CourseConditions(
area: area,
date: date,
start: start,
end: end,
party: party is int && party > 0 ? party : 2,
pet: PetCondition.fromJson(json['pet']),
mobility: Mobility.parse(json['mobility']) ?? Mobility.walk,
indoorIfRain: json['indoorIfRain'] != false,
);
}
final CourseArea area;
final String date;
final String start;
final String end;
final int party;
final PetCondition? pet;
final Mobility mobility;
final bool indoorIfRain;
Map<String, Object?> toJson() => {
'area': area.toJson(),
'date': date,
'start': start,
'end': end,
'party': party,
'pet': pet?.toJson(),
'mobility': mobility.name,
'indoorIfRain': indoorIfRain,
};
CourseConditions copyWith({
CourseArea? area,
String? date,
String? start,
String? end,
int? party,
Mobility? mobility,
bool? indoorIfRain,
}) => CourseConditions(
area: area ?? this.area,
date: date ?? this.date,
start: start ?? this.start,
end: end ?? this.end,
party: party ?? this.party,
pet: pet,
mobility: mobility ?? this.mobility,
indoorIfRain: indoorIfRain ?? this.indoorIfRain,
);
/// 반려동물만 바꾼다(없앨 때는 null)
CourseConditions withPet(PetCondition? value) => CourseConditions(
area: area,
date: date,
start: start,
end: end,
party: party,
pet: value,
mobility: mobility,
indoorIfRain: indoorIfRain,
);
}
/// 조건 항목 — 조건 해석이 문장에서 뽑은 항목(`inferred`)과 사용자가 고친 항목을 가리킨다
enum ConditionField {
area,
date,
start,
end,
party,
pet,
mobility,
indoorIfRain;
static ConditionField? parse(Object? value) =>
_parseCode(values, value, (f) => f.name);
}
/// 조건 해석이 되묻거나 알릴 것(설계 §2)
enum ConditionIssue {
unsupportedArea('unsupported_area'),
unknownPlace('unknown_place'),
pastDate('past_date'),
dateOutOfRange('date_out_of_range'),
multiDay('multi_day'),
tooShort('too_short'),
pastMidnight('past_midnight'),
unclearRequest('unclear_request');
const ConditionIssue(this.code);
final String code;
static ConditionIssue? parse(Object? value) =>
_parseCode(values, value, (i) => i.code);
}
/// 강수 형태(강수확률이 가장 높은 때)
enum PrecipitationType {
none('none'),
rain('rain'),
rainSnow('rain_snow'),
snow('snow'),
shower('shower');
const PrecipitationType(this.code);
final String code;
static PrecipitationType? parse(Object? value) =>
_parseCode(values, value, (p) => p.code);
}
/// 하늘 상태(가장 잦은 것)
enum SkyState {
clear('clear'),
partlyCloudy('partly_cloudy'),
cloudy('cloudy');
const SkyState(this.code);
final String code;
static SkyState? parse(Object? value) =>
_parseCode(values, value, (s) => s.code);
}
/// 그날 시간 범위의 날씨(기상청 단기예보). 예보가 없는 날(4일 뒤부터)은 응답에서 null 이다
class CourseWeather {
const CourseWeather({
required this.date,
required this.precipitationProbability,
this.precipitationType,
this.sky,
this.temperatureMin,
this.temperatureMax,
this.issuedAt,
});
static CourseWeather? fromJson(Object? json) {
if (json is! Map<String, dynamic>) return null;
final date = json['date'];
final probability = json['precipitationProbability'];
if (date is! String || probability is! num) return null;
final issuedAt = json['issuedAt'];
return CourseWeather(
date: date,
precipitationProbability: probability.round(),
precipitationType: PrecipitationType.parse(json['precipitationType']),
sky: SkyState.parse(json['sky']),
temperatureMin: (json['temperatureMin'] as num?)?.toDouble(),
temperatureMax: (json['temperatureMax'] as num?)?.toDouble(),
issuedAt: issuedAt is String ? DateTime.tryParse(issuedAt) : null,
);
}
final String date;
/// 시간 범위 중 가장 높은 강수확률(%)
final int precipitationProbability;
final PrecipitationType? precipitationType;
final SkyState? sky;
final double? temperatureMin;
final double? temperatureMax;
/// 발표 시각(UTC)
final DateTime? issuedAt;
}
/// 조건 해석 결과(`POST /course-conditions`) — 횟수를 차감하지 않는다
class ConditionParse {
const ConditionParse({
required this.conditions,
this.inferred = const {},
this.issues = const [],
this.weather,
});
/// 조건이 없거나 모양이 틀리면 null
static ConditionParse? fromJson(Map<String, dynamic> json) {
final conditions = CourseConditions.fromJson(json['conditions']);
if (conditions == null) return null;
final inferred = json['inferred'];
final issues = json['issues'];
return ConditionParse(
conditions: conditions,
inferred: {
if (inferred is List)
for (final f in inferred) ?ConditionField.parse(f),
},
issues: [
if (issues is List)
for (final i in issues) ?ConditionIssue.parse(i),
],
weather: CourseWeather.fromJson(json['weather']),
);
}
final CourseConditions conditions;
/// 문장에서 뽑은 항목 — 화면의 "적은 내용에서 자동으로 채웠어요"
final Set<ConditionField> inferred;
final List<ConditionIssue> issues;
final CourseWeather? weather;
}
@@ -0,0 +1,602 @@
/// AI 코스 생성 — 백엔드 CourseGenerationDto · CourseResultDto · CourseQuotaDto 와 1:1 대응한다(설계 ai-course §3.7 · §4).
library;
import 'package:wandergo/features/ai_course/data/models/course_conditions.dart';
import 'package:wandergo/features/place/data/models/place_models.dart'
show PetPolicy, PetSize, ValueOrigin;
import 'package:wandergo/shared/models/source_models.dart';
/// 코드값을 enum 으로 읽는다. 모르는 값이면 null — 백엔드가 값을 늘려도 앱이 죽지 않게 한다
T? _parseCode<T extends Enum>(
List<T> values,
Object? value,
String Function(T) code,
) {
for (final v in values) {
if (code(v) == value) return v;
}
return null;
}
List<String> _strings(Object? value) => [
if (value is List)
for (final v in value)
if (v is String && v.trim().isNotEmpty) v,
];
int _int(Object? value) => value is num ? value.round() : 0;
int? _intOrNull(Object? value) => value is num ? value.round() : null;
DateTime? _time(Object? value) =>
value is String ? DateTime.tryParse(value) : null;
/// 하루 횟수의 대상 — 비로그인(기기) · 로그인(사용자)
enum QuotaTier {
guest,
member;
static QuotaTier parse(Object? value) =>
_parseCode(values, value, (t) => t.name) ?? guest;
}
/// 오늘 남은 생성 횟수(`GET /course-generations/quota`)
class CourseQuota {
const CourseQuota({
required this.limit,
required this.used,
required this.remaining,
this.resetsAt,
this.tier = QuotaTier.guest,
});
factory CourseQuota.fromJson(Map<String, dynamic> json) => CourseQuota(
limit: _int(json['limit']),
used: _int(json['used']),
remaining: _int(json['remaining']),
resetsAt: _time(json['resetsAt']),
tier: QuotaTier.parse(json['tier']),
);
final int limit;
final int used;
final int remaining;
/// 다시 채워지는 시각(한국 자정, UTC 로 받는다)
final DateTime? resetsAt;
final QuotaTier tier;
}
/// 생성 상태
enum GenerationStatus {
queued,
running,
succeeded,
failed,
canceled;
/// 끝나지 않았다 — 진행을 계속 조회한다
bool get isActive => this == queued || this == running;
static GenerationStatus? parse(Object? value) =>
_parseCode(values, value, (s) => s.name);
}
/// 서버가 하는 일(설계 §3.1) — 찾기 → 거르기 → AI 와 시간표 → 정리
enum GenerationStage {
searching,
filtering,
planning,
finishing;
static GenerationStage? parse(Object? value) =>
_parseCode(values, value, (s) => s.name);
}
enum CourseRating {
like,
dislike;
static CourseRating? parse(Object? value) =>
_parseCode(values, value, (r) => r.name);
}
/// 생성 요청을 받은 결과(`POST /course-generations`)
class GenerationCreated {
const GenerationCreated({
required this.id,
required this.status,
required this.remaining,
});
factory GenerationCreated.fromJson(Map<String, dynamic> json) =>
GenerationCreated(
id: json['id'] as String,
status:
GenerationStatus.parse(json['status']) ?? GenerationStatus.queued,
remaining: _int(json['remaining']),
);
final String id;
final GenerationStatus status;
/// 이번 생성을 뺀 오늘 남은 횟수
final int remaining;
}
/// 생성 상태(`GET /course-generations/{id}`) — 실패해도 조회는 성공하고 이유는 [failure] 에 있다
class CourseGeneration {
const CourseGeneration({
required this.id,
required this.status,
this.stage,
this.found,
this.kept,
this.result,
this.failure,
this.remaining = 0,
this.rating,
});
factory CourseGeneration.fromJson(Map<String, dynamic> json) {
final progress = json['progress'];
final failure = json['failure'];
final result = json['result'];
return CourseGeneration(
id: json['id'] as String,
status: GenerationStatus.parse(json['status']) ?? GenerationStatus.failed,
stage: GenerationStage.parse(json['stage']),
found: progress is Map<String, dynamic>
? _intOrNull(progress['found'])
: null,
kept: progress is Map<String, dynamic>
? _intOrNull(progress['kept'])
: null,
result: result is Map<String, dynamic>
? CourseResult.fromJson(result)
: null,
failure: failure is Map<String, dynamic>
? GenerationFailure.fromJson(failure)
: null,
remaining: _int(json['remaining']),
rating: CourseRating.parse(json['rating']),
);
}
final String id;
final GenerationStatus status;
final GenerationStage? stage;
/// 찾은 후보 수 · 거른 뒤 남은 후보 수(그 단계 전이면 null)
final int? found;
final int? kept;
final CourseResult? result;
final GenerationFailure? failure;
final int remaining;
final CourseRating? rating;
}
/// 생성 실패 — 문구는 에러 코드 사전([code]), [notices] 는 AI 가 남긴 이유
class GenerationFailure {
const GenerationFailure({required this.code, this.notices = const []});
factory GenerationFailure.fromJson(Map<String, dynamic> json) =>
GenerationFailure(
code: json['code'] is String ? json['code'] as String : 'AI_002',
notices: _strings(json['notices']),
);
final String code;
final List<String> notices;
}
enum CourseOutcome {
course,
/// 장소를 뺐거나 AI 가 맞추지 못한 조건을 알렸다
partial;
static CourseOutcome parse(Object? value) =>
_parseCode(values, value, (o) => o.name) ?? course;
}
/// 장소 · 행사
enum StopKind {
place,
event;
static StopKind parse(Object? value) =>
_parseCode(values, value, (k) => k.name) ?? place;
}
/// 실내·외(운영자 확인 값, 없으면 분류로 추정)
enum StopSetting {
indoor,
outdoor,
unknown;
static StopSetting parse(Object? value) =>
_parseCode(values, value, (s) => s.name) ?? unknown;
}
/// 장소마다 보일 경고
enum StopWarning {
hoursUnknown('hours_unknown'),
eventTimeUnknown('event_time_unknown'),
petUnknown('pet_unknown'),
/// 그날이 행사 마지막 날
eventLastDay('event_last_day');
const StopWarning(this.code);
final String code;
static StopWarning? parse(Object? value) =>
_parseCode(values, value, (w) => w.code);
}
/// 코드가 남긴 알림 — 문구는 앱이 언어별로 만든다
enum PlanNoticeCode {
stopRemoved('stop_removed'),
indoorRatioUnmet('indoor_ratio_unmet');
const PlanNoticeCode(this.code);
final String code;
static PlanNoticeCode? parse(Object? value) =>
_parseCode(values, value, (c) => c.code);
}
/// 'HH:mm' 두 개 — 그날 영업 구간
typedef TimeRange = ({String start, String end});
/// 'YYYY-MM-DD' 두 개 — 행사 기간
typedef DateSpan = ({String start, String end});
/// 장소의 사실 값(생성 시점 스냅샷)
class StopFacts {
const StopFacts({
this.hoursOnDate,
this.hoursText,
this.hoursOrigin = ValueOrigin.unknown,
this.petPolicy = PetPolicy.unknown,
this.petOrigin = ValueOrigin.unknown,
this.petSizes,
this.verifiedAt,
this.setting = StopSetting.unknown,
this.eventPeriod,
this.provider = '',
});
factory StopFacts.fromJson(Map<String, dynamic> json) {
final hours = json['hoursOnDate'];
final sizes = json['petSizes'];
final period = json['eventPeriod'];
return StopFacts(
hoursOnDate: hours is List
? [
for (final h in hours)
if (h is Map<String, dynamic> &&
h['start'] is String &&
h['end'] is String)
(start: h['start'] as String, end: h['end'] as String),
]
: null,
hoursText: json['hoursText'] is String
? json['hoursText'] as String
: null,
hoursOrigin: ValueOrigin.parse(json['hoursOrigin']),
petPolicy: PetPolicy.parse(json['petPolicy']),
petOrigin: ValueOrigin.parse(json['petOrigin']),
petSizes: sizes is List
? [for (final s in sizes) ?PetSize.parse(s)]
: null,
verifiedAt: _time(json['verifiedAt']),
setting: StopSetting.parse(json['setting']),
eventPeriod:
period is Map<String, dynamic> &&
period['start'] is String &&
period['end'] is String
? (start: period['start'] as String, end: period['end'] as String)
: null,
provider: json['provider'] is String ? json['provider'] as String : '',
);
}
/// 그날 영업 구간. 모르면 null, 쉬는 날이면 빈 목록
final List<TimeRange>? hoursOnDate;
/// 영업시간 원문(행사는 운영 시간 문구)
final String? hoursText;
final ValueOrigin hoursOrigin;
final PetPolicy petPolicy;
final ValueOrigin petOrigin;
/// 동반 가능 크기(확인한 곳만)
final List<PetSize>? petSizes;
/// 운영자가 확인한 날
final DateTime? verifiedAt;
final StopSetting setting;
final DateSpan? eventPeriod;
/// 출처(예: 한국관광공사)
final String provider;
}
/// 장소 사이 이동(직선거리 추정)
class CourseLeg {
const CourseLeg({
required this.minutes,
required this.meters,
required this.mode,
this.estimated = true,
});
static CourseLeg? fromJson(Object? json) {
if (json is! Map<String, dynamic>) return null;
return CourseLeg(
minutes: _int(json['minutes']),
meters: _int(json['meters']),
mode: Mobility.parse(json['mode']) ?? Mobility.walk,
estimated: json['estimated'] != false,
);
}
final int minutes;
final int meters;
/// 실제 이동 수단 — 대중교통 · 자동차라도 걷는 편이 빠르면 걷기
final Mobility mode;
final bool estimated;
}
class CoursePhoto {
const CoursePhoto({required this.thumbUrl, this.license});
static CoursePhoto? fromJson(Object? json) {
if (json is! Map<String, dynamic>) return null;
final url = json['thumbUrl'];
if (url is! String || url.isEmpty) return null;
return CoursePhoto(
thumbUrl: url,
license: ImageLicense.parse(json['license']),
);
}
final String thumbUrl;
final ImageLicense? license;
}
/// 코스의 장소 하나
class CourseStop {
const CourseStop({
required this.kind,
required this.id,
required this.name,
required this.category,
required this.arrive,
required this.leave,
required this.minutes,
required this.reason,
required this.facts,
this.eventKind,
this.photo,
this.warnings = const [],
this.legToNext,
});
factory CourseStop.fromJson(Map<String, dynamic> json) {
final warnings = json['warnings'];
final facts = json['facts'];
return CourseStop(
kind: StopKind.parse(json['kind']),
id: json['id'] as String,
name: json['name'] as String,
category: json['category'] is String ? json['category'] as String : '',
eventKind: json['eventKind'] is String
? json['eventKind'] as String
: null,
photo: CoursePhoto.fromJson(json['photo']),
arrive: DateTime.parse(json['arrive'] as String),
leave: DateTime.parse(json['leave'] as String),
minutes: _int(json['minutes']),
reason: json['reason'] is String ? json['reason'] as String : '',
facts: facts is Map<String, dynamic>
? StopFacts.fromJson(facts)
: const StopFacts(),
warnings: [
if (warnings is List)
for (final w in warnings) ?StopWarning.parse(w),
],
legToNext: CourseLeg.fromJson(json['legToNext']),
);
}
final StopKind kind;
/// 장소 id 또는 행사 id
final String id;
final String name;
/// 분류(행사는 event)
final String category;
final String? eventKind;
final CoursePhoto? photo;
/// 도착 · 떠남(UTC)
final DateTime arrive;
final DateTime leave;
final int minutes;
/// 고른 이유(AI)
final String reason;
final StopFacts facts;
final List<StopWarning> warnings;
final CourseLeg? legToNext;
/// 다음 장소로 가는 이동만 바꾼 사본 — 바꾸기 후보를 넣으면 앞 장소의 이동이 바뀐다
CourseStop withLegToNext(CourseLeg? leg) => CourseStop(
kind: kind,
id: id,
name: name,
category: category,
arrive: arrive,
leave: leave,
minutes: minutes,
reason: reason,
facts: facts,
eventKind: eventKind,
photo: photo,
warnings: warnings,
legToNext: leg,
);
}
/// 바꾸기 후보 — 같은 시간대로 검증해 두어 앱이 AI 호출 없이 바꾼다
class CourseAlternate {
const CourseAlternate({
required this.forIndex,
required this.stop,
this.legFromPrev,
});
factory CourseAlternate.fromJson(Map<String, dynamic> json) =>
CourseAlternate(
forIndex: _int(json['forIndex']),
stop: CourseStop.fromJson(json),
legFromPrev: CourseLeg.fromJson(json['legFromPrev']),
);
/// 바꿀 장소의 자리(stops 의 0부터)
final int forIndex;
/// 바꿔 넣을 장소 — 다음 장소로 가는 이동([CourseStop.legToNext])까지 다시 계산돼 있다
final CourseStop stop;
/// 앞 장소에서 오는 이동
final CourseLeg? legFromPrev;
}
class PlanNotice {
const PlanNotice({required this.code, this.name, this.cause});
static PlanNotice? fromJson(Object? json) {
if (json is! Map<String, dynamic>) return null;
final code = PlanNoticeCode.parse(json['code']);
if (code == null) return null;
return PlanNotice(
code: code,
name: json['name'] is String ? json['name'] as String : null,
cause: json['cause'] is String ? json['cause'] as String : null,
);
}
final PlanNoticeCode code;
/// 뺀 장소 이름
final String? name;
/// 뺀 이유(closed · last_order · window · leg_too_long · pet)
final String? cause;
}
/// 숫자 요약
class CourseTotals {
const CourseTotals({
required this.stops,
required this.totalMinutes,
required this.travelMinutes,
required this.petConfirmed,
});
factory CourseTotals.fromJson(Object? json) {
final map = json is Map<String, dynamic> ? json : const <String, dynamic>{};
return CourseTotals(
stops: _int(map['stops']),
totalMinutes: _int(map['totalMinutes']),
travelMinutes: _int(map['travelMinutes']),
petConfirmed: _int(map['petConfirmed']),
);
}
final int stops;
final int totalMinutes;
final int travelMinutes;
/// 반려 동반을 확인한 곳 수
final int petConfirmed;
}
/// 생성 결과 — 저장하기 전의 초안이다
class CourseResult {
const CourseResult({
required this.outcome,
required this.title,
required this.summary,
required this.date,
required this.mobility,
required this.totals,
required this.stops,
this.notices = const [],
this.planNotices = const [],
this.weather,
this.alternates = const [],
this.sources = const [],
});
factory CourseResult.fromJson(Map<String, dynamic> json) {
final window = json['window'];
final stops = json['stops'];
final alternates = json['alternates'];
final planNotices = json['planNotices'];
return CourseResult(
outcome: CourseOutcome.parse(json['outcome']),
title: json['title'] is String ? json['title'] as String : '',
summary: json['summary'] is String ? json['summary'] as String : '',
notices: _strings(json['notices']),
planNotices: [
if (planNotices is List)
for (final n in planNotices) ?PlanNotice.fromJson(n),
],
weather: CourseWeather.fromJson(json['weather']),
date: window is Map<String, dynamic> && window['date'] is String
? window['date'] as String
: '',
mobility: Mobility.parse(json['mobility']) ?? Mobility.walk,
totals: CourseTotals.fromJson(json['totals']),
stops: [
if (stops is List)
for (final s in stops)
if (s is Map<String, dynamic>) CourseStop.fromJson(s),
],
alternates: [
if (alternates is List)
for (final a in alternates)
if (a is Map<String, dynamic>) CourseAlternate.fromJson(a),
],
sources: _strings(json['sources']),
);
}
final CourseOutcome outcome;
final String title;
final String summary;
/// AI 가 남긴 알림(맞추지 못한 조건 등)
final List<String> notices;
final List<PlanNotice> planNotices;
final CourseWeather? weather;
/// 코스 날짜('YYYY-MM-DD')
final String date;
final Mobility mobility;
final CourseTotals totals;
final List<CourseStop> stops;
final List<CourseAlternate> alternates;
/// 출처 문구(공공누리 유형 · 기상청 발표 시각 등)
final List<String> sources;
}
@@ -0,0 +1,105 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:wandergo/core/api/dio_provider.dart';
import 'package:wandergo/features/ai_course/data/models/course_conditions.dart';
import 'package:wandergo/features/ai_course/data/models/course_generation.dart';
/// 조건 해석 응답이 모양이 틀렸다 — 화면은 SYS_001 처럼 다룬다
class ConditionParseFormatException implements Exception {
const ConditionParseFormatException();
}
/// AI 코스 API(설계 ai-course §4) — 로그인 없이 쓰고, 기기 ID 헤더는 dio 인터셉터가 붙인다.
///
/// [lang] 은 `Accept-Language` 로 보낸다. 서버는 이 언어로 코스 제목·이유를 쓴다.
/// 응답 언래핑과 에러 코드 매핑은 dio 인터셉터가 맡는다. 여기서는 파싱만 한다.
class AiCourseRepository {
const AiCourseRepository(this._dio);
final Dio _dio;
static const String _generations = '/course-generations';
Options _options(String lang) => Options(headers: {'Accept-Language': lang});
/// 오늘 남은 횟수
Future<CourseQuota> quota({required String lang}) async {
final response = await _dio.get<Map<String, dynamic>>(
'$_generations/quota',
options: _options(lang),
);
return CourseQuota.fromJson(response.data!);
}
/// 문장을 조건으로 바꾼다 — 횟수를 차감하지 않는다. [baseArea] 는 문장에 지역이 없을 때의 기준이다
Future<ConditionParse> parseConditions({
required String text,
required CourseArea baseArea,
required String lang,
}) async {
final response = await _dio.post<Map<String, dynamic>>(
'/course-conditions',
data: {'text': text, 'baseArea': baseArea.toJson()},
options: _options(lang),
);
final parsed = ConditionParse.fromJson(response.data!);
if (parsed == null) throw const ConditionParseFormatException();
return parsed;
}
/// 생성을 시작한다 — 결과가 나와야 횟수가 차감된다.
/// [previousGenerationId] 는 '다시 만들기'에서 지난 결과와 다른 곳을 고르게 할 때 넘긴다
Future<GenerationCreated> create({
required String request,
required CourseConditions conditions,
String? previousGenerationId,
required String lang,
}) async {
final response = await _dio.post<Map<String, dynamic>>(
_generations,
data: {
'request': request,
'conditions': conditions.toJson(),
'previousGenerationId': ?previousGenerationId,
},
options: _options(lang),
);
return GenerationCreated.fromJson(response.data!);
}
Future<CourseGeneration> get(String id, {required String lang}) async {
final response = await _dio.get<Map<String, dynamic>>(
'$_generations/$id',
options: _options(lang),
);
return CourseGeneration.fromJson(response.data!);
}
/// 취소 — AI 호출을 끊는다. 차감하지 않는다. 이미 끝난 생성이면 그대로 돌려준다
Future<CourseGeneration> cancel(String id, {required String lang}) async {
final response = await _dio.post<Map<String, dynamic>>(
'$_generations/$id/cancellation',
options: _options(lang),
);
return CourseGeneration.fromJson(response.data!);
}
/// "이 코스, 마음에 드세요?" — 결과가 나온 생성만 받는다
Future<CourseGeneration> rate(
String id,
CourseRating value, {
required String lang,
}) async {
final response = await _dio.put<Map<String, dynamic>>(
'$_generations/$id/rating',
data: {'value': value.name},
options: _options(lang),
);
return CourseGeneration.fromJson(response.data!);
}
}
final aiCourseRepositoryProvider = Provider<AiCourseRepository>(
(ref) => AiCourseRepository(ref.watch(dioProvider)),
);
@@ -0,0 +1,149 @@
import 'package:wandergo/features/ai_course/data/models/course_conditions.dart';
import 'package:wandergo/features/ai_course/domain/condition_rules.dart';
/// 입력 화면(8)의 상태 — 요청 문장과, 해석해 채운 뒤 사용자가 고친 조건
class AiCourseDraft {
const AiCourseDraft({
this.text = '',
this.conditions,
this.inferred = const {},
this.edited = const {},
this.issues = const [],
this.weather,
this.parsing = false,
this.parseError,
this.parsedText,
this.submitting = false,
this.generationId,
});
final String text;
/// 해석하기 전이면 null
final CourseConditions? conditions;
/// 문장에서 뽑아 채운 항목(사용자가 고친 항목은 빠진다)
final Set<ConditionField> inferred;
/// 사용자가 고친 항목 — 문장을 고쳐 다시 해석해도 이 값을 지킨다
final Set<ConditionField> edited;
/// 아직 풀지 않은 것(그 항목을 고치면 사라진다)
final List<ConditionIssue> issues;
/// 해석한 날의 날씨 — 날짜를 고치면 [weatherForDate] 가 null 이 된다
final CourseWeather? weather;
final bool parsing;
final Object? parseError;
/// 마지막으로 해석한 문장 — 같은 문장은 다시 부르지 않는다
final String? parsedText;
final bool submitting;
/// 이 입력으로 마지막에 시작한 생성
final String? generationId;
/// 조건 날짜의 날씨(예보가 없거나 날짜가 다르면 null)
CourseWeather? get weatherForDate {
final w = weather;
return w != null && w.date == conditions?.date ? w : null;
}
/// 만들기 전에 고쳐야 하는 것이 남았나
bool get hasBlockingIssue => issues.any(isBlockingIssue);
AiCourseDraft copyWith({
String? text,
CourseConditions? conditions,
Set<ConditionField>? inferred,
Set<ConditionField>? edited,
List<ConditionIssue>? issues,
CourseWeather? weather,
bool? parsing,
Object? parseError,
bool clearParseError = false,
String? parsedText,
bool? submitting,
String? generationId,
}) => AiCourseDraft(
text: text ?? this.text,
conditions: conditions ?? this.conditions,
inferred: inferred ?? this.inferred,
edited: edited ?? this.edited,
issues: issues ?? this.issues,
weather: weather ?? this.weather,
parsing: parsing ?? this.parsing,
parseError: clearParseError ? null : (parseError ?? this.parseError),
parsedText: parsedText ?? this.parsedText,
submitting: submitting ?? this.submitting,
generationId: generationId ?? this.generationId,
);
/// 해석 결과를 반영한다 — 사용자가 고친 항목은 지금 값을 지키고, 그 항목의 알림은 풀린 것으로 본다
AiCourseDraft applyParse(ConditionParse parse, String parsedText) {
final current = conditions;
final merged = current == null
? parse.conditions
: _merge(parsed: parse.conditions, current: current, keep: edited);
return AiCourseDraft(
text: text,
conditions: merged,
inferred: parse.inferred.difference(edited),
edited: edited,
issues: [
for (final issue in parse.issues)
if (!edited.contains(issueField(issue))) issue,
],
weather: parse.weather,
parsedText: parsedText,
submitting: submitting,
generationId: generationId,
);
}
/// 사용자가 조건을 고쳤다 — 그 항목은 고친 것으로 남고 알림이 풀린다
AiCourseDraft applyEdit(CourseConditions next, Set<ConditionField> fields) =>
AiCourseDraft(
text: text,
conditions: next,
inferred: inferred.difference(fields),
edited: edited.union(fields),
issues: [
for (final issue in issues)
if (!fields.contains(issueField(issue))) issue,
],
weather: weather,
parsing: parsing,
parseError: parseError,
parsedText: parsedText,
submitting: submitting,
generationId: generationId,
);
static CourseConditions _merge({
required CourseConditions parsed,
required CourseConditions current,
required Set<ConditionField> keep,
}) {
T pick<T>(ConditionField field, T now, T next) =>
keep.contains(field) ? now : next;
return CourseConditions(
area: pick(ConditionField.area, current.area, parsed.area),
date: pick(ConditionField.date, current.date, parsed.date),
start: pick(ConditionField.start, current.start, parsed.start),
end: pick(ConditionField.end, current.end, parsed.end),
party: pick(ConditionField.party, current.party, parsed.party),
pet: pick(ConditionField.pet, current.pet, parsed.pet),
mobility: pick(
ConditionField.mobility,
current.mobility,
parsed.mobility,
),
indoorIfRain: pick(
ConditionField.indoorIfRain,
current.indoorIfRain,
parsed.indoorIfRain,
),
);
}
}
@@ -0,0 +1,163 @@
/// 생성 조건 규칙 — 백엔드 `course-conditions.ts` 의 `conditionsProblem` 과 같은 기준이다(설계 ai-course §2 · §4).
/// 앱이 먼저 막아 서버의 VAL_001 을 보지 않게 한다. 기준을 바꾸면 두 곳을 함께 고친다.
library;
import 'package:intl/intl.dart';
import 'package:wandergo/core/utils/format.dart';
import 'package:wandergo/features/ai_course/data/models/course_conditions.dart';
/// 요청 문장 최대 글자 수(디자인 200 — 서버는 300 까지 받는다)
const int kRequestTextMax = 200;
/// 하루 코스 길이(분) — 2~12시간
const int kWindowMinMinutes = 120;
const int kWindowMaxMinutes = 720;
/// 오늘부터 며칠 뒤까지 만들 수 있나
const int kDateRangeDays = 30;
const int kPartyMin = 1;
const int kPartyMax = 10;
const int kPetCountMax = 5;
/// 시각을 고치는 단위(분)
const int kTimeStepMinutes = 30;
const int _kMinutesPerHour = 60;
const int _kMinutesPerDay = 1440;
/// 한국 표준시(UTC+9) — 기기 시간대와 상관없이 한국 시각으로 계산한다(백엔드와 같은 기준)
const Duration _kKstOffset = Duration(hours: 9);
/// 'HH:mm' → 분. 형식이 다르면 null('24:00' 은 1440)
int? parseHhmm(String text) {
final m = RegExp(r'^(\d{2}):(\d{2})$').firstMatch(text);
if (m == null) return null;
final hours = int.parse(m[1]!);
final minutes = int.parse(m[2]!);
final total = hours * _kMinutesPerHour + minutes;
return minutes < _kMinutesPerHour && total <= _kMinutesPerDay ? total : null;
}
/// 분 → 'HH:mm'('24:00' 까지)
String formatHhmm(int minutes) {
final h = (minutes ~/ _kMinutesPerHour).toString().padLeft(2, '0');
final m = (minutes % _kMinutesPerHour).toString().padLeft(2, '0');
return '$h:$m';
}
/// 지금의 한국 날짜('YYYY-MM-DD')와 그날 지난 분
({String date, int minutes}) kstClock(DateTime now) {
final kst = now.toUtc().add(_kKstOffset);
return (
date: toYmd(DateTime.utc(kst.year, kst.month, kst.day)),
minutes: kst.hour * _kMinutesPerHour + kst.minute,
);
}
/// UTC 시각 → 한국 시각 'HH:mm'
String formatKstTime(DateTime utc) {
final kst = utc.toUtc().add(_kKstOffset);
return formatHhmm(kst.hour * _kMinutesPerHour + kst.minute);
}
/// 두 날짜('YYYY-MM-DD') 사이의 날 수. 형식이 틀리면 null
int? daysBetween(String from, String to) {
final a = parseYmd(from);
final b = parseYmd(to);
if (a == null || b == null) return null;
return b.difference(a).inDays;
}
/// 'YYYY-MM-DD' 에 날을 더한다
String addDays(String ymd, int days) {
final d = parseYmd(ymd);
return d == null ? ymd : toYmd(d.add(Duration(days: days)));
}
const List<String> _kKoWeekdays = ['월', '화', '수', '목', '금', '토', '일'];
/// 조건의 날짜 표시 — 한국어 "11.14(토)", 다른 언어 "Sat, Nov 14"
String formatCourseDate(String ymd, {required String localeName}) {
final d = parseYmd(ymd);
if (d == null) return ymd;
if (localeName.startsWith('ko')) {
return '${d.month}.${d.day}(${_kKoWeekdays[d.weekday - 1]})';
}
return DateFormat('EEE, MMM d', localeName).format(d);
}
/// 요일 이름 — 한국어 "토요일", 다른 언어 "Saturday"
String formatWeekday(String ymd, {required String localeName}) {
final d = parseYmd(ymd);
if (d == null) return ymd;
if (localeName.startsWith('ko')) return '${_kKoWeekdays[d.weekday - 1]}요일';
return DateFormat.EEEE(localeName).format(d);
}
/// 만들 수 없는 조건의 이유
enum ConditionProblem {
/// 지난 날짜
pastDate,
/// 30일보다 먼 날짜
dateOutOfRange,
/// 형식이 틀린 시각
invalidTime,
/// 2시간 미만(끝이 시작보다 이르면 여기 든다)
tooShort,
/// 12시간 초과
tooLong,
/// 오늘인데 이미 끝난 시간
pastTime,
}
/// 생성할 수 없는 조건이면 이유, 괜찮으면 null.
/// 날짜는 오늘~30일 뒤, 시간은 2~12시간에 자정을 넘기지 않고, 오늘이면 아직 끝나지 않은 시간이어야 한다.
/// 현재 위치가 대구 밖인지는 고를 때 막는다(지역 고르기).
ConditionProblem? conditionsProblem(CourseConditions c, DateTime now) {
final today = kstClock(now);
final ahead = daysBetween(today.date, c.date);
if (ahead == null || ahead < 0) return ConditionProblem.pastDate;
if (ahead > kDateRangeDays) return ConditionProblem.dateOutOfRange;
final start = parseHhmm(c.start);
final end = parseHhmm(c.end);
if (start == null || end == null) return ConditionProblem.invalidTime;
final length = end - start;
if (length < kWindowMinMinutes) return ConditionProblem.tooShort;
if (length > kWindowMaxMinutes) return ConditionProblem.tooLong;
if (ahead == 0 && end <= today.minutes) return ConditionProblem.pastTime;
return null;
}
/// 문제가 보일 조건 줄
ConditionField problemField(ConditionProblem problem) => switch (problem) {
ConditionProblem.pastDate ||
ConditionProblem.dateOutOfRange => ConditionField.date,
_ => ConditionField.start,
};
/// 조건 해석이 알린 것이 보일 조건 줄. 문장 칸이면 null
ConditionField? issueField(ConditionIssue issue) => switch (issue) {
ConditionIssue.unsupportedArea ||
ConditionIssue.unknownPlace => ConditionField.area,
ConditionIssue.pastDate ||
ConditionIssue.dateOutOfRange ||
ConditionIssue.multiDay => ConditionField.date,
ConditionIssue.tooShort ||
ConditionIssue.pastMidnight => ConditionField.start,
ConditionIssue.unclearRequest => null,
};
/// 고치기 전에는 만들 수 없는 것 — 조건이 요청과 달라졌다.
/// 장소를 못 찾은 것(unknownPlace)만 알리고 넘어간다(기준 위치로 두었다)
bool isBlockingIssue(ConditionIssue issue) =>
issue != ConditionIssue.unknownPlace;
/// 시각 범위 줄의 표시 — "14:00–20:00"
String formatTimeRange(String start, String end) => '$start–$end';
@@ -0,0 +1,60 @@
import 'package:wandergo/features/ai_course/data/models/course_generation.dart';
import 'package:wandergo/features/place/data/models/place_models.dart'
show PetPolicy;
/// 결과 화면(10)의 바꾸기 — 바꾸기 후보로 한 자리를 바꾼다(설계 ai-course §3.7).
///
/// 후보는 서버가 같은 시간대 · 앞뒤 이동까지 다시 계산해 두었다. 다만 계산의 기준이 원래 앞뒤 장소라,
/// 두 자리를 함께 바꾸면 그 사이 이동을 알 수 없다. 그래서 한 번에 한 곳만 바꾼다
/// (MVP 합의: 후보로 한 곳만 바꾸는 것은 하루 횟수에 들지 않는다).
List<CourseStop> stopsWithSwap(CourseResult result, int? swappedIndex) {
final stops = [...result.stops];
if (swappedIndex == null) return stops;
for (final alternate in result.alternates) {
final index = alternate.forIndex;
if (index != swappedIndex || index < 0 || index >= stops.length) continue;
stops[index] = alternate.stop;
if (index > 0) {
stops[index - 1] = stops[index - 1].withLegToNext(alternate.legFromPrev);
}
break;
}
return stops;
}
/// 자리마다 첫 바꾸기 후보 — 서버는 장소마다 1개까지 준다
Map<int, CourseAlternate> alternatesBySlot(CourseResult result) {
final bySlot = <int, CourseAlternate>{};
for (final alternate in result.alternates) {
final index = alternate.forIndex;
if (index < 0 || index >= result.stops.length) continue;
bySlot.putIfAbsent(index, () => alternate);
}
return bySlot;
}
/// 반려 동반을 확인한 곳 — 백엔드 `isPetConfirmed` 와 같은 기준(동반 가능 · 일부 구역)
bool isPetConfirmed(CourseStop stop) =>
stop.facts.petPolicy == PetPolicy.allowed ||
stop.facts.petPolicy == PetPolicy.partial;
/// 숫자 요약 — 바꾼 뒤 이동 시간 · 반려 동반 곳 수가 달라진다
CourseTotals totalsOf(List<CourseStop> stops) {
if (stops.isEmpty) {
return const CourseTotals(
stops: 0,
totalMinutes: 0,
travelMinutes: 0,
petConfirmed: 0,
);
}
return CourseTotals(
stops: stops.length,
totalMinutes: stops.last.leave.difference(stops.first.arrive).inMinutes,
travelMinutes: stops.fold(
0,
(sum, stop) => sum + (stop.legToNext?.minutes ?? 0),
),
petConfirmed: stops.where(isPetConfirmed).length,
);
}
@@ -0,0 +1,42 @@
import 'package:wandergo/features/ai_course/data/models/course_generation.dart';
/// 만드는 중 화면(9)의 네 줄 — 서버 단계(설계 §3.1)와 같은 순서다
enum GenerationStep {
/// 후보 찾기 — "○○ 근처에서 128곳을 찾았어요"
search,
/// 거르기 · 줄이기 — "31곳을 추렸어요"
filter,
/// AI 호출과 시간표 · 검증 · 고치기
plan,
/// 사실 값 · 바꾸기 후보 · 출처 붙이기
finish,
}
/// 줄 하나의 상태
enum GenerationStepState { done, current, waiting }
/// 지금 하고 있는 줄의 번호. 기다리는 중이면 0, 끝났으면 줄 수(모두 끝남)
int activeStepIndex(GenerationStatus status, GenerationStage? stage) {
if (status == GenerationStatus.succeeded) return GenerationStep.values.length;
return switch (stage) {
null || GenerationStage.searching => 0,
GenerationStage.filtering => 1,
GenerationStage.planning => 2,
GenerationStage.finishing => 3,
};
}
GenerationStepState stepState(GenerationStep step, int activeIndex) {
if (step.index < activeIndex) return GenerationStepState.done;
if (step.index == activeIndex) return GenerationStepState.current;
return GenerationStepState.waiting;
}
/// 진행 막대(0–1) — 시간이 가장 긴 AI 단계가 막대의 가운데를 차지한다
const List<double> _kStepProgress = [0.1, 0.3, 0.6, 0.9, 1];
double progressValue(GenerationStatus status, GenerationStage? stage) =>
_kStepProgress[activeStepIndex(status, stage)];
@@ -0,0 +1,460 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:wandergo/core/api/error_lexicon.dart';
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/utils/keep_all.dart';
import 'package:wandergo/features/ai_course/data/models/course_conditions.dart';
import 'package:wandergo/features/ai_course/data/models/course_generation.dart';
import 'package:wandergo/features/ai_course/domain/condition_rules.dart';
import 'package:wandergo/features/ai_course/domain/generation_steps.dart';
import 'package:wandergo/features/ai_course/presentation/providers/ai_course_providers.dart';
import 'package:wandergo/features/ai_course/presentation/widgets/ai_course_title.dart';
import 'package:wandergo/features/ai_course/presentation/widgets/condition_labels.dart';
import 'package:wandergo/features/ai_course/presentation/widgets/course_result_view.dart';
import 'package:wandergo/shared/widgets/app_back_button.dart';
import 'package:wandergo/shared/widgets/character_image.dart';
import 'package:wandergo/shared/widgets/load_error.dart';
/// 9 만드는 중 → 10 결과. 진행 상태로 화면을 고른다(같은 id 로 앱 복귀 · 이어 보기를 한다).
/// 만드는 중에 나가면(닫기 · 뒤로) 취소한다 — 차감하지 않는다.
class AiCourseGenerationPage extends ConsumerStatefulWidget {
const AiCourseGenerationPage({super.key, required this.id});
final String id;
@override
ConsumerState<AiCourseGenerationPage> createState() =>
_AiCourseGenerationPageState();
}
class _AiCourseGenerationPageState
extends ConsumerState<AiCourseGenerationPage> {
late final AppLifecycleListener _lifecycle;
GenerationNotifier get _notifier =>
ref.read(generationProvider(widget.id).notifier);
@override
void initState() {
super.initState();
// 백그라운드에서는 조회를 멈추고, 돌아오면 바로 한 번 조회한다
_lifecycle = AppLifecycleListener(
onStateChange: (state) {
switch (state) {
case AppLifecycleState.resumed:
_notifier.resume();
case AppLifecycleState.paused || AppLifecycleState.hidden:
_notifier.pause();
case AppLifecycleState.inactive || AppLifecycleState.detached:
break;
}
},
);
}
@override
void dispose() {
_lifecycle.dispose();
super.dispose();
}
/// 만드는 중이면 취소한다(이미 취소 중이거나 끝났으면 그대로 둔다)
void _cancelIfActive() {
final view = ref.read(generationProvider(widget.id));
final active = view.generation?.status.isActive ?? true;
if (active && !view.canceling) unawaited(_notifier.cancel());
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final view = ref.watch(generationProvider(widget.id));
final generation = view.generation;
final active = generation?.status.isActive ?? true;
final Widget body;
final error = view.error;
if (generation == null && error != null) {
body = _Centered(
child: LoadErrorBlock(error: error, onRetry: _notifier.retry),
);
} else if (generation == null || active) {
body = _ProgressView(id: widget.id, generation: generation);
} else {
body = switch (generation.status) {
GenerationStatus.succeeded => switch (generation.result) {
final result? => CourseResultView(
generation: generation,
result: result,
),
null => _FailureView(failure: generation.failure),
},
GenerationStatus.canceled => const _CanceledView(),
_ => _FailureView(failure: generation.failure),
};
}
return PopScope(
onPopInvokedWithResult: (didPop, _) {
if (didPop) _cancelIfActive();
},
child: Scaffold(
// 만드는 중은 닫기(X)와 제목만, 끝나면 뒤로 · 남은 횟수 · 아래 구분선(시안 9 · 10)
appBar: AppBar(
leading: active
? AppBackButton(icon: AppIconData.close, tooltip: l10n.aiClose)
: const AppBackButton(),
titleSpacing: 0,
title: AiCourseTitle(showQuota: !active),
shape: active
? null
: const Border(bottom: BorderSide(color: AppColors.divider)),
),
body: body,
bottomNavigationBar: active
? SafeArea(
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Center(
heightFactor: 1,
child: TextButton(
onPressed: view.canceling
? null
: () {
unawaited(_notifier.cancel());
Navigator.of(context).maybePop();
},
child: Text(l10n.aiCancel),
),
),
),
)
: null,
),
);
}
}
/// 남은 높이의 세로 가운데 — 짧은 화면에서는 위에서부터 넘긴다
class _Centered extends StatelessWidget {
const _Centered({required this.child, this.padding = EdgeInsets.zero});
final Widget child;
final EdgeInsets padding;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
padding: padding,
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: (constraints.maxHeight - padding.vertical).clamp(
0,
double.infinity,
),
),
child: Center(child: child),
),
),
);
}
}
/// 만드는 중 — 그림 · 제목 · 진행 막대 · 네 단계(시안 9, 세로 가운데)
class _ProgressView extends ConsumerWidget {
const _ProgressView({required this.id, required this.generation});
final String id;
final CourseGeneration? generation;
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = context.l10n;
final textTheme = Theme.of(context).textTheme;
final status = generation?.status ?? GenerationStatus.queued;
final stage = generation?.stage;
final active = activeStepIndex(status, stage);
// 이 입력으로 만든 생성이면 지역 · 요일 · 반려동물을 함께 보인다
// ("수성못 근처에서 128곳을 찾았어요" · "토요일 영업·반려견 동반으로 31곳을 추렸어요")
final draft = ref.watch(aiCourseDraftProvider);
final conditions = draft.generationId == id ? draft.conditions : null;
String textFor(GenerationStep step) {
final done = stepState(step, active) == GenerationStepState.done;
final found = generation?.found;
final kept = generation?.kept;
return switch (step) {
GenerationStep.search when done && found != null => switch (conditions
?.area) {
null => l10n.aiStepFound(found),
// 장소 기준은 이름이 이미 "○○ 근처"
final PlaceArea area => l10n.aiStepFoundAt(
areaLabel(area, l10n, ref),
found,
),
final area => l10n.aiStepFoundNear(areaLabel(area, l10n, ref), found),
},
GenerationStep.search => l10n.aiStepSearching,
GenerationStep.filter when done && kept != null => _keptText(
l10n,
conditions,
kept,
),
GenerationStep.filter => l10n.aiStepFiltering,
GenerationStep.plan => l10n.aiStepPlanning,
GenerationStep.finish => l10n.aiStepFinishing,
};
}
return _Centered(
padding: const EdgeInsets.fromLTRB(28, 0, 28, 40),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Center(child: CharacterImage(width: 150, height: 109)),
const SizedBox(height: 20),
Text(
l10n.aiLoadingTitle,
textAlign: TextAlign.center,
style: textTheme.titleLarge,
),
const SizedBox(height: 6),
Text(
l10n.aiLoadingSubtitle,
textAlign: TextAlign.center,
style: textTheme.bodyMedium?.copyWith(
fontSize: 14,
height: 21 / 14,
),
),
const SizedBox(height: 28),
Semantics(
label: l10n.aiProgressLabel,
child: TweenAnimationBuilder<double>(
tween: Tween(end: progressValue(status, stage)),
duration: const Duration(milliseconds: 600),
builder: (context, value, _) => LinearProgressIndicator(
value: value,
minHeight: 6,
borderRadius: BorderRadius.circular(3),
color: AppColors.brand,
backgroundColor: AppColors.progressTrack,
),
),
),
const SizedBox(height: 28),
for (final (index, step) in GenerationStep.values.indexed) ...[
if (index > 0) const SizedBox(height: 16),
_StepRow(state: stepState(step, active), text: textFor(step)),
],
],
),
);
}
/// 추린 결과 — 이 기기에서 만든 생성이면 요일 영업과 반려동물 조건을 말한다
static String _keptText(
AppLocalizations l10n,
CourseConditions? conditions,
int kept,
) {
if (conditions == null) return l10n.aiStepKept(kept);
final day = formatWeekday(conditions.date, localeName: l10n.localeName);
final pet = conditions.pet;
if (pet == null) return l10n.aiStepKeptOpen(day, kept);
return l10n.aiStepKeptOpenPet(
day,
pet.kind == PetKind.dog ? l10n.aiKeptPetDog : l10n.aiKeptPetAny,
kept,
);
}
}
class _StepRow extends StatelessWidget {
const _StepRow({required this.state, required this.text});
final GenerationStepState state;
final String text;
@override
Widget build(BuildContext context) {
final Widget mark = switch (state) {
GenerationStepState.done => Container(
width: 22,
height: 22,
decoration: const BoxDecoration(
color: AppColors.brand,
shape: BoxShape.circle,
),
child: const Center(
child: AppIcon(
AppIconData.check,
size: 13,
color: Colors.white,
strokeWidth: 3.25,
),
),
),
GenerationStepState.current => const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 3,
color: AppColors.brand,
backgroundColor: AppColors.chipBorderSelected,
),
),
GenerationStepState.waiting => Container(
width: 22,
height: 22,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: AppColors.chipBorder, width: 2),
),
),
};
return Row(
children: [
mark,
const SizedBox(width: 12),
Expanded(
child: Text(
keepAll(text),
style: TextStyle(
fontSize: 15,
height: 21 / 15,
fontWeight: state == GenerationStepState.current
? FontWeight.w600
: FontWeight.w400,
color: switch (state) {
GenerationStepState.done => AppColors.label,
GenerationStepState.current => AppColors.text,
// 아직 오지 않은 단계 — 비활성 표시라 옅은 회색(시안)
GenerationStepState.waiting => AppColors.iconMuted,
},
),
),
),
],
);
}
}
/// 끝난 화면(실패 · 취소)의 틀 — 공통 상태(시안 20)처럼 그림 타일 · 제목 · 설명 · 버튼
class _EndState extends StatelessWidget {
const _EndState({
required this.title,
this.messages = const [],
required this.action,
});
final String title;
final List<String> messages;
final Widget action;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return _Centered(
padding: const EdgeInsets.fromLTRB(28, 0, 28, 40),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppColors.placeholder,
borderRadius: BorderRadius.circular(20),
),
alignment: Alignment.center,
child: const AppIcon(
AppIconData.info,
size: 30,
strokeWidth: 1.75,
color: AppColors.iconMuted,
),
),
const SizedBox(height: 16),
Text(
keepAll(title),
textAlign: TextAlign.center,
style: textTheme.titleSmall?.copyWith(
fontSize: 17,
height: 25 / 17,
),
),
for (final message in messages) ...[
const SizedBox(height: 6),
Text(
keepAll(message),
textAlign: TextAlign.center,
style: textTheme.bodyMedium?.copyWith(
fontSize: 14,
height: 21 / 14,
),
),
],
const SizedBox(height: 20),
action,
],
),
);
}
}
/// 공통 상태의 버튼 — 높이 44, 좌우 18
final ButtonStyle _stateButtonStyle = FilledButton.styleFrom(
minimumSize: const Size(0, 44),
padding: const EdgeInsets.symmetric(horizontal: 18),
);
/// 만들지 못했다 — 문구는 에러 코드 사전, AI 가 남긴 이유를 함께 보인다
class _FailureView extends StatelessWidget {
const _FailureView({required this.failure});
final GenerationFailure? failure;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final failure = this.failure;
return _EndState(
title: l10n.aiFailedTitle,
messages: [
ErrorLexicon.getMessage(failure?.code ?? 'AI_002'),
...?failure?.notices,
],
action: FilledButton(
onPressed: () => Navigator.of(context).maybePop(),
style: _stateButtonStyle,
child: Text(l10n.aiEditConditions),
),
);
}
}
class _CanceledView extends StatelessWidget {
const _CanceledView();
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return _EndState(
title: l10n.aiCanceledTitle,
action: FilledButton(
onPressed: () => Navigator.of(context).maybePop(),
style: _stateButtonStyle,
child: Text(l10n.aiEditConditions),
),
);
}
}
@@ -0,0 +1,402 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:wandergo/core/clock.dart';
import 'package:wandergo/core/icons/app_icons.dart';
import 'package:wandergo/core/l10n/l10n.dart';
import 'package:wandergo/core/router/app_routes.dart';
import 'package:wandergo/core/theme/app_colors.dart';
import 'package:wandergo/core/theme/app_theme.dart';
import 'package:wandergo/core/utils/keep_all.dart';
import 'package:wandergo/features/ai_course/data/models/course_conditions.dart';
import 'package:wandergo/features/ai_course/domain/ai_course_draft.dart';
import 'package:wandergo/features/ai_course/domain/condition_rules.dart';
import 'package:wandergo/features/ai_course/presentation/providers/ai_course_providers.dart';
import 'package:wandergo/features/ai_course/presentation/widgets/ai_course_title.dart';
import 'package:wandergo/features/ai_course/presentation/widgets/condition_labels.dart';
import 'package:wandergo/features/ai_course/presentation/widgets/condition_section.dart';
import 'package:wandergo/features/ai_course/presentation/widgets/quota_exhausted_sheet.dart';
import 'package:wandergo/shared/widgets/app_back_button.dart';
import 'package:wandergo/shared/widgets/character_image.dart';
import 'package:wandergo/shared/widgets/load_error.dart';
/// 8 AI 조건 입력 — 문장을 쓰면 조건을 채우고, 고친 뒤 코스를 만든다(설계 ai-course §2).
/// 문장 · 조건은 화면을 나갔다 와도 남는다(결과에서 '조건 수정').
class AiCourseInputPage extends ConsumerStatefulWidget {
const AiCourseInputPage({super.key});
@override
ConsumerState<AiCourseInputPage> createState() => _AiCourseInputPageState();
}
class _AiCourseInputPageState extends ConsumerState<AiCourseInputPage> {
late final TextEditingController _controller;
/// 들어올 때 만들던 생성이 남아 있었으면 이어 보기를 보인다
String? _pendingId;
@override
void initState() {
super.initState();
_controller = TextEditingController(
text: ref.read(aiCourseDraftProvider).text,
);
_pendingId = ref.read(pendingGenerationProvider).read();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _useExample(String text) {
_controller.text = text;
unawaited(ref.read(aiCourseDraftProvider.notifier).useExample(text));
}
void _openGeneration(String id) {
setState(() => _pendingId = null);
unawaited(context.push(AppRoutes.aiCourseGeneration(id)));
}
Future<void> _submit() async {
FocusScope.of(context).unfocus();
final quota = ref.read(aiQuotaProvider).value;
if (quota != null && quota.remaining <= 0) {
await showQuotaExhaustedSheet(context);
return;
}
try {
final created = await ref.read(aiCourseDraftProvider.notifier).submit();
if (!mounted) return;
_openGeneration(created.id);
} catch (error) {
if (!mounted) return;
switch (errorCodeOf(error)) {
case 'AI_001':
ref.invalidate(aiQuotaProvider);
await showQuotaExhaustedSheet(context);
case 'AI_004':
// 이미 만드는 중 — 그 생성을 이어 본다
final pending = ref.read(pendingGenerationProvider).read();
if (pending != null) {
_openGeneration(pending);
return;
}
_tell(errorMessageOf(error));
default:
_tell(errorMessageOf(error));
}
}
}
void _tell(String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final textTheme = Theme.of(context).textTheme;
final draft = ref.watch(aiCourseDraftProvider);
final now = ref.watch(clockProvider)();
final pendingId = _pendingId;
final unclear = draft.issues.contains(ConditionIssue.unclearRequest);
return Scaffold(
appBar: AppBar(
leading: const AppBackButton(),
titleSpacing: 0,
title: const AiCourseTitle(),
shape: const Border(bottom: BorderSide(color: AppColors.divider)),
),
body: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
behavior: HitTestBehavior.translucent,
child: ListView(
padding: const EdgeInsets.fromLTRB(
kPagePadding,
20,
kPagePadding,
28,
),
children: [
if (pendingId != null) ...[
_PendingBanner(onOpen: () => _openGeneration(pendingId)),
const SizedBox(height: 16),
],
// 질문과 그림 — 그림은 두 줄 제목 높이에 맞춰 아래를 맞춘다
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Text(
keepAll(l10n.aiAskTitle),
style: textTheme.titleLarge?.copyWith(
fontSize: 21,
height: 29 / 21,
),
),
),
const SizedBox(width: 12),
const CharacterImage(width: 80, height: 58),
],
),
const SizedBox(height: 16),
_RequestBox(
controller: _controller,
hint: l10n.aiRequestHint,
onChanged: ref.read(aiCourseDraftProvider.notifier).setText,
onSubmitted: (_) =>
ref.read(aiCourseDraftProvider.notifier).parseNow(),
),
if (unclear)
Text(
keepAll(issueMessage(ConditionIssue.unclearRequest, l10n)),
style: textTheme.bodyMedium?.copyWith(color: AppColors.danger),
),
// 예시는 문장이 비었을 때만 — 누르면 쓰던 문장을 바꾸기 때문
if (draft.text.trim().isEmpty) ...[
const SizedBox(height: 12),
Text(l10n.aiExamplesTitle, style: textTheme.bodySmall),
const SizedBox(height: 2),
for (final example in [l10n.aiExample1, l10n.aiExample2])
_ExampleButton(
text: example,
onTap: () => _useExample(example),
),
],
const SizedBox(height: 24),
const Divider(),
const SizedBox(height: 24),
const ConditionSection(),
],
),
),
bottomNavigationBar: _SubmitBar(
enabled: _canSubmit(draft, now),
submitting: draft.submitting,
onSubmit: _submit,
),
);
}
/// 조건을 해석했고, 고쳐야 할 것이 없고, 만들 수 있는 조건일 때
bool _canSubmit(AiCourseDraft draft, DateTime now) {
final conditions = draft.conditions;
return conditions != null &&
draft.text.trim().isNotEmpty &&
!draft.parsing &&
!draft.submitting &&
!draft.hasBlockingIssue &&
conditionsProblem(conditions, now) == null;
}
}
/// 문장 칸 — 회색 칸 안 오른쪽 아래에 글자 수("22 / 200"). 칸은 112 높이부터 6줄까지 늘어난다
class _RequestBox extends StatelessWidget {
const _RequestBox({
required this.controller,
required this.hint,
required this.onChanged,
required this.onSubmitted,
});
final TextEditingController controller;
final String hint;
final ValueChanged<String> onChanged;
final ValueChanged<String> onSubmitted;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final textStyle = textTheme.bodyLarge?.copyWith(
fontSize: 16,
height: 24 / 16,
color: AppColors.text,
);
return Container(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 10),
decoration: BoxDecoration(
color: AppColors.inputFill,
borderRadius: BorderRadius.circular(kRadius),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 3줄(72) + 글자 수(16) + 위아래 여백(24) = 시안의 최소 높이 112
TextField(
controller: controller,
minLines: 3,
maxLines: 6,
maxLength: kRequestTextMax,
buildCounter:
(_, {required currentLength, required isFocused, maxLength}) =>
null,
textInputAction: TextInputAction.done,
style: textStyle,
decoration: InputDecoration(
hintText: hint,
hintStyle: textStyle?.copyWith(color: AppColors.textHint),
filled: false,
isDense: true,
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
),
onChanged: onChanged,
onSubmitted: onSubmitted,
),
Align(
alignment: Alignment.centerRight,
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: controller,
builder: (context, value, _) => Text(
'${value.text.characters.length} / $kRequestTextMax',
style: textTheme.bodySmall?.copyWith(color: AppColors.textHint),
),
),
),
],
),
);
}
}
/// 앱을 껐다 켰을 때 만들던 생성이 남아 있다
class _PendingBanner extends StatelessWidget {
const _PendingBanner({required this.onOpen});
final VoidCallback onOpen;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Row(
children: [
const AppIcon(AppIconData.sparkle, size: 18, color: AppColors.brand),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.aiPendingTitle,
style: Theme.of(context).textTheme.bodyMedium,
),
),
TextButton(
onPressed: onOpen,
style: TextButton.styleFrom(foregroundColor: AppColors.brand),
child: Text(l10n.aiPendingOpen),
),
],
);
}
}
/// "이렇게 써 봐도 좋아요" 예시 한 줄 — 누르면 그 문장으로 채운다
class _ExampleButton extends StatelessWidget {
const _ExampleButton({required this.text, required this.onTap});
final String text;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 36),
child: Row(
children: [
const AppIcon(
AppIconData.plus,
size: 14,
color: AppColors.iconMuted,
),
const SizedBox(width: 8),
Expanded(
child: Text(
keepAll(text),
style: const TextStyle(
fontSize: 14,
height: 20 / 14,
color: AppColors.textSecondary,
),
),
),
],
),
),
);
}
}
/// 아래 고정 — 안내 한 줄과 '코스 만들기'
class _SubmitBar extends StatelessWidget {
const _SubmitBar({
required this.enabled,
required this.submitting,
required this.onSubmit,
});
final bool enabled;
final bool submitting;
final VoidCallback onSubmit;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return DecoratedBox(
decoration: const BoxDecoration(
color: AppColors.background,
border: Border(top: BorderSide(color: AppColors.divider)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(
kPagePadding,
12,
kPagePadding,
12,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
keepAll(l10n.aiCreateNote),
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(height: 18 / 12),
),
const SizedBox(height: 10),
FilledButton.icon(
onPressed: enabled ? onSubmit : null,
icon: submitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const AppIcon(
AppIconData.sparkle,
size: 20,
color: Colors.white,
),
label: Text(l10n.aiCreate),
),
],
),
),
),
);
}
}
@@ -0,0 +1,286 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wandergo/core/l10n/locale_provider.dart';
import 'package:wandergo/core/storage/preferences.dart';
import 'package:wandergo/features/ai_course/data/models/course_conditions.dart';
import 'package:wandergo/features/ai_course/data/models/course_generation.dart';
import 'package:wandergo/features/ai_course/data/repositories/ai_course_repository.dart';
import 'package:wandergo/features/ai_course/domain/ai_course_draft.dart';
import 'package:wandergo/features/location/domain/base_location.dart';
import 'package:wandergo/features/location/presentation/providers/base_location_provider.dart';
/// 문장 입력이 이만큼 멈추면 조건 해석을 부른다(설계 §2 — 글자마다 부르지 않는다)
const Duration kParseDebounce = Duration(seconds: 1);
/// 진행 조회 간격(설계 §3.1 — 1~2초)
const Duration kPollInterval = Duration(milliseconds: 1500);
/// 진행 조회가 이만큼 잇달아 실패하면 오류를 보인다(잠깐 끊긴 것은 넘긴다)
const int kPollFailureLimit = 3;
/// 오늘 남은 생성 횟수
final aiQuotaProvider = FutureProvider.autoDispose<CourseQuota>(
(ref) => ref
.watch(aiCourseRepositoryProvider)
.quota(lang: ref.watch(contentLanguageProvider)),
);
/// 만드는 중인 생성 id — 앱을 다시 켜거나 '이미 만드는 중'(AI_004)을 받으면 이어 본다
class PendingGenerationStore {
const PendingGenerationStore(this._prefs);
final SharedPreferences _prefs;
String? read() => _prefs.getString(PrefKeys.pendingGeneration);
Future<void> save(String id) =>
_prefs.setString(PrefKeys.pendingGeneration, id);
/// [id] 가 저장된 값일 때만 지운다(다른 생성이 이미 시작됐으면 그대로 둔다)
Future<void> clear(String id) async {
if (read() == id) await _prefs.remove(PrefKeys.pendingGeneration);
}
}
final pendingGenerationProvider = Provider<PendingGenerationStore>(
(ref) => PendingGenerationStore(ref.watch(sharedPreferencesProvider)),
);
/// 문장에 지역이 없을 때의 기준 — 앱의 기준 위치(동네 또는 현재 위치)
CourseArea baseAreaOf(BasePoint base) {
final neighborhood = base.choice.neighborhood;
if (neighborhood != null) return NeighborhoodArea(neighborhood);
if (base.fellBack) return const NeighborhoodArea(kFallbackNeighborhood);
return PointArea(lat: base.point.lat, lng: base.point.lng);
}
/// 입력 화면(8)의 문장 · 조건. 화면을 나갔다 와도(결과에서 '조건 수정') 그대로 남는다
final aiCourseDraftProvider =
NotifierProvider<AiCourseDraftNotifier, AiCourseDraft>(
AiCourseDraftNotifier.new,
);
class AiCourseDraftNotifier extends Notifier<AiCourseDraft> {
Timer? _debounce;
/// 늦게 온 해석 응답이 새 문장의 결과를 덮지 않게 요청마다 번호를 붙인다
int _parseSeq = 0;
@override
AiCourseDraft build() {
ref.onDispose(() => _debounce?.cancel());
return const AiCourseDraft();
}
/// 문장이 바뀌었다 — 1초 멈추면 해석한다
void setText(String text) {
_debounce?.cancel();
state = state.copyWith(text: text);
if (text.trim().isEmpty) return;
_debounce = Timer(kParseDebounce, () => unawaited(parseNow()));
}
/// 예시 문구를 눌렀다
Future<void> useExample(String text) {
_debounce?.cancel();
state = state.copyWith(text: text);
return parseNow();
}
/// 지금 해석한다(키보드 '완료'·예시·다시 시도). 같은 문장을 이미 해석했으면 부르지 않는다
Future<void> parseNow() async {
_debounce?.cancel();
final text = state.text.trim();
if (text.isEmpty) return;
if (text == state.parsedText && state.parseError == null) return;
final seq = ++_parseSeq;
state = state.copyWith(parsing: true, clearParseError: true);
try {
final base = await ref.read(basePointProvider.future);
final parse = await ref
.read(aiCourseRepositoryProvider)
.parseConditions(
text: text,
baseArea: baseAreaOf(base),
lang: ref.read(contentLanguageProvider),
);
if (!ref.mounted || seq != _parseSeq) return;
state = state.applyParse(parse, text);
} catch (error) {
if (!ref.mounted || seq != _parseSeq) return;
state = state.copyWith(parsing: false, parseError: error);
}
}
/// 사용자가 조건을 고쳤다 — [change] 는 지금 조건에서 바꾼 값을 만든다(시트를 닫는 사이 다시 해석됐을 수 있다).
/// [fields] 는 고친 항목(시간은 시작·끝을 함께 넘긴다)
void edit(
CourseConditions Function(CourseConditions current) change,
Set<ConditionField> fields,
) {
final current = state.conditions;
if (current == null) return;
state = state.applyEdit(change(current), fields);
}
/// 생성을 시작한다. 실패하면 그대로 던진다(화면이 에러 코드로 나눈다).
/// [previousGenerationId] 는 결과 화면의 '다시 만들기' — 서버가 지난 결과와 다른 곳을 고른다
Future<GenerationCreated> submit({String? previousGenerationId}) async {
final conditions = state.conditions;
if (conditions == null) throw StateError('조건을 해석하기 전에는 만들 수 없다');
state = state.copyWith(submitting: true);
try {
final created = await ref
.read(aiCourseRepositoryProvider)
.create(
request: state.text.trim(),
conditions: conditions,
previousGenerationId: previousGenerationId,
lang: ref.read(contentLanguageProvider),
);
await ref.read(pendingGenerationProvider).save(created.id);
if (ref.mounted) {
state = state.copyWith(submitting: false, generationId: created.id);
}
ref.invalidate(aiQuotaProvider);
return created;
} catch (_) {
if (ref.mounted) state = state.copyWith(submitting: false);
rethrow;
}
}
}
/// 만드는 중 · 결과 화면의 상태. [generation] 이 없고 [error] 가 있으면 처음 조회부터 실패했다
class GenerationView {
const GenerationView({this.generation, this.error, this.canceling = false});
final CourseGeneration? generation;
final Object? error;
final bool canceling;
GenerationView copyWith({
CourseGeneration? generation,
Object? error,
bool clearError = false,
bool? canceling,
}) => GenerationView(
generation: generation ?? this.generation,
error: clearError ? null : (error ?? this.error),
canceling: canceling ?? this.canceling,
);
}
/// 생성 하나의 진행 — 끝날 때까지 [kPollInterval] 마다 조회한다. 앱이 백그라운드면 멈춘다
final generationProvider = NotifierProvider.autoDispose
.family<GenerationNotifier, GenerationView, String>(GenerationNotifier.new);
class GenerationNotifier extends Notifier<GenerationView> {
GenerationNotifier(this.id);
final String id;
Timer? _timer;
bool _paused = false;
int _failures = 0;
@override
GenerationView build() {
ref.onDispose(() => _timer?.cancel());
_timer = Timer(Duration.zero, () => unawaited(_poll()));
return const GenerationView();
}
bool get _active => state.generation?.status.isActive ?? true;
Future<void> _poll() async {
_timer?.cancel();
if (_paused) return;
try {
final generation = await ref
.read(aiCourseRepositoryProvider)
.get(id, lang: ref.read(contentLanguageProvider));
if (!ref.mounted) return;
_failures = 0;
state = state.copyWith(generation: generation, clearError: true);
if (generation.status.isActive) {
_schedule();
} else {
await _finished();
}
} catch (error) {
if (!ref.mounted) return;
_failures++;
// 잠깐 끊긴 것은 넘기고 다시 조회한다. 잇달아 실패하면 오류를 보이고 멈춘다
if (state.generation != null && _failures < kPollFailureLimit) {
_schedule();
} else {
state = state.copyWith(error: error);
}
}
}
void _schedule() {
_timer?.cancel();
if (_paused || !_active) return;
_timer = Timer(kPollInterval, () => unawaited(_poll()));
}
/// 끝났다 — 이어 보기 표시를 지우고 남은 횟수를 다시 받는다
Future<void> _finished() async {
final pending = ref.read(pendingGenerationProvider);
await pending.clear(id);
// 그사이 화면을 나갔으면 남은 횟수는 다음에 볼 때 받는다
if (ref.mounted) ref.invalidate(aiQuotaProvider);
}
/// 앱이 백그라운드로 갔다
void pause() {
_paused = true;
_timer?.cancel();
}
/// 앱으로 돌아왔다 — 바로 한 번 조회한다
void resume() {
if (!_paused) return;
_paused = false;
if (_active) unawaited(_poll());
}
/// "이 코스, 마음에 드세요?" — 결과가 나온 생성만 받는다. 실패하면 던진다(화면이 알린다)
Future<void> rate(CourseRating value) async {
final generation = await ref
.read(aiCourseRepositoryProvider)
.rate(id, value, lang: ref.read(contentLanguageProvider));
if (!ref.mounted) return;
state = state.copyWith(generation: generation);
}
/// 오류 뒤 다시 시도
Future<void> retry() {
_failures = 0;
state = state.copyWith(clearError: true);
return _poll();
}
/// 취소 — AI 호출을 끊는다. 차감하지 않는다
Future<void> cancel() async {
_timer?.cancel();
state = state.copyWith(canceling: true);
final repository = ref.read(aiCourseRepositoryProvider);
final pending = ref.read(pendingGenerationProvider);
final lang = ref.read(contentLanguageProvider);
try {
final generation = await repository.cancel(id, lang: lang);
await pending.clear(id);
if (!ref.mounted) return;
state = GenerationView(generation: generation);
} catch (error) {
if (!ref.mounted) return;
state = state.copyWith(canceling: false, error: error);
_schedule();
}
}
}
@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:wandergo/core/l10n/l10n.dart';
import 'package:wandergo/features/ai_course/presentation/providers/ai_course_providers.dart';
/// AI 화면 위 제목 — "AI 코스 만들기"와 오늘 남은 횟수(만드는 중에는 횟수 없이)
class AiCourseTitle extends ConsumerWidget {
const AiCourseTitle({super.key, this.showQuota = true});
final bool showQuota;
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = context.l10n;
final quota = showQuota ? ref.watch(aiQuotaProvider).value : null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.aiInputTitle),
if (quota != null) ...[
const SizedBox(height: 1),
Text(
quota.remaining > 0
? l10n.aiQuotaRemaining(quota.remaining)
: l10n.aiQuotaNone,
style: Theme.of(context).textTheme.bodySmall,
),
],
],
);
}
}
@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:wandergo/core/icons/app_icons.dart';
import 'package:wandergo/core/l10n/l10n.dart';
import 'package:wandergo/core/router/app_routes.dart';
import 'package:wandergo/core/theme/app_colors.dart';
import 'package:wandergo/core/theme/app_theme.dart';
import 'package:wandergo/core/utils/keep_all.dart';
import 'package:wandergo/features/ai_course/data/models/course_generation.dart';
import 'package:wandergo/features/ai_course/presentation/providers/ai_course_providers.dart';
import 'package:wandergo/shared/widgets/character_image.dart';
/// 홈 'AI에게 코스 맡기기' — 위아래 구분선 사이에 제목 · 설명 · 횟수와 그림, 누르면 AI 조건 입력(8)으로 가는 줄.
/// 디자인의 '비 소식이 있으면 실내 코스로 짜기'는 홈에서 볼 날씨 API 가 생기면 붙인다
class AiHomeSection extends ConsumerWidget {
const AiHomeSection({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = context.l10n;
final textTheme = Theme.of(context).textTheme;
final quota = ref.watch(aiQuotaProvider).value;
void open() => context.push(AppRoutes.aiCourse);
return DecoratedBox(
decoration: const BoxDecoration(
border: Border.symmetric(
horizontal: BorderSide(color: AppColors.divider),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.aiHomeTitle, style: textTheme.titleMedium),
const SizedBox(height: 6),
Text(
keepAll(l10n.aiHomeBody),
style: textTheme.bodyMedium,
),
if (quota != null && quota.tier == QuotaTier.guest) ...[
const SizedBox(height: 6),
Text(
l10n.aiHomeQuota(quota.limit),
style: textTheme.bodySmall?.copyWith(
color: AppColors.brand,
fontWeight: FontWeight.w600,
),
),
],
],
),
),
const SizedBox(width: 12),
const CharacterImage(width: 84, height: 61),
],
),
const SizedBox(height: 14),
Semantics(
button: true,
label: l10n.aiHomeTitle,
excludeSemantics: true,
onTap: open,
child: Material(
color: AppColors.inputFill,
borderRadius: BorderRadius.circular(kRadius),
child: InkWell(
borderRadius: BorderRadius.circular(kRadius),
onTap: open,
child: SizedBox(
height: 52,
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 6, 0),
child: Row(
children: [
const AppIcon(
AppIconData.sparkle,
size: 18,
color: AppColors.brand,
),
const SizedBox(width: 10),
Expanded(
child: Text(
l10n.aiHomePlaceholder,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyMedium?.copyWith(
fontSize: 14,
color: AppColors.textHint,
),
),
),
const SizedBox(width: 10),
// 보내기 모양의 빨간 네모 — 장식(줄 전체가 버튼)
Container(
width: 40,
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.brand,
borderRadius: BorderRadius.circular(10),
),
child: const AppIcon(
AppIconData.arrowRight,
size: 18,
strokeWidth: 2,
color: Colors.white,
),
),
],
),
),
),
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,81 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:wandergo/core/l10n/l10n.dart';
import 'package:wandergo/features/ai_course/data/models/course_conditions.dart';
import 'package:wandergo/features/ai_course/domain/condition_rules.dart';
import 'package:wandergo/features/location/presentation/widgets/region_sheet.dart';
import 'package:wandergo/features/place/data/models/place_models.dart'
show PetSize;
import 'package:wandergo/features/place/presentation/providers/place_providers.dart';
/// 조건 지역 이름 — 동네 이름 · 현재 위치 · "서문시장 근처"(장소 이름은 장소 API 로 받는다)
String areaLabel(CourseArea area, AppLocalizations l10n, WidgetRef ref) =>
switch (area) {
NeighborhoodArea(:final neighborhood) => neighborhood.label(l10n),
PointArea() => l10n.regionCurrentLocation,
PlaceArea(:final placeId) => switch (ref
.watch(placeDetailProvider(placeId))
.value) {
final detail? => l10n.aiPlaceNear(detail.item.displayName),
null => l10n.aiPlaceNearUnknown,
},
};
/// "소형견 1마리" · "고양이 2마리" · 함께 안 가면 "함께 안 가요"
String petLabel(PetCondition? pet, AppLocalizations l10n) {
if (pet == null) return l10n.aiPetNone;
final kind = switch (pet.kind) {
PetKind.dog => switch (pet.size) {
PetSize.small => l10n.aiPetSmallDog,
PetSize.medium => l10n.aiPetMediumDog,
PetSize.large => l10n.aiPetLargeDog,
null => l10n.aiPetDog,
},
PetKind.cat => l10n.aiPetCat,
PetKind.other => l10n.aiPetOther,
};
return l10n.aiPetSummary(pet.count, kind);
}
String mobilityLabel(Mobility mobility, AppLocalizations l10n) =>
switch (mobility) {
Mobility.walk => l10n.aiMobilityWalk,
Mobility.transit => l10n.aiMobilityTransit,
Mobility.car => l10n.aiMobilityCar,
};
/// 조건 해석이 알린 것의 문구
String issueMessage(ConditionIssue issue, AppLocalizations l10n) =>
switch (issue) {
ConditionIssue.unsupportedArea => l10n.aiIssueUnsupportedArea,
ConditionIssue.unknownPlace => l10n.aiIssueUnknownPlace,
ConditionIssue.pastDate => l10n.aiIssuePastDate,
ConditionIssue.dateOutOfRange => l10n.aiIssueDateOutOfRange,
ConditionIssue.multiDay => l10n.aiIssueMultiDay,
ConditionIssue.tooShort => l10n.aiIssueTooShort,
ConditionIssue.pastMidnight => l10n.aiIssuePastMidnight,
ConditionIssue.unclearRequest => l10n.aiIssueUnclear,
};
/// 만들 수 없는 조건의 문구
String problemMessage(ConditionProblem problem, AppLocalizations l10n) =>
switch (problem) {
ConditionProblem.pastDate => l10n.aiProblemPastDate,
ConditionProblem.dateOutOfRange => l10n.aiProblemDateOutOfRange,
ConditionProblem.invalidTime => l10n.aiProblemInvalidTime,
ConditionProblem.tooShort => l10n.aiProblemTooShort,
ConditionProblem.tooLong => l10n.aiProblemTooLong,
ConditionProblem.pastTime => l10n.aiProblemPastTime,
};
const int _kMinutesPerHour = 60;
/// 길이 표시 — "6시간" · "2시간 30분" · 1시간이 안 되면 "19분"
String durationLabel(int minutes, AppLocalizations l10n) {
final hours = minutes ~/ _kMinutesPerHour;
final rest = minutes % _kMinutesPerHour;
if (hours == 0) return l10n.aiMinutes(rest);
return rest == 0
? l10n.aiDurationHours(hours)
: l10n.aiDurationHoursMinutes(hours, rest);
}
@@ -0,0 +1,611 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:wandergo/core/clock.dart';
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/utils/format.dart';
import 'package:wandergo/core/utils/keep_all.dart';
import 'package:wandergo/features/ai_course/data/models/course_conditions.dart';
import 'package:wandergo/features/ai_course/domain/ai_course_draft.dart';
import 'package:wandergo/features/ai_course/domain/condition_rules.dart';
import 'package:wandergo/features/ai_course/presentation/providers/ai_course_providers.dart';
import 'package:wandergo/features/ai_course/presentation/widgets/condition_labels.dart';
import 'package:wandergo/features/ai_course/presentation/widgets/condition_sheets.dart';
import 'package:wandergo/features/location/data/location_service.dart';
import 'package:wandergo/features/location/domain/base_location.dart';
import 'package:wandergo/features/location/presentation/widgets/region_sheet.dart';
import 'package:wandergo/shared/widgets/app_switch.dart';
import 'package:wandergo/shared/widgets/load_error.dart';
/// 입력 화면(8)의 '조건' — 문장에서 채운 값을 보이고 줄마다 고친다
class ConditionSection extends ConsumerWidget {
const ConditionSection({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = context.l10n;
final textTheme = Theme.of(context).textTheme;
final draft = ref.watch(aiCourseDraftProvider);
final conditions = draft.conditions;
final Widget body;
if (conditions == null) {
final error = draft.parseError;
body = switch ((draft.parsing, error)) {
(true, _) => _Parsing(text: l10n.aiParsing),
(false, final error?) => LoadError(
error: error,
onRetry: () => ref.read(aiCourseDraftProvider.notifier).parseNow(),
),
_ => Text(keepAll(l10n.aiConditionsEmpty), style: textTheme.bodyMedium),
};
} else {
body = _ConditionRows(draft: draft, conditions: conditions);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.aiConditionsTitle, style: textTheme.titleMedium),
const SizedBox(height: 4),
if (conditions != null) ...[
Row(
children: [
Expanded(
child: Text(
keepAll(l10n.aiConditionsAutoFilled),
style: textTheme.bodyMedium,
),
),
if (draft.parsing) const _SmallSpinner(),
],
),
if (draft.parseError case final error?)
LoadError(
error: error,
onRetry: () =>
ref.read(aiCourseDraftProvider.notifier).parseNow(),
),
],
const SizedBox(height: 4),
body,
],
);
}
}
class _Parsing extends StatelessWidget {
const _Parsing({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
children: [
const _SmallSpinner(),
const SizedBox(width: 10),
Text(text, style: Theme.of(context).textTheme.bodyMedium),
],
),
);
}
}
class _SmallSpinner extends StatelessWidget {
const _SmallSpinner();
@override
Widget build(BuildContext context) {
return const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.brand),
);
}
}
class _ConditionRows extends ConsumerWidget {
const _ConditionRows({required this.draft, required this.conditions});
final AiCourseDraft draft;
final CourseConditions conditions;
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = context.l10n;
final localeName = Localizations.localeOf(context).toString();
final notifier = ref.read(aiCourseDraftProvider.notifier);
final problem = conditionsProblem(conditions, ref.watch(clockProvider)());
/// 그 줄에 보일 알림 — 만들 수 없는 조건이 먼저, 그다음 조건 해석이 알린 것
_RowMessage? messageFor(ConditionField field) {
if (problem != null && problemField(problem) == field) {
return (text: problemMessage(problem, l10n), blocking: true);
}
for (final issue in draft.issues) {
if (issueField(issue) == field) {
return (
text: issueMessage(issue, l10n),
blocking: isBlockingIssue(issue),
);
}
}
return null;
}
bool inferred(ConditionField field) => draft.inferred.contains(field);
final weather = draft.weatherForDate;
return Column(
children: [
_ConditionRow(
icon: AppIconData.pin,
label: l10n.aiFieldArea,
value: areaLabel(conditions.area, l10n, ref),
inferred: inferred(ConditionField.area),
message: messageFor(ConditionField.area),
onTap: () => _pickArea(context, ref),
),
_ConditionRow(
icon: AppIconData.calendar,
label: l10n.aiFieldDate,
value: formatCourseDate(conditions.date, localeName: localeName),
inferred: inferred(ConditionField.date),
message: messageFor(ConditionField.date),
onTap: () => _pickDate(context, ref),
),
_ConditionRow(
icon: AppIconData.clock,
label: l10n.aiFieldTime,
value: formatTimeRange(conditions.start, conditions.end),
inferred:
inferred(ConditionField.start) || inferred(ConditionField.end),
message: messageFor(ConditionField.start),
onTap: () => _pickTime(context, ref),
),
_ConditionRow(
icon: AppIconData.people,
label: l10n.aiFieldParty,
value: l10n.aiPartyCount(conditions.party),
inferred: inferred(ConditionField.party),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
RoundStepButton(
icon: AppIconData.minus,
label: l10n.aiPartyDecrease,
onPressed: conditions.party > kPartyMin
? () => notifier.edit(
(c) => c.copyWith(party: c.party - 1),
{ConditionField.party},
)
: null,
),
RoundStepButton(
icon: AppIconData.plus,
label: l10n.aiPartyIncrease,
onPressed: conditions.party < kPartyMax
? () => notifier.edit(
(c) => c.copyWith(party: c.party + 1),
{ConditionField.party},
)
: null,
),
],
),
),
_ConditionRow(
icon: conditions.pet == null ? AppIconData.paw : AppIconData.pawSolid,
iconColor: conditions.pet == null ? null : AppColors.pet,
label: l10n.aiFieldPet,
value: petLabel(conditions.pet, l10n),
valueColor: conditions.pet == null ? null : AppColors.pet,
inferred: inferred(ConditionField.pet),
onTap: () => _pickPet(context, ref),
),
_MobilityRow(
value: conditions.mobility,
onChanged: (mobility) => notifier.edit(
(c) => c.copyWith(mobility: mobility),
{ConditionField.mobility},
),
),
_IndoorRow(
value: conditions.indoorIfRain,
rainText: weather == null
? null
: l10n.aiRainChance(
formatWeekday(weather.date, localeName: localeName),
weather.precipitationProbability,
),
onChanged: (value) => notifier.edit(
(c) => c.copyWith(indoorIfRain: value),
{ConditionField.indoorIfRain},
),
),
],
);
}
/// 지역 — 동네 셋 또는 현재 위치(대구 안이어야 한다)
Future<void> _pickArea(BuildContext context, WidgetRef ref) async {
final l10n = context.l10n;
final selected = switch (conditions.area) {
NeighborhoodArea(:final neighborhood) => BaseLocationChoice.neighborhood(
neighborhood,
),
PointArea() => const BaseLocationChoice.current(),
PlaceArea() => null,
};
final picked = await showRegionSheet(
context,
selected: selected,
title: l10n.aiAreaSheetTitle,
);
if (picked == null || !context.mounted) return;
final neighborhood = picked.neighborhood;
final CourseArea area;
if (neighborhood != null) {
area = NeighborhoodArea(neighborhood);
} else {
final location = ref.read(locationServiceProvider);
final granted = await location.requestPermission();
final point = granted ? await location.currentPoint() : null;
if (!context.mounted) return;
void tell(String message) => ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
if (point == null) {
tell(l10n.locationUnavailable);
return;
}
// 서버도 대구 밖 현재 위치를 받지 않는다(VAL_001) — 고를 때 막는다
if (!isInServiceArea(point)) {
tell(l10n.aiIssueUnsupportedArea);
return;
}
area = PointArea(lat: point.lat, lng: point.lng);
}
ref.read(aiCourseDraftProvider.notifier).edit(
(c) => c.copyWith(area: area),
{ConditionField.area},
);
}
/// 날짜 — 오늘(한국 날짜)부터 30일 뒤까지
Future<void> _pickDate(BuildContext context, WidgetRef ref) async {
final today = parseYmd(kstClock(ref.read(clockProvider)()).date)!;
final first = DateTime(today.year, today.month, today.day);
final last = first.add(const Duration(days: kDateRangeDays));
final current = parseYmd(conditions.date);
var initial = current == null
? first
: DateTime(current.year, current.month, current.day);
if (initial.isBefore(first)) initial = first;
if (initial.isAfter(last)) initial = last;
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: first,
lastDate: last,
);
if (picked == null) return;
ref.read(aiCourseDraftProvider.notifier).edit(
(c) => c.copyWith(date: toYmd(picked)),
{ConditionField.date},
);
}
Future<void> _pickTime(BuildContext context, WidgetRef ref) async {
final picked = await showTimeSheet(
context,
start: conditions.start,
end: conditions.end,
);
if (picked == null) return;
ref.read(aiCourseDraftProvider.notifier).edit(
(c) => c.copyWith(start: picked.start, end: picked.end),
{ConditionField.start, ConditionField.end},
);
}
Future<void> _pickPet(BuildContext context, WidgetRef ref) async {
final picked = await showPetSheet(context, current: conditions.pet);
if (picked == null) return;
ref.read(aiCourseDraftProvider.notifier).edit(
(c) => c.withPet(picked.pet),
{ConditionField.pet},
);
}
}
/// 줄 아래 알림 — [blocking] 이면 고쳐야 만들 수 있다
typedef _RowMessage = ({String text, bool blocking});
/// 조건 한 줄 — 아이콘 · 이름 · 값 · (자동으로 채웠으면 반짝임) · 오른쪽
class _ConditionRow extends StatelessWidget {
const _ConditionRow({
required this.icon,
required this.label,
required this.value,
this.iconColor,
this.valueColor,
this.inferred = false,
this.message,
this.onTap,
this.trailing,
});
final AppIconData icon;
final String label;
final String value;
/// 없으면 보조 회색
final Color? iconColor;
/// 강조 값(반려동물) — 이 색 굵은 글자. 없으면 본문색 보통 굵기
final Color? valueColor;
final bool inferred;
final _RowMessage? message;
final VoidCallback? onTap;
/// 없으면 누를 수 있는 줄의 화살표
final Widget? trailing;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final message = this.message;
final row = ConstrainedBox(
constraints: const BoxConstraints(minHeight: 52),
child: Row(
children: [
AppIcon(icon, size: 20, color: iconColor ?? AppColors.textTertiary),
const SizedBox(width: 10),
SizedBox(
width: 72,
child: Text(
label,
style: textTheme.bodyMedium?.copyWith(fontSize: 14),
),
),
Expanded(
child: Row(
children: [
Flexible(
child: Text(
value,
style: textTheme.titleSmall?.copyWith(
fontWeight: valueColor == null
? FontWeight.w400
: FontWeight.w600,
color: valueColor,
),
),
),
if (inferred) ...[
const SizedBox(width: 4),
const AppIcon(
AppIconData.sparkle,
size: 14,
color: AppColors.brand,
),
],
],
),
),
trailing ??
(onTap == null
? const SizedBox.shrink()
: const AppIcon(
AppIconData.chevronRight,
size: 16,
color: AppColors.iconMuted,
)),
],
),
);
return Container(
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: AppColors.divider)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (onTap == null) row else InkWell(onTap: onTap, child: row),
if (message != null)
Padding(
padding: const EdgeInsets.only(left: 30, bottom: 10),
child: Text(
keepAll(message.text),
style: textTheme.bodyMedium?.copyWith(
color: message.blocking
? AppColors.danger
: AppColors.textTertiary,
),
),
),
],
),
);
}
}
/// 장소 사이 이동 — 걸어서 · 대중교통 · 자동차
class _MobilityRow extends StatelessWidget {
const _MobilityRow({required this.value, required this.onChanged});
final Mobility value;
final ValueChanged<Mobility> onChanged;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final textTheme = Theme.of(context).textTheme;
return Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: AppColors.divider)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const AppIcon(
AppIconData.walk,
size: 20,
color: AppColors.textTertiary,
),
const SizedBox(width: 10),
Text(
l10n.aiFieldMobility,
style: textTheme.bodyMedium?.copyWith(fontSize: 14),
),
],
),
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: AppColors.inputFill,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
for (final mobility in Mobility.values)
Expanded(
child: _Segment(
label: mobilityLabel(mobility, l10n),
selected: mobility == value,
onTap: () => onChanged(mobility),
),
),
],
),
),
],
),
);
}
}
class _Segment extends StatelessWidget {
const _Segment({
required this.label,
required this.selected,
required this.onTap,
});
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
inMutuallyExclusiveGroup: true,
checked: selected,
button: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? AppColors.background : Colors.transparent,
borderRadius: BorderRadius.circular(8),
boxShadow: selected
? const [
BoxShadow(
color: AppColors.raisedShadow,
blurRadius: 2,
offset: Offset(0, 1),
),
]
: null,
),
child: Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
color: selected ? AppColors.text : AppColors.textTertiary,
),
),
),
),
);
}
}
/// 비가 오면 실내 위주로 — 그날 예보가 있으면 강수확률을 함께 보인다
class _IndoorRow extends StatelessWidget {
const _IndoorRow({
required this.value,
required this.rainText,
required this.onChanged,
});
final bool value;
final String? rainText;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final textTheme = Theme.of(context).textTheme;
final rainText = this.rainText;
return ConstrainedBox(
constraints: const BoxConstraints(minHeight: 64),
child: Row(
children: [
const AppIcon(
AppIconData.umbrella,
size: 20,
color: AppColors.textTertiary,
),
const SizedBox(width: 10),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.aiIndoorIfRain,
style: textTheme.bodyLarge?.copyWith(
height: 21 / 15,
color: AppColors.text,
),
),
if (rainText != null) ...[
const SizedBox(height: 2),
Text(
rainText,
style: textTheme.bodyMedium?.copyWith(
height: 18 / 13,
color: AppColors.rain,
fontWeight: FontWeight.w600,
),
),
],
],
),
),
AppSwitch(
value: value,
onChanged: onChanged,
semanticLabel: l10n.aiIndoorIfRain,
),
],
),
);
}
}
@@ -0,0 +1,361 @@
import 'package:flutter/material.dart';
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/features/ai_course/data/models/course_conditions.dart';
import 'package:wandergo/features/ai_course/domain/condition_rules.dart';
import 'package:wandergo/features/ai_course/presentation/widgets/condition_labels.dart';
import 'package:wandergo/features/place/data/models/place_models.dart'
show PetSize;
const int _kMinutesPerDay = 1440;
/// 시각을 읽지 못했을 때 시작(13:00 — 백엔드 기본값과 같다)
const int _kFallbackStartMinutes = 13 * 60;
/// 시간 고르기 — 시작 · 끝을 30분씩 옮긴다(2~12시간, 자정까지). 고르지 않고 닫으면 null
Future<({String start, String end})?> showTimeSheet(
BuildContext context, {
required String start,
required String end,
}) {
return showModalBottomSheet<({String start, String end})>(
context: context,
isScrollControlled: true,
builder: (context) => _TimeSheet(start: start, end: end),
);
}
class _TimeSheet extends StatefulWidget {
const _TimeSheet({required this.start, required this.end});
final String start;
final String end;
@override
State<_TimeSheet> createState() => _TimeSheetState();
}
class _TimeSheetState extends State<_TimeSheet> {
late int _start;
late int _end;
@override
void initState() {
super.initState();
_start = parseHhmm(widget.start) ?? _kFallbackStartMinutes;
_end = parseHhmm(widget.end) ?? _start + kWindowMinMinutes;
}
ConditionProblem? get _problem {
final length = _end - _start;
if (length < kWindowMinMinutes) return ConditionProblem.tooShort;
if (length > kWindowMaxMinutes) return ConditionProblem.tooLong;
return null;
}
void _moveStart(int delta) {
final next = _start + delta;
if (next < 0 || next > _kMinutesPerDay - kWindowMinMinutes) return;
setState(() {
_start = next;
// 끝을 함께 밀어 2시간을 지킨다(자정을 넘기지는 않는다)
if (_end - _start < kWindowMinMinutes) _end = _start + kWindowMinMinutes;
});
}
void _moveEnd(int delta) {
final next = _end + delta;
if (next <= _start || next > _kMinutesPerDay) return;
setState(() => _end = next);
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final textTheme = Theme.of(context).textTheme;
final problem = _problem;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(kPagePadding, 0, kPagePadding, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.aiTimeSheetTitle, style: textTheme.titleMedium),
const SizedBox(height: 12),
_Stepper(
label: l10n.aiTimeStart,
value: formatHhmm(_start),
decreaseLabel: l10n.aiTimeEarlier(l10n.aiTimeStart),
increaseLabel: l10n.aiTimeLater(l10n.aiTimeStart),
onDecrease: () => _moveStart(-kTimeStepMinutes),
onIncrease: () => _moveStart(kTimeStepMinutes),
),
const Divider(),
_Stepper(
label: l10n.aiTimeEnd,
value: formatHhmm(_end),
decreaseLabel: l10n.aiTimeEarlier(l10n.aiTimeEnd),
increaseLabel: l10n.aiTimeLater(l10n.aiTimeEnd),
onDecrease: () => _moveEnd(-kTimeStepMinutes),
onIncrease: () => _moveEnd(kTimeStepMinutes),
),
const SizedBox(height: 8),
Text(
problem == null
? durationLabel(_end - _start, l10n)
: problemMessage(problem, l10n),
style: textTheme.bodyMedium?.copyWith(
color: problem == null ? null : AppColors.danger,
),
),
const SizedBox(height: 16),
FilledButton(
onPressed: problem == null
? () => Navigator.of(
context,
).pop((start: formatHhmm(_start), end: formatHhmm(_end)))
: null,
child: Text(l10n.aiApply),
),
],
),
),
);
}
}
/// 이름 · 값 · 줄이기/늘리기 버튼 한 줄
class _Stepper extends StatelessWidget {
const _Stepper({
required this.label,
required this.value,
required this.decreaseLabel,
required this.increaseLabel,
required this.onDecrease,
required this.onIncrease,
});
final String label;
final String value;
final String decreaseLabel;
final String increaseLabel;
final VoidCallback? onDecrease;
final VoidCallback? onIncrease;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return SizedBox(
height: 56,
child: Row(
children: [
Expanded(
child: Text(
label,
style: textTheme.bodyLarge?.copyWith(
color: AppColors.textTertiary,
),
),
),
RoundStepButton(
icon: AppIconData.minus,
label: decreaseLabel,
onPressed: onDecrease,
),
SizedBox(
width: 72,
child: Text(
value,
textAlign: TextAlign.center,
style: textTheme.titleMedium,
),
),
RoundStepButton(
icon: AppIconData.plus,
label: increaseLabel,
onPressed: onIncrease,
),
],
),
);
}
}
/// 둥근 테두리의 줄이기/늘리기 버튼 — 누르는 영역은 44px 이다
class RoundStepButton extends StatelessWidget {
const RoundStepButton({
super.key,
required this.icon,
required this.label,
required this.onPressed,
});
final AppIconData icon;
/// 화면 읽기 프로그램용 이름
final String label;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
final enabled = onPressed != null;
return Semantics(
button: true,
enabled: enabled,
label: label,
excludeSemantics: true,
onTap: onPressed,
child: InkResponse(
onTap: onPressed,
radius: 22,
child: SizedBox(
width: 44,
height: 44,
child: Center(
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: AppColors.chipBorder),
),
child: Center(
child: AppIcon(
icon,
size: 16,
color: enabled ? AppColors.text : AppColors.chipBorder,
),
),
),
),
),
),
);
}
}
/// 반려동물 고르기 결과 — [pet] 이 null 이면 '함께 안 가요'
typedef PetChoice = ({PetCondition? pet});
/// 반려동물 고르기. 고르지 않고 닫으면 null
Future<PetChoice?> showPetSheet(BuildContext context, {PetCondition? current}) {
return showModalBottomSheet<PetChoice>(
context: context,
isScrollControlled: true,
builder: (context) => _PetSheet(current: current),
);
}
class _PetSheet extends StatefulWidget {
const _PetSheet({required this.current});
final PetCondition? current;
@override
State<_PetSheet> createState() => _PetSheetState();
}
class _PetSheetState extends State<_PetSheet> {
PetKind? _kind;
PetSize? _size;
int _count = 1;
@override
void initState() {
super.initState();
final pet = widget.current;
_kind = pet?.kind;
_size = pet?.size;
_count = pet?.count ?? 1;
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final textTheme = Theme.of(context).textTheme;
final kind = _kind;
final kinds = <(PetKind?, String)>[
(null, l10n.aiPetKindNone),
(PetKind.dog, l10n.aiPetKindDog),
(PetKind.cat, l10n.aiPetKindCat),
(PetKind.other, l10n.aiPetKindOther),
];
final sizes = <(PetSize?, String)>[
(PetSize.small, l10n.petSizeSmall),
(PetSize.medium, l10n.petSizeMedium),
(PetSize.large, l10n.petSizeLarge),
(null, l10n.aiPetSizeUnknown),
];
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(kPagePadding, 0, kPagePadding, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.aiPetSheetTitle, style: textTheme.titleMedium),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final (value, label) in kinds)
ChoiceChip(
label: Text(label),
selected: kind == value,
onSelected: (_) => setState(() {
_kind = value;
if (value != PetKind.dog) _size = null;
}),
),
],
),
if (kind == PetKind.dog) ...[
const SizedBox(height: 20),
Text(l10n.aiPetSize, style: textTheme.bodyMedium),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final (value, label) in sizes)
ChoiceChip(
label: Text(label),
selected: _size == value,
onSelected: (_) => setState(() => _size = value),
),
],
),
],
if (kind != null) ...[
const SizedBox(height: 12),
_Stepper(
label: l10n.aiPetCount,
value: '$_count',
decreaseLabel: l10n.aiPetCountDecrease,
increaseLabel: l10n.aiPetCountIncrease,
onDecrease: _count > 1 ? () => setState(() => _count--) : null,
onIncrease: _count < kPetCountMax
? () => setState(() => _count++)
: null,
),
],
const SizedBox(height: 16),
FilledButton(
onPressed: () => Navigator.of(context).pop((
pet: kind == null
? null
: PetCondition(kind: kind, size: _size, count: _count),
)),
child: Text(l10n.aiApply),
),
],
),
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,391 @@
import 'package:flutter/material.dart';
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/utils/format.dart';
import 'package:wandergo/core/utils/keep_all.dart';
import 'package:wandergo/features/ai_course/data/models/course_conditions.dart';
import 'package:wandergo/features/ai_course/data/models/course_generation.dart';
import 'package:wandergo/features/ai_course/domain/condition_rules.dart';
import 'package:wandergo/features/event/data/models/event_models.dart';
import 'package:wandergo/features/event/presentation/widgets/event_visuals.dart';
import 'package:wandergo/features/place/data/models/place_models.dart';
import 'package:wandergo/features/place/presentation/widgets/place_visuals.dart';
/// 장소 분류 이름 — 행사는 행사 종류(공연 · 축제 · 전시 · 행사), 장소는 장소 분류
String stopCategoryLabel(CourseStop stop, AppLocalizations l10n) {
if (stop.kind == StopKind.event) {
return (EventKind.parse(stop.eventKind) ?? EventKind.event).label(l10n);
}
return PlaceCategory.parse(stop.category)?.label(l10n) ??
l10n.placeCategoryOther;
}
/// 반려 동반 글자 — "반려견 동반 · 소형견" · "일부 구역 동반". 확인하지 못했으면 null
String? stopPetLabel(CourseStop stop, AppLocalizations l10n) {
final label = petLabel(l10n, stop.facts.petPolicy);
if (label == null) return null;
final sizes = stop.facts.petSizes ?? const <PetSize>[];
if (sizes.isEmpty) return label;
return '$label · ${sizes.map((s) => s.label(l10n)).join('·')}';
}
/// 사실 줄 — "영업 11:00–19:00 · 10.2 확인 · 출처: 한국관광공사"
String stopFactsText(
CourseStop stop,
AppLocalizations l10n, {
required String localeName,
}) {
final facts = stop.facts;
final hours = facts.hoursOnDate;
final period = facts.eventPeriod;
final verifiedAt = facts.verifiedAt;
return [
if (period != null)
l10n.aiEventPeriod(
formatDateRange(period.start, period.end, localeName: localeName),
)
else if (hours != null && hours.isNotEmpty)
l10n.aiHours(
hours.map((h) => formatTimeRange(h.start, h.end)).join(', '),
),
if (verifiedAt != null)
l10n.placeVerifiedOn(
formatDateRange(
toYmd(verifiedAt.toUtc()),
toYmd(verifiedAt.toUtc()),
localeName: localeName,
),
),
if (facts.provider.isNotEmpty) l10n.sourceLabel(facts.provider),
].join(' · ');
}
String warningText(StopWarning warning, AppLocalizations l10n) =>
switch (warning) {
StopWarning.hoursUnknown => l10n.aiWarnHoursUnknown,
StopWarning.eventTimeUnknown => l10n.aiWarnEventTimeUnknown,
StopWarning.petUnknown => l10n.aiWarnPetUnknown,
StopWarning.eventLastDay => l10n.aiWarnEventLastDay,
};
/// 이동 — "걸어서 6분 · 450m" · "대중교통 약 12분 · 3.1km" · "차로 약 8분 · 4.2km"
String legText(CourseLeg leg, AppLocalizations l10n) {
final distance = formatDistance(leg.meters);
return switch (leg.mode) {
Mobility.walk => l10n.aiLegWalk(leg.minutes, distance),
Mobility.transit => l10n.aiLegTransit(leg.minutes, distance),
Mobility.car => l10n.aiLegCar(leg.minutes, distance),
};
}
/// 장소 사진 — 사진이 없으면 분류별 자리 표시. 공공누리 Type3 은 자르지 않는다
class CourseStopImage extends StatelessWidget {
const CourseStopImage({
super.key,
required this.stop,
required this.width,
required this.height,
});
final CourseStop stop;
final double width;
final double height;
@override
Widget build(BuildContext context) {
final photo = stop.photo;
if (stop.kind == StopKind.event) {
return EventImage(
url: photo?.thumbUrl,
kind: EventKind.parse(stop.eventKind),
license: photo?.license,
width: width,
height: height,
);
}
return PlaceImage(
url: photo?.thumbUrl,
category: PlaceCategory.parse(stop.category),
license: photo?.license,
width: width,
height: height,
iconSize: 24,
);
}
}
/// 코스 순서의 한 칸 — 번호 원과 이어지는 선(타임라인) · 시각 · 이름 · 분류 · 사진 · 이유 · 경고 · 사실 · 다음 이동
class CourseStopTile extends StatelessWidget {
const CourseStopTile({
super.key,
required this.number,
required this.stop,
required this.isLast,
required this.showPet,
required this.onOpen,
});
final int number;
final CourseStop stop;
final bool isLast;
/// 반려동물과 함께 가는 코스면 동반 정보를 보인다
final bool showPet;
final VoidCallback onOpen;
/// 번호 원 지름
static const double _dot = 28;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final textTheme = Theme.of(context).textTheme;
final meta = textTheme.bodyMedium?.copyWith(height: 18 / 13);
final small = textTheme.bodySmall?.copyWith(height: 17 / 12);
final facts = stopFactsText(stop, l10n, localeName: l10n.localeName);
final leg = stop.legToNext;
final warnings = [
for (final warning in stop.warnings)
if (warning != StopWarning.petUnknown || showPet) warning,
];
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 번호 원과 다음 장소로 이어지는 선
SizedBox(
width: _dot,
child: Column(
children: [
Container(
width: _dot,
height: _dot,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: AppColors.brand,
shape: BoxShape.circle,
),
child: Text(
'$number',
style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
if (!isLast)
Expanded(
child: Container(
width: 2,
margin: const EdgeInsets.only(top: 4),
color: AppColors.chipBorderSelected,
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Heading(stop: stop, showPet: showPet, onOpen: onOpen),
if (stop.reason.isNotEmpty) ...[
const SizedBox(height: 10),
Text.rich(
TextSpan(
children: [
const WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Padding(
padding: EdgeInsets.only(right: 4),
child: AppIcon(
AppIconData.sparkleSolid,
size: 13,
color: AppColors.brand,
),
),
),
TextSpan(text: keepAll(stop.reason)),
],
),
style: meta?.copyWith(
height: 20 / 13,
color: AppColors.label,
),
),
],
for (final warning in warnings)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(top: 1.5),
child: AppIcon(
AppIconData.info,
size: 14,
color: AppColors.textSecondary,
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
keepAll(warningText(warning, l10n)),
style: small?.copyWith(
color: AppColors.textSecondary,
),
),
),
],
),
),
if (facts.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
keepAll(facts),
style: small?.copyWith(color: AppColors.textHint),
),
),
if (leg != null)
Padding(
padding: const EdgeInsets.only(top: 14, bottom: 18),
child: Row(
children: [
switch (leg.mode) {
Mobility.walk => const AppIcon(
AppIconData.walk,
size: 16,
color: AppColors.textTertiary,
),
Mobility.transit => const Icon(
Icons.directions_bus_outlined,
size: 16,
color: AppColors.textTertiary,
),
Mobility.car => const Icon(
Icons.directions_car_outlined,
size: 16,
color: AppColors.textTertiary,
),
},
const SizedBox(width: 6),
Expanded(
child: Text(legText(leg, l10n), style: meta),
),
],
),
)
else if (!isLast)
const SizedBox(height: 18),
],
),
),
),
],
),
);
}
}
/// 시각 · 이름 · 분류 줄과 오른쪽 사진 — 누르면 장소 · 행사 상세로 간다
class _Heading extends StatelessWidget {
const _Heading({
required this.stop,
required this.showPet,
required this.onOpen,
});
final CourseStop stop;
final bool showPet;
final VoidCallback onOpen;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final textTheme = Theme.of(context).textTheme;
final meta = textTheme.bodyMedium?.copyWith(height: 18 / 13);
final setting = switch (stop.facts.setting) {
StopSetting.indoor => l10n.placeSettingIndoor,
StopSetting.outdoor => l10n.placeSettingOutdoor,
StopSetting.unknown => null,
};
final pet = showPet ? stopPetLabel(stop, l10n) : null;
final period = stop.facts.eventPeriod;
// 행사는 "축제 10.31–11.2"처럼 종류와 기간을 굵은 날짜색으로 앞에 둔다
final InlineSpan lead = stop.kind == StopKind.event
? TextSpan(
text: [
stopCategoryLabel(stop, l10n),
if (period != null)
formatDateRange(
period.start,
period.end,
localeName: l10n.localeName,
),
].join(' '),
style: const TextStyle(
fontWeight: FontWeight.w600,
color: AppColors.eventDate,
),
)
: TextSpan(text: stopCategoryLabel(stop, l10n));
return InkWell(
onTap: onOpen,
borderRadius: BorderRadius.circular(kRadius),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.aiStopTime(
formatKstTime(stop.arrive),
formatKstTime(stop.leave),
stop.minutes,
),
style: meta,
),
const SizedBox(height: 4),
Text(
keepAll(stop.name),
style: textTheme.titleSmall?.copyWith(
fontSize: 16,
height: 22 / 16,
),
),
const SizedBox(height: 4),
Text.rich(
TextSpan(
children: [
lead,
if (setting != null) TextSpan(text: ' · $setting'),
if (pet != null) ...[
const TextSpan(text: ' · '),
petSpan(pet),
],
],
),
style: meta?.copyWith(color: AppColors.textSecondary),
),
],
),
),
const SizedBox(width: 12),
CourseStopImage(stop: stop, width: 64, height: 64),
],
),
);
}
}
@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:wandergo/core/api/error_lexicon.dart';
import 'package:wandergo/core/l10n/l10n.dart';
import 'package:wandergo/core/theme/app_theme.dart';
import 'package:wandergo/core/utils/keep_all.dart';
import 'package:wandergo/shared/widgets/character_image.dart';
/// 하루 횟수를 다 썼다(AI_001) — 그림 · 사전 문구 · 다시 채워지는 때(시안 공통 상태 3).
/// 디자인의 '로그인하기' · '직접 코스 짜기'는 그 기능이 생기면 붙인다
Future<void> showQuotaExhaustedSheet(BuildContext context) {
return showModalBottomSheet<void>(
context: context,
builder: (context) {
final l10n = context.l10n;
final textTheme = Theme.of(context).textTheme;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(kPagePadding, 0, kPagePadding, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CharacterImage(width: 110, height: 80),
const SizedBox(height: 16),
Text(
keepAll(ErrorLexicon.getMessage('AI_001')),
textAlign: TextAlign.center,
style: textTheme.titleSmall?.copyWith(height: 23 / 15),
),
const SizedBox(height: 6),
Text(
keepAll(l10n.aiQuotaExhaustedHint),
textAlign: TextAlign.center,
style: textTheme.bodyMedium?.copyWith(
fontSize: 14,
height: 21 / 14,
),
),
const SizedBox(height: 20),
FilledButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.aiConfirm),
),
],
),
),
);
},
);
}
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:wandergo/core/l10n/l10n.dart';
import 'package:wandergo/core/theme/app_theme.dart';
import 'package:wandergo/core/utils/keep_all.dart';
/// '다시 만들기' 확인 — 같은 조건으로 다른 곳을 골라 새로 만든다. 하루 횟수가 차감되므로 한 번 묻는다
Future<bool> showRegenerateSheet(BuildContext context, {int? remaining}) async {
final confirmed = await showModalBottomSheet<bool>(
context: context,
builder: (context) {
final l10n = context.l10n;
final textTheme = Theme.of(context).textTheme;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(kPagePadding, 0, kPagePadding, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(l10n.aiRegenerateTitle, style: textTheme.titleMedium),
const SizedBox(height: 8),
Text(
keepAll(
remaining == null
? l10n.aiRegenerateBodyNoCount
: l10n.aiRegenerateBody(remaining),
),
style: textTheme.bodyLarge,
),
const SizedBox(height: 20),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(l10n.aiRegenerate),
),
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(l10n.aiCancel),
),
],
),
),
);
},
);
return confirmed ?? false;
}
@@ -8,6 +8,8 @@ import 'package:wandergo/core/router/app_routes.dart';
import 'package:wandergo/core/theme/app_colors.dart';
import 'package:wandergo/core/theme/app_theme.dart';
import 'package:wandergo/core/utils/keep_all.dart';
import 'package:wandergo/features/ai_course/presentation/providers/ai_course_providers.dart';
import 'package:wandergo/features/ai_course/presentation/widgets/ai_home_section.dart';
import 'package:wandergo/features/event/presentation/providers/event_providers.dart';
import 'package:wandergo/features/event/presentation/widgets/event_card.dart';
import 'package:wandergo/features/home/presentation/widgets/category_grid.dart';
@@ -21,8 +23,8 @@ import 'package:wandergo/shared/widgets/language_button.dart';
import 'package:wandergo/shared/widgets/load_error.dart';
import 'package:wandergo/shared/widgets/section_header.dart';
/// 3 홈 — 기준 위치 · 제목 · 검색창 · 카테고리 · 이번 주말 공연·행사 · 가까운 반려동물 동반 장소 · 출처.
/// 배너·AI·추천 코스·지역 바로가기·로그인 버튼은 해당 기능을 만들 때 붙인다.
/// 3 홈 — 기준 위치 · 제목 · 검색창 · 카테고리 · AI에게 코스 맡기기 · 이번 주말 공연·행사 · 가까운 반려동물 동반 장소 · 출처.
/// 배너·추천 코스·지역 바로가기·로그인 버튼은 해당 기능을 만들 때 붙인다.
class HomePage extends ConsumerWidget {
const HomePage({super.key});
@@ -30,7 +32,8 @@ class HomePage extends ConsumerWidget {
// 현재 위치를 쓰는 경우 위치도 다시 받는다
ref
..invalidate(basePointProvider)
..invalidate(homePetPlacesProvider);
..invalidate(homePetPlacesProvider)
..invalidate(aiQuotaProvider);
await Future.wait([
ref.read(homeEventsProvider.future),
ref.read(homePetPlacesProvider.future),
@@ -82,6 +85,8 @@ class HomePage extends ConsumerWidget {
const HomeSearchBar(),
const SizedBox(height: 28),
const CategoryGrid(),
const SizedBox(height: 28),
const AiHomeSection(),
],
),
),
@@ -33,11 +33,15 @@ Future<BaseLocationChoice?> showRegionSheet(
BuildContext context, {
BaseLocationChoice? selected,
bool includeCurrent = true,
String? title,
}) {
return showModalBottomSheet<BaseLocationChoice>(
context: context,
builder: (context) =>
_RegionSheet(selected: selected, includeCurrent: includeCurrent),
builder: (context) => _RegionSheet(
selected: selected,
includeCurrent: includeCurrent,
title: title,
),
);
}
@@ -62,11 +66,18 @@ Future<void> applyBaseLocation(
}
class _RegionSheet extends StatelessWidget {
const _RegionSheet({required this.selected, required this.includeCurrent});
const _RegionSheet({
required this.selected,
required this.includeCurrent,
this.title,
});
final BaseLocationChoice? selected;
final bool includeCurrent;
/// 없으면 "어디를 기준으로 볼까요?"
final String? title;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
@@ -107,7 +118,7 @@ class _RegionSheet extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.regionSheetTitle,
title ?? l10n.regionSheetTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
+148 -1
View File
@@ -32,6 +32,8 @@
"homeEventsEmpty": "No shows or events nearby yet.",
"seeAll": "See all",
"retry": "Try again",
"errorCodeLine": "Error code {code}",
"errorCodeTraceLine": "Error code {code} · Trace ID {traceId}",
"loadFailed": "Couldn't load this.",
"eventKindFestival": "Festival",
"eventKindPerformance": "Performance",
@@ -140,5 +142,150 @@
"nearbyPlaces": "Good to go with",
"photoCredit": "Photo: {provider} · {license}",
"koglType1": "KOGL Type 1",
"koglType3": "KOGL Type 3"
"koglType3": "KOGL Type 3",
"aiHomeTitle": "Let AI plan your day",
"aiHomeBody": "Just tell us what you want — we check opening hours, distance and pet rules for you.",
"aiHomeQuota": "{count} a day, no sign-in needed",
"aiHomePlaceholder": "e.g. Saturday afternoon, cafés and a walk near Suseongmot with a friend",
"aiInputTitle": "Plan with AI",
"aiQuotaRemaining": "{count} left today",
"aiQuotaNone": "None left today",
"aiAskTitle": "What kind of day\ndo you want?",
"aiRequestHint": "When, who with, and what you'd like to do",
"aiExamplesTitle": "Try something like",
"aiExample1": "Café hopping in Apsan with a friend, then a walk until sunset",
"aiExample2": "A quiet solo day at downtown galleries, then dinner",
"aiConditionsTitle": "Details",
"aiConditionsAutoFilled": "Filled in from what you wrote. Tap to change.",
"aiConditionsEmpty": "Write a sentence and we'll fill in the date, time and group.",
"aiParsing": "Reading your request…",
"aiFieldArea": "Area",
"aiFieldDate": "Date",
"aiFieldTime": "Time",
"aiFieldParty": "People",
"aiFieldPet": "Pets",
"aiFieldMobility": "Getting around",
"aiAreaSheetTitle": "Where do you want to go?",
"aiPlaceNear": "Near {name}",
"aiPlaceNearUnknown": "Near the chosen place",
"aiPartyCount": "{count, plural, =1{1 person} other{{count} people}}",
"aiPartyDecrease": "Fewer people",
"aiPartyIncrease": "More people",
"aiPetNone": "No pets",
"aiPetSummary": "{count, plural, =1{1 {label}} other{{count} {label}s}}",
"aiPetSmallDog": "small dog",
"aiPetMediumDog": "medium dog",
"aiPetLargeDog": "large dog",
"aiPetDog": "dog",
"aiPetCat": "cat",
"aiPetOther": "pet",
"aiPetSheetTitle": "Who's coming along?",
"aiPetKindNone": "No pets",
"aiPetKindDog": "Dog",
"aiPetKindCat": "Cat",
"aiPetKindOther": "Other",
"aiPetSize": "Size",
"aiPetSizeUnknown": "Not sure",
"aiPetCount": "How many",
"aiPetCountDecrease": "Fewer pets",
"aiPetCountIncrease": "More pets",
"aiMobilityWalk": "Walk",
"aiMobilityTransit": "Transit",
"aiMobilityCar": "Car",
"aiIndoorIfRain": "Mostly indoors if it rains",
"aiRainChance": "{day}: {percent}% chance of rain",
"aiTimeSheetTitle": "From when to when?",
"aiTimeStart": "Start",
"aiTimeEnd": "End",
"aiTimeEarlier": "{label} 30 minutes earlier",
"aiTimeLater": "{label} 30 minutes later",
"aiDurationHours": "{hours, plural, =1{1 hour} other{{hours} hours}}",
"aiDurationHoursMinutes": "{hours} h {minutes} min",
"aiApply": "Apply",
"aiProblemPastDate": "That date has passed. Pick another date.",
"aiProblemDateOutOfRange": "You can plan up to 30 days ahead.",
"aiProblemInvalidTime": "Pick the time again.",
"aiProblemTooShort": "Make it at least 2 hours.",
"aiProblemTooLong": "Keep it within 12 hours.",
"aiProblemPastTime": "That time has already passed. Pick another time.",
"aiIssueUnsupportedArea": "Only Daegu is available for now. Pick an area in Daegu.",
"aiIssueUnknownPlace": "We couldn't find that place, so we used your base area.",
"aiIssuePastDate": "That date has passed. Pick another date.",
"aiIssueDateOutOfRange": "That's more than 30 days away. Pick another date.",
"aiIssueMultiDay": "We can only plan one day at a time. Pick a single date.",
"aiIssueTooShort": "That's shorter than 2 hours. Add more time.",
"aiIssuePastMidnight": "Pick a time that ends by midnight.",
"aiIssueUnclear": "Tell us a bit more about the day you want.",
"aiCreateNote": "We pick from public data and show why we chose each place.",
"aiCreate": "Plan my course",
"aiQuotaExhaustedHint": "Your count resets at midnight Korea time.",
"aiConfirm": "OK",
"aiPendingTitle": "You have a course in progress",
"aiPendingOpen": "View",
"aiLoadingTitle": "Planning your course",
"aiLoadingSubtitle": "This usually takes 10–20 seconds",
"aiProgressLabel": "Progress",
"aiStepSearching": "Looking for places nearby",
"aiStepFound": "Found {count} places nearby",
"aiStepFoundNear": "Found {count} places near {area}",
"aiStepFoundAt": "Found {count} places · {area}",
"aiStepFiltering": "Narrowing down to what fits",
"aiStepKept": "Narrowed down to {count} places that fit",
"aiStepKeptOpen": "Narrowed down to {count} places open on {day}",
"aiStepKeptOpenPet": "Narrowed down to {count} {pet}-friendly places open on {day}",
"aiKeptPetDog": "dog",
"aiKeptPetAny": "pet",
"aiStepPlanning": "Building a schedule around weather and travel",
"aiStepFinishing": "Writing up why we chose each place",
"aiCancel": "Cancel",
"aiClose": "Close",
"aiFailedTitle": "We couldn't plan this course",
"aiEditConditions": "Edit details",
"aiCanceledTitle": "Course planning canceled",
"aiResultHeading": "AI-planned course",
"aiStopTime": "{start}–{end} · {minutes} min",
"aiMinutes": "{minutes} min",
"aiResultRequestTitle": "Your request",
"aiResultIndoor": "Mostly indoors",
"aiStatStops": "Stops",
"aiStatStopsValue": "{count}",
"aiStatTotal": "Total time",
"aiStatTravelWalk": "Walking",
"aiStatTravelTransit": "Transit",
"aiStatTravelCar": "Driving",
"aiStatPet": "Pet-friendly",
"aiStatPetDog": "Dog-friendly",
"aiOrderTitle": "Your route",
"aiOrderTimes": "{start} start · {end} finish",
"aiHours": "Open {hours}",
"aiEventPeriod": "Runs {period}",
"aiWarnHoursUnknown": "No opening hours on record. Check before you go.",
"aiWarnEventTimeUnknown": "No event times on record. Check before you go.",
"aiWarnPetUnknown": "No pet info on record. Follow the on-site rules.",
"aiWarnEventLastDay": "This is the last day of the event.",
"aiLegWalk": "{minutes} min walk · {distance}",
"aiLegTransit": "About {minutes} min by transit · {distance}",
"aiLegCar": "About {minutes} min by car · {distance}",
"aiAlternatesTitle": "Swap in a different place",
"aiAlternatesBody": "Tap to swap a stop for one at the same time. One swap at a time, and it doesn't count toward your daily limit.",
"aiAlternateFor": "Instead of #{number} · {category}",
"aiAlternateRevert": "Put #{number} back",
"aiRatingTitle": "Do you like this course?",
"aiRatingLike": "Like",
"aiRatingDislike": "Not quite",
"aiRatingThanks": "Thanks for the feedback",
"aiGeneratedNote": "AI planned this course from public data. Check opening hours and pet rules again before you go.",
"aiRegenerate": "Plan again",
"aiRegenerateTitle": "Plan again?",
"aiRegenerateBody": "We'll pick different places with the same details. {count} left today.",
"aiRegenerateBodyNoCount": "We'll pick different places with the same details. This uses one of today's plans.",
"aiPlanStopRemoved": "{name} was left out: {cause}.",
"aiOneStop": "One stop",
"aiCauseClosed": "it's closed that day",
"aiCauseLastOrder": "last orders have passed",
"aiCauseWindow": "it doesn't fit the time",
"aiCauseLegTooLong": "it's too far from the next stop",
"aiCausePet": "pets aren't allowed",
"aiCauseOther": "it didn't fit the details",
"aiPlanIndoorUnmet": "Rain is forecast, but there weren't enough indoor places, so some stops are outdoors."
}
+424 -1
View File
@@ -50,6 +50,25 @@
"homeEventsEmpty": "근처에 예정된 공연·행사가 아직 없어요.",
"seeAll": "전체 보기",
"retry": "다시 시도",
"errorCodeLine": "오류 코드 {code}",
"@errorCodeLine": {
"placeholders": {
"code": {
"type": "String"
}
}
},
"errorCodeTraceLine": "오류 코드 {code} · 추적 ID {traceId}",
"@errorCodeTraceLine": {
"placeholders": {
"code": {
"type": "String"
},
"traceId": {
"type": "String"
}
}
},
"loadFailed": "불러오지 못했어요.",
"eventKindFestival": "축제",
"eventKindPerformance": "공연",
@@ -240,5 +259,409 @@
}
},
"koglType1": "공공누리 제1유형",
"koglType3": "공공누리 제3유형"
"koglType3": "공공누리 제3유형",
"aiHomeTitle": "AI에게 코스 맡기기",
"aiHomeBody": "조건만 말하면 영업시간·거리·반려동물 동반까지 확인해 짜 드려요",
"aiHomeQuota": "로그인 없이도 하루 {count}번",
"@aiHomeQuota": {
"description": "홈 AI 칸 — 비로그인 하루 횟수",
"placeholders": {
"count": {
"type": "int"
}
}
},
"aiHomePlaceholder": "예) 토요일 오후, 친구랑 수성못 근처 카페랑 산책",
"aiInputTitle": "AI 코스 만들기",
"aiQuotaRemaining": "오늘 {count}번 더 만들 수 있어요",
"@aiQuotaRemaining": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"aiQuotaNone": "오늘 만들 수 있는 횟수를 다 썼어요",
"aiAskTitle": "어떤 하루를\n보내고 싶어요?",
"aiRequestHint": "언제, 누구와, 무엇을 하고 싶은지 적어 주세요",
"aiExamplesTitle": "이렇게 써 봐도 좋아요",
"aiExample1": "친구랑 앞산 카페 들렀다가 해 질 무렵까지 걷기",
"aiExample2": "혼자 조용히 도심 미술관 둘러보고 저녁 먹기",
"aiConditionsTitle": "조건",
"aiConditionsAutoFilled": "적은 내용에서 자동으로 채웠어요. 눌러서 고칠 수 있어요.",
"aiConditionsEmpty": "문장을 쓰면 날짜·시간·인원을 채워 드려요.",
"aiParsing": "적은 내용을 읽고 있어요",
"aiFieldArea": "지역",
"aiFieldDate": "날짜",
"aiFieldTime": "시간",
"aiFieldParty": "인원",
"aiFieldPet": "반려동물",
"aiFieldMobility": "장소 사이 이동",
"aiAreaSheetTitle": "어디서 보낼까요?",
"aiPlaceNear": "{name} 근처",
"@aiPlaceNear": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"aiPlaceNearUnknown": "고른 장소 근처",
"aiPartyCount": "{count, plural, other{{count}명}}",
"@aiPartyCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"aiPartyDecrease": "인원 줄이기",
"aiPartyIncrease": "인원 늘리기",
"aiPetNone": "함께 안 가요",
"aiPetSummary": "{count, plural, other{{label} {count}마리}}",
"@aiPetSummary": {
"description": "반려동물 요약 — label 은 aiPetSmallDog 같은 이름",
"placeholders": {
"count": {
"type": "int"
},
"label": {
"type": "String"
}
}
},
"aiPetSmallDog": "소형견",
"aiPetMediumDog": "중형견",
"aiPetLargeDog": "대형견",
"aiPetDog": "강아지",
"aiPetCat": "고양이",
"aiPetOther": "반려동물",
"aiPetSheetTitle": "누구와 함께 가요?",
"aiPetKindNone": "함께 안 가요",
"aiPetKindDog": "강아지",
"aiPetKindCat": "고양이",
"aiPetKindOther": "다른 동물",
"aiPetSize": "크기",
"aiPetSizeUnknown": "잘 몰라요",
"aiPetCount": "마리 수",
"aiPetCountDecrease": "마리 수 줄이기",
"aiPetCountIncrease": "마리 수 늘리기",
"aiMobilityWalk": "걸어서",
"aiMobilityTransit": "대중교통",
"aiMobilityCar": "자동차",
"aiIndoorIfRain": "비가 오면 실내 위주로",
"aiRainChance": "{day} 비 {percent}%",
"@aiRainChance": {
"description": "그날 강수확률 — day 는 요일 이름",
"placeholders": {
"day": {
"type": "String"
},
"percent": {
"type": "int"
}
}
},
"aiTimeSheetTitle": "언제부터 언제까지?",
"aiTimeStart": "시작",
"aiTimeEnd": "끝",
"aiTimeEarlier": "{label} 30분 당기기",
"@aiTimeEarlier": {
"description": "시각 버튼의 화면 읽기 이름 — label 은 시작/끝",
"placeholders": {
"label": {
"type": "String"
}
}
},
"aiTimeLater": "{label} 30분 늦추기",
"@aiTimeLater": {
"placeholders": {
"label": {
"type": "String"
}
}
},
"aiDurationHours": "{hours, plural, other{{hours}시간}}",
"@aiDurationHours": {
"placeholders": {
"hours": {
"type": "int"
}
}
},
"aiDurationHoursMinutes": "{hours}시간 {minutes}분",
"@aiDurationHoursMinutes": {
"placeholders": {
"hours": {
"type": "int"
},
"minutes": {
"type": "int"
}
}
},
"aiApply": "적용",
"aiProblemPastDate": "지난 날짜예요. 날짜를 다시 골라 주세요.",
"aiProblemDateOutOfRange": "오늘부터 30일 안의 날짜만 만들 수 있어요.",
"aiProblemInvalidTime": "시간을 다시 골라 주세요.",
"aiProblemTooShort": "2시간 이상으로 골라 주세요.",
"aiProblemTooLong": "12시간 안으로 골라 주세요.",
"aiProblemPastTime": "이미 지난 시간이에요. 시간을 다시 골라 주세요.",
"aiIssueUnsupportedArea": "지금은 대구만 만들 수 있어요. 대구 안에서 골라 주세요.",
"aiIssueUnknownPlace": "말한 장소를 찾지 못해 기준 위치로 두었어요.",
"aiIssuePastDate": "지난 날짜라 날짜를 다시 골라 주세요.",
"aiIssueDateOutOfRange": "30일보다 먼 날짜라 날짜를 다시 골라 주세요.",
"aiIssueMultiDay": "하루 코스만 만들 수 있어요. 날짜 하루를 골라 주세요.",
"aiIssueTooShort": "2시간보다 짧아요. 시간을 늘려 주세요.",
"aiIssuePastMidnight": "자정을 넘기지 않게 시간을 골라 주세요.",
"aiIssueUnclear": "어떤 하루를 원하는지 조금 더 적어 주세요.",
"aiCreateNote": "공공데이터에 있는 곳 중에서 고르고, 고른 이유를 함께 보여 드려요",
"aiCreate": "코스 만들기",
"aiQuotaExhaustedHint": "횟수는 한국 시간 자정에 다시 채워져요.",
"aiConfirm": "확인",
"aiPendingTitle": "만들던 코스가 있어요",
"aiPendingOpen": "보기",
"aiLoadingTitle": "코스를 짜고 있어요",
"aiLoadingSubtitle": "보통 10~20초 걸려요",
"aiProgressLabel": "진행률",
"aiStepSearching": "근처에서 갈 만한 곳을 찾는 중",
"aiStepFound": "근처에서 {count}곳을 찾았어요",
"@aiStepFound": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"aiStepFoundNear": "{area} 근처에서 {count}곳을 찾았어요",
"@aiStepFoundNear": {
"description": "만드는 중 첫 줄 — area 는 동네 이름·현재 위치(장소 기준은 '○○ 근처'라 aiStepFoundAt)",
"placeholders": {
"area": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"aiStepFoundAt": "{area}에서 {count}곳을 찾았어요",
"@aiStepFoundAt": {
"placeholders": {
"area": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"aiStepFiltering": "조건에 맞는 곳을 추리는 중",
"aiStepKept": "조건에 맞는 {count}곳을 추렸어요",
"@aiStepKept": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"aiStepKeptOpen": "{day} 영업으로 {count}곳을 추렸어요",
"@aiStepKeptOpen": {
"placeholders": {
"day": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"aiStepKeptOpenPet": "{day} 영업·{pet} 동반으로 {count}곳을 추렸어요",
"@aiStepKeptOpenPet": {
"placeholders": {
"day": {
"type": "String"
},
"pet": {
"type": "String"
},
"count": {
"type": "int"
}
}
},
"aiKeptPetDog": "반려견",
"aiKeptPetAny": "반려동물",
"aiStepPlanning": "날씨와 동선에 맞춰 시간표를 짜는 중",
"aiStepFinishing": "고른 이유를 정리할게요",
"aiCancel": "취소",
"aiClose": "닫기",
"aiFailedTitle": "코스를 만들지 못했어요",
"aiEditConditions": "조건 수정",
"aiCanceledTitle": "코스 만들기를 취소했어요",
"aiResultHeading": "AI 추천 코스",
"aiStopTime": "{start}–{end} · {minutes}분",
"@aiStopTime": {
"description": "결과의 장소 시각 — 도착·떠남은 한국 시각 HH:mm",
"placeholders": {
"start": {
"type": "String"
},
"end": {
"type": "String"
},
"minutes": {
"type": "int"
}
}
},
"aiMinutes": "{minutes}분",
"@aiMinutes": {
"placeholders": {
"minutes": {
"type": "int"
}
}
},
"aiResultRequestTitle": "내가 요청한 코스",
"aiResultIndoor": "실내 위주",
"aiStatStops": "장소",
"aiStatStopsValue": "{count}곳",
"@aiStatStopsValue": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"aiStatTotal": "전체 일정",
"aiStatTravelWalk": "걸어서 이동",
"aiStatTravelTransit": "대중교통 이동",
"aiStatTravelCar": "차로 이동",
"aiStatPet": "반려동물 동반",
"aiStatPetDog": "반려견 동반",
"aiOrderTitle": "코스 순서",
"aiOrderTimes": "{start} 출발 · {end} 마침",
"@aiOrderTimes": {
"placeholders": {
"start": {
"type": "String"
},
"end": {
"type": "String"
}
}
},
"aiHours": "영업 {hours}",
"@aiHours": {
"placeholders": {
"hours": {
"type": "String"
}
}
},
"aiEventPeriod": "행사 기간 {period}",
"@aiEventPeriod": {
"placeholders": {
"period": {
"type": "String"
}
}
},
"aiWarnHoursUnknown": "영업시간 정보가 없어요. 방문 전에 확인하세요.",
"aiWarnEventTimeUnknown": "행사 시간 정보가 없어요. 방문 전에 확인하세요.",
"aiWarnPetUnknown": "반려동물 동반 정보가 없어요. 현장 안내를 따라 주세요.",
"aiWarnEventLastDay": "이날이 행사 마지막 날이에요.",
"aiLegWalk": "걸어서 {minutes}분 · {distance}",
"@aiLegWalk": {
"placeholders": {
"minutes": {
"type": "int"
},
"distance": {
"type": "String"
}
}
},
"aiLegTransit": "대중교통 약 {minutes}분 · {distance}",
"@aiLegTransit": {
"placeholders": {
"minutes": {
"type": "int"
},
"distance": {
"type": "String"
}
}
},
"aiLegCar": "차로 약 {minutes}분 · {distance}",
"@aiLegCar": {
"placeholders": {
"minutes": {
"type": "int"
},
"distance": {
"type": "String"
}
}
},
"aiAlternatesTitle": "이런 곳으로 바꿀 수도 있어요",
"aiAlternatesBody": "누르면 같은 시간대의 장소와 바꿔요. 한 번에 한 곳만 바꿀 수 있고, 하루 횟수에는 들지 않아요.",
"aiAlternateFor": "{number}번 대신 · {category}",
"@aiAlternateFor": {
"description": "바꾸기 후보 — number 는 코스 순서 번호(1부터), category 는 분류 이름",
"placeholders": {
"number": {
"type": "int"
},
"category": {
"type": "String"
}
}
},
"aiAlternateRevert": "{number}번 원래 장소로 되돌리기",
"@aiAlternateRevert": {
"placeholders": {
"number": {
"type": "int"
}
}
},
"aiRatingTitle": "이 코스, 마음에 드세요?",
"aiRatingLike": "좋아요",
"aiRatingDislike": "아쉬워요",
"aiRatingThanks": "의견 고마워요",
"aiGeneratedNote": "AI가 공공데이터를 바탕으로 만든 코스예요. 영업시간과 반려동물 동반 조건은 방문 전에 다시 확인하세요.",
"aiRegenerate": "다시 만들기",
"aiRegenerateTitle": "다시 만들까요?",
"aiRegenerateBody": "같은 조건으로 다른 곳을 골라 새 코스를 만들어요. 오늘 {count}번 남아요.",
"@aiRegenerateBody": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"aiRegenerateBodyNoCount": "같은 조건으로 다른 곳을 골라 새 코스를 만들어요. 하루 횟수가 1번 줄어요.",
"aiPlanStopRemoved": "{name}: {cause} 코스에서 뺐어요.",
"@aiPlanStopRemoved": {
"description": "앱이 뺀 장소 알림 — cause 는 aiCause* 문구",
"placeholders": {
"name": {
"type": "String"
},
"cause": {
"type": "String"
}
}
},
"aiOneStop": "한 곳",
"aiCauseClosed": "그날 영업하지 않아",
"aiCauseLastOrder": "마지막 주문 시간이 지나",
"aiCauseWindow": "그 시간에 머물 수 없어",
"aiCauseLegTooLong": "다음 장소와 너무 멀어",
"aiCausePet": "반려동물과 함께 갈 수 없어",
"aiCauseOther": "조건이 맞지 않아",
"aiPlanIndoorUnmet": "비 예보가 있지만 실내 장소가 모자라 실외도 넣었어요."
}
@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:wandergo/core/icons/app_icons.dart';
import 'package:wandergo/core/l10n/l10n.dart';
import 'package:wandergo/core/theme/app_colors.dart';
/// 화면 머리의 뒤로(꺾쇠) · 닫기(X) — 선 아이콘 24, 누르는 칸 44(시안 머리 padding 8 16 8 8)
class AppBackButton extends StatelessWidget {
const AppBackButton({
super.key,
this.icon = AppIconData.chevronLeft,
this.tooltip,
this.onPressed,
});
/// 닫기 화면이면 [AppIconData.close]
final AppIconData icon;
/// 없으면 '뒤로'
final String? tooltip;
/// 없으면 이전 화면으로
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return IconButton(
tooltip: tooltip ?? context.l10n.back,
onPressed: onPressed ?? () => Navigator.of(context).maybePop(),
style: IconButton.styleFrom(
fixedSize: const Size.square(44),
minimumSize: const Size.square(44),
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
icon: AppIcon(icon, color: AppColors.text),
);
}
}
@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:wandergo/core/theme/app_colors.dart';
/// 켜고 끄기 — 시안 트랙 48×28(안쪽 2), 흰 손잡이 24 와 얕은 그림자. 켜면 브랜드색.
/// Material 스위치(52×32)는 크기를 바꿀 수 없어 따로 그린다. 누르는 칸은 48×44
class AppSwitch extends StatelessWidget {
const AppSwitch({
super.key,
required this.value,
required this.onChanged,
required this.semanticLabel,
});
final bool value;
/// null 이면 누를 수 없다(흐리게)
final ValueChanged<bool>? onChanged;
/// 화면 읽기 프로그램이 읽을 이름(옆 글자와 같게)
final String semanticLabel;
static const Duration _duration = Duration(milliseconds: 150);
@override
Widget build(BuildContext context) {
final onChanged = this.onChanged;
final toggle = onChanged == null ? null : () => onChanged(!value);
return Semantics(
toggled: value,
enabled: onChanged != null,
label: semanticLabel,
onTap: toggle,
excludeSemantics: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: toggle,
child: Opacity(
opacity: onChanged == null ? 0.5 : 1,
child: SizedBox(
width: 48,
height: 44,
child: Center(
child: AnimatedContainer(
duration: _duration,
width: 48,
height: 28,
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: value ? AppColors.brand : AppColors.chipBorder,
borderRadius: BorderRadius.circular(14),
),
child: AnimatedAlign(
duration: _duration,
curve: Curves.easeOut,
alignment: value
? Alignment.centerRight
: Alignment.centerLeft,
child: const DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Color(0x33161A23),
offset: Offset(0, 1),
blurRadius: 2,
),
],
),
child: SizedBox.square(dimension: 24),
),
),
),
),
),
),
),
);
}
}
@@ -0,0 +1,23 @@
import 'package:flutter/widgets.dart';
/// 마스코트 그림(족제비) — 디자인 캔버스의 일러스트와 같은 파일
const String kCharacterAsset = 'assets/images/character.png';
/// 마스코트 그림 — 장식이라 화면 읽기에서 뺀다. 비율이 달라도 자르지 않는다
class CharacterImage extends StatelessWidget {
const CharacterImage({super.key, required this.width, required this.height});
final double width;
final double height;
@override
Widget build(BuildContext context) {
return Image.asset(
kCharacterAsset,
width: width,
height: height,
fit: BoxFit.contain,
excludeFromSemantics: true,
);
}
}
+102
View File
@@ -4,8 +4,31 @@ import 'package:flutter/material.dart';
import 'package:wandergo/core/api/api_exception.dart';
import 'package:wandergo/core/api/error_lexicon.dart';
import 'package:wandergo/core/api/error_lexicon.g.dart' show kFallbackErrorCode;
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/utils/keep_all.dart';
/// 실패의 에러 코드 — 화면이 코드로 나눌 때 쓴다(문구로 나누지 않는다). 모르면 null
String? errorCodeOf(Object error) {
if (error is DioException) {
final api = error.error;
if (api is ApiException) return api.code;
}
if (error is ApiException) return error.code;
return null;
}
/// 서버 요청 추적 ID — 문의 대응 때 로그 검색 키. 응답이 없던 실패(연결 끊김)는 null
String? traceIdOf(Object error) {
if (error is DioException) {
final api = error.error;
if (api is ApiException) return api.traceId;
}
if (error is ApiException) return error.traceId;
return null;
}
/// 화면에 보일 실패 문구 — 에러 사전에서만 가져온다(여기서 문구를 만들지 않는다).
/// dio 인터셉터가 실어 준 [ApiException] 이 없으면 SYS_001.
@@ -50,3 +73,82 @@ class LoadError extends StatelessWidget {
);
}
}
/// 화면 내용 전체를 못 불러왔을 때(시안 공통 상태 2) — 그림 타일 · 사전 문구 · 다시 시도 · 오류 코드와 추적 ID.
/// 일부만 실패한 칸(홈 섹션 · 목록 끝)은 한 줄짜리 [LoadError] 를 쓴다
class LoadErrorBlock extends StatelessWidget {
const LoadErrorBlock({super.key, required this.error, required this.onRetry});
final Object error;
final VoidCallback onRetry;
/// 추적 ID 는 앞 8자만 보인다(문의 때 로그 검색에 충분)
static const int _traceLength = 8;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final textTheme = Theme.of(context).textTheme;
final code = errorCodeOf(error) ?? kFallbackErrorCode;
final trace = traceIdOf(error);
return Padding(
padding: const EdgeInsets.fromLTRB(28, 40, 28, 44),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppColors.placeholder,
borderRadius: BorderRadius.circular(20),
),
alignment: Alignment.center,
child: const AppIcon(
AppIconData.cloudOff,
size: 30,
strokeWidth: 1.75,
color: AppColors.iconMuted,
),
),
const SizedBox(height: 16),
Text(
keepAll(errorMessageOf(error)),
textAlign: TextAlign.center,
style: textTheme.titleSmall?.copyWith(height: 23 / 15),
),
const SizedBox(height: 20),
OutlinedButton(
onPressed: onRetry,
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 44),
padding: const EdgeInsets.symmetric(horizontal: 18),
side: const BorderSide(color: AppColors.chipBorder),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(kRadius),
),
textStyle: textTheme.labelLarge?.copyWith(fontSize: 15),
),
child: Text(l10n.retry),
),
const SizedBox(height: 14),
Text(
trace == null || trace.isEmpty
? l10n.errorCodeLine(code)
: l10n.errorCodeTraceLine(
code,
trace.length > _traceLength
? trace.substring(0, _traceLength)
: trace,
),
textAlign: TextAlign.center,
style: textTheme.bodySmall?.copyWith(
height: 17 / 12,
color: AppColors.textHint,
),
),
],
),
);
}
}
+50
View File
@@ -0,0 +1,50 @@
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';
import 'package:wandergo/core/device/device_id.dart';
import 'package:wandergo/core/utils/uuid.dart';
final RegExp _uuidV4 = RegExp(
r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
);
void main() {
group('uuidV4', () {
test('버전 4 · RFC 4122 변형 모양이다', () {
for (var seed = 0; seed < 50; seed++) {
expect(uuidV4(Random(seed)), matches(_uuidV4));
}
});
test('같은 씨앗이면 같은 값, 다른 씨앗이면 다른 값', () {
expect(uuidV4(Random(1)), uuidV4(Random(1)));
expect(uuidV4(Random(1)), isNot(uuidV4(Random(2))));
});
});
group('loadOrCreateDeviceId', () {
test('저장된 값이 있으면 그대로 쓰고 새로 만들지 않는다', () async {
final written = <String>[];
final id = await loadOrCreateDeviceId(
read: () async => ' saved-id ',
write: (id) async => written.add(id),
create: () => fail('만들면 안 된다'),
);
expect(id, 'saved-id');
expect(written, isEmpty);
});
test('없거나 비어 있으면 만들어 저장한다', () async {
for (final saved in [null, '', ' ']) {
final written = <String>[];
final id = await loadOrCreateDeviceId(
read: () async => saved,
write: (id) async => written.add(id),
create: () => 'new-id',
);
expect(id, 'new-id');
expect(written, ['new-id']);
}
});
});
}
@@ -0,0 +1,180 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:wandergo/features/ai_course/data/models/course_conditions.dart';
import 'package:wandergo/features/ai_course/data/models/course_generation.dart';
import 'package:wandergo/features/ai_course/domain/ai_course_draft.dart';
import 'package:wandergo/features/ai_course/domain/condition_rules.dart';
import 'package:wandergo/features/ai_course/domain/generation_steps.dart';
import 'package:wandergo/features/location/domain/base_location.dart';
/// 2026-11-12(목) 한국 12:00
final DateTime _now = DateTime.utc(2026, 11, 12, 3);
CourseConditions _conditions({
String date = '2026-11-14',
String start = '14:00',
String end = '20:00',
int party = 2,
CourseArea area = const NeighborhoodArea(Neighborhood.suseongmot),
}) => CourseConditions(
area: area,
date: date,
start: start,
end: end,
party: party,
pet: null,
mobility: Mobility.walk,
indoorIfRain: true,
);
void main() {
// 앱에서는 flutter_localizations 가 지금 언어의 날짜 표기 데이터를 준비한다
setUpAll(() => initializeDateFormatting('en'));
group('시각 · 날짜', () {
test("'HH:mm' 을 분으로 읽고 다시 쓴다('24:00' 까지)", () {
expect(parseHhmm('14:30'), 870);
expect(parseHhmm('24:00'), 1440);
expect(parseHhmm('24:30'), isNull);
expect(parseHhmm('9:00'), isNull);
expect(formatHhmm(870), '14:30');
expect(formatHhmm(1440), '24:00');
});
test('한국 날짜와 그날 지난 분 — 기기 시간대와 상관없다', () {
expect(kstClock(_now), (date: '2026-11-12', minutes: 720));
expect(kstClock(DateTime.utc(2026, 11, 12, 16)).date, '2026-11-13');
expect(formatKstTime(DateTime.utc(2026, 11, 14, 5, 10)), '14:10');
});
test('조건 날짜 표시 — 한국어 "11.14(토)", 영어 "Sat, Nov 14"', () {
expect(formatCourseDate('2026-11-14', localeName: 'ko'), '11.14(토)');
expect(formatCourseDate('2026-11-14', localeName: 'en'), 'Sat, Nov 14');
expect(formatWeekday('2026-11-14', localeName: 'ko'), '토요일');
expect(formatWeekday('2026-11-14', localeName: 'en'), 'Saturday');
});
});
group('conditionsProblem — 백엔드와 같은 기준', () {
test('오늘~30일 · 2~12시간 · 오늘이면 아직 끝나지 않은 시간이면 통과', () {
expect(conditionsProblem(_conditions(), _now), isNull);
expect(
conditionsProblem(
_conditions(date: '2026-11-12', start: '11:00', end: '14:00'),
_now,
),
isNull,
);
expect(conditionsProblem(_conditions(date: '2026-12-12'), _now), isNull);
});
test('날짜 · 시간 문제를 나눈다', () {
expect(
conditionsProblem(_conditions(date: '2026-11-11'), _now),
ConditionProblem.pastDate,
);
expect(
conditionsProblem(_conditions(date: '2026-12-13'), _now),
ConditionProblem.dateOutOfRange,
);
expect(
conditionsProblem(_conditions(start: '14:00', end: '15:30'), _now),
ConditionProblem.tooShort,
);
expect(
conditionsProblem(_conditions(start: '20:00', end: '14:00'), _now),
ConditionProblem.tooShort,
);
expect(
conditionsProblem(_conditions(start: '09:00', end: '21:30'), _now),
ConditionProblem.tooLong,
);
expect(
conditionsProblem(
_conditions(date: '2026-11-12', start: '09:00', end: '12:00'),
_now,
),
ConditionProblem.pastTime,
);
expect(
conditionsProblem(_conditions(start: '2pm'), _now),
ConditionProblem.invalidTime,
);
});
test('문제 · 알림이 보일 줄과, 고쳐야 하는 알림', () {
expect(problemField(ConditionProblem.pastDate), ConditionField.date);
expect(problemField(ConditionProblem.tooShort), ConditionField.start);
expect(issueField(ConditionIssue.multiDay), ConditionField.date);
expect(issueField(ConditionIssue.unclearRequest), isNull);
expect(isBlockingIssue(ConditionIssue.unknownPlace), isFalse);
expect(isBlockingIssue(ConditionIssue.unsupportedArea), isTrue);
});
});
group('AiCourseDraft', () {
final parse = ConditionParse(
conditions: _conditions(),
inferred: const {ConditionField.date, ConditionField.party},
issues: const [ConditionIssue.multiDay, ConditionIssue.unknownPlace],
weather: const CourseWeather(
date: '2026-11-14',
precipitationProbability: 60,
),
);
test('처음 해석하면 조건 · 뽑은 항목 · 알림 · 날씨를 그대로 채운다', () {
final draft = const AiCourseDraft(
text: '토요일 수성못',
).applyParse(parse, '토요일 수성못');
expect(draft.conditions!.date, '2026-11-14');
expect(draft.inferred, parse.inferred);
expect(draft.issues, parse.issues);
expect(draft.weatherForDate!.precipitationProbability, 60);
expect(draft.hasBlockingIssue, isTrue);
expect(draft.parsedText, '토요일 수성못');
});
test('사용자가 고친 항목은 다시 해석해도 지키고, 그 항목의 알림은 풀린다', () {
final edited = const AiCourseDraft(text: 'a')
.applyParse(parse, 'a')
.applyEdit(_conditions(date: '2026-11-15', party: 4), {
ConditionField.date,
ConditionField.party,
});
expect(edited.issues, [ConditionIssue.unknownPlace]);
expect(edited.inferred, isEmpty);
// 날짜를 고쳐 해석한 날과 달라졌다 — 날씨를 보이지 않는다
expect(edited.weatherForDate, isNull);
final reparsed = edited.applyParse(parse, 'b');
expect(reparsed.conditions!.date, '2026-11-15');
expect(reparsed.conditions!.party, 4);
expect(reparsed.issues, [ConditionIssue.unknownPlace]);
expect(reparsed.inferred, isEmpty);
expect(reparsed.hasBlockingIssue, isFalse);
});
});
group('만드는 중 단계', () {
test('서버 단계 → 지금 줄 · 진행 막대', () {
expect(activeStepIndex(GenerationStatus.queued, null), 0);
expect(
activeStepIndex(GenerationStatus.running, GenerationStage.planning),
2,
);
expect(activeStepIndex(GenerationStatus.succeeded, null), 4);
expect(
progressValue(GenerationStatus.running, GenerationStage.filtering),
0.3,
);
expect(progressValue(GenerationStatus.succeeded, null), 1);
});
test('지금 줄 앞은 끝남, 뒤는 기다림', () {
expect(stepState(GenerationStep.search, 2), GenerationStepState.done);
expect(stepState(GenerationStep.plan, 2), GenerationStepState.current);
expect(stepState(GenerationStep.finish, 2), GenerationStepState.waiting);
});
});
}
@@ -0,0 +1,264 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:wandergo/features/ai_course/data/models/course_conditions.dart';
import 'package:wandergo/features/ai_course/data/models/course_generation.dart';
import 'package:wandergo/features/location/domain/base_location.dart';
import 'package:wandergo/features/place/data/models/place_models.dart';
import 'package:wandergo/shared/models/source_models.dart';
/// 백엔드 CourseConditionsDto 모양
Map<String, dynamic> conditionsJson({
Map<String, dynamic> area = const {'type': 'neighborhood', 'key': 'apsan'},
Object? pet,
}) => {
'area': area,
'date': '2026-11-14',
'start': '14:00',
'end': '20:00',
'party': 3,
'pet': pet,
'mobility': 'transit',
'indoorIfRain': false,
};
/// 백엔드 CourseStopDto 모양
Map<String, dynamic> stopJson(String id, {Map<String, dynamic>? legToNext}) => {
'kind': 'place',
'id': id,
'name': '앞산카페거리',
'category': 'attraction',
'eventKind': null,
'photo': {'thumbUrl': 'https://img/1.jpg', 'license': 'Type1'},
'arrive': '2026-11-14T05:00:00.000Z',
'leave': '2026-11-14T06:10:00.000Z',
'minutes': 70,
'reason': '실외 관광지로 카페가 모여 있어요',
'facts': {
'hoursOnDate': [
{'start': '10:00', 'end': '22:00'},
],
'hoursText': '10:00~22:00',
'hoursOrigin': 'public',
'petPolicy': 'partial',
'petOrigin': 'curation',
'petSizes': ['small', 'huge'],
'verifiedAt': '2026-10-02T00:00:00.000Z',
'setting': 'outdoor',
'eventPeriod': null,
'provider': '한국관광공사',
},
'warnings': ['pet_unknown', 'new_warning'],
'legToNext': legToNext,
};
void main() {
group('CourseConditions', () {
test('백엔드 모양을 읽고 그대로 다시 쓴다', () {
final json = conditionsJson(
pet: {'kind': 'dog', 'size': 'small', 'count': 2},
);
final conditions = CourseConditions.fromJson(json)!;
expect(conditions.area, const NeighborhoodArea(Neighborhood.apsan));
expect(
conditions.pet,
const PetCondition(kind: PetKind.dog, size: PetSize.small, count: 2),
);
expect(conditions.mobility, Mobility.transit);
expect(conditions.indoorIfRain, isFalse);
expect(conditions.toJson(), json);
});
test('지역 세 가지를 읽고 쓴다', () {
for (final area in [
{'type': 'neighborhood', 'key': 'downtown'},
{'type': 'point', 'lat': 35.87, 'lng': 128.59},
{'type': 'place', 'placeId': 'p-1'},
]) {
expect(CourseArea.fromJson(area)!.toJson(), area);
}
});
test('모르는 동네 · 빠진 날짜는 읽지 않는다', () {
expect(
CourseArea.fromJson({'type': 'neighborhood', 'key': 'seongsu'}),
isNull,
);
final noDate = conditionsJson()..remove('date');
expect(CourseConditions.fromJson(noDate), isNull);
});
test('반려동물만 바꾸고 나머지는 그대로 둔다', () {
final conditions = CourseConditions.fromJson(
conditionsJson(pet: {'kind': 'cat', 'size': null, 'count': 1}),
)!;
final none = conditions.withPet(null);
expect(none.pet, isNull);
expect(none.date, conditions.date);
expect(conditions.copyWith(party: 5).pet, conditions.pet);
});
});
group('ConditionParse', () {
test('뽑은 항목 · 되물을 것 · 날씨를 읽고 모르는 값은 버린다', () {
final parse = ConditionParse.fromJson({
'conditions': conditionsJson(),
'inferred': ['area', 'date', 'start', 'mood'],
'issues': ['multi_day', 'brand_new_issue'],
'weather': {
'date': '2026-11-14',
'precipitationProbability': 60,
'precipitationType': 'rain',
'sky': 'cloudy',
'temperatureMin': 9,
'temperatureMax': 14.5,
'issuedAt': '2026-11-12T02:00:00.000Z',
},
})!;
expect(parse.inferred, {
ConditionField.area,
ConditionField.date,
ConditionField.start,
});
expect(parse.issues, [ConditionIssue.multiDay]);
expect(parse.weather!.precipitationProbability, 60);
expect(parse.weather!.precipitationType, PrecipitationType.rain);
expect(parse.weather!.temperatureMax, 14.5);
});
test('조건이 없으면 null', () {
expect(ConditionParse.fromJson({'inferred': []}), isNull);
});
});
group('CourseGeneration', () {
test('진행 중 — 단계와 숫자를 읽는다', () {
final generation = CourseGeneration.fromJson({
'id': 'g1',
'status': 'running',
'stage': 'filtering',
'progress': {'found': 128, 'kept': null},
'result': null,
'failure': null,
'remaining': 1,
'rating': null,
});
expect(generation.status, GenerationStatus.running);
expect(generation.status.isActive, isTrue);
expect(generation.stage, GenerationStage.filtering);
expect(generation.found, 128);
expect(generation.kept, isNull);
});
test('실패 — 코드와 AI 가 남긴 이유', () {
final generation = CourseGeneration.fromJson({
'id': 'g1',
'status': 'failed',
'stage': null,
'progress': {'found': 3, 'kept': 1},
'result': null,
'failure': {
'code': 'AI_003',
'notices': ['반려견과 들어갈 수 있는 실내가 없어요'],
},
'remaining': 2,
'rating': null,
});
expect(generation.status.isActive, isFalse);
expect(generation.failure!.code, 'AI_003');
expect(generation.failure!.notices, hasLength(1));
});
test('결과 — 장소 · 사실 값 · 바꾸기 후보 · 알림을 읽는다', () {
final generation = CourseGeneration.fromJson({
'id': 'g1',
'status': 'succeeded',
'stage': null,
'progress': {'found': 128, 'kept': 31},
'result': {
'outcome': 'partial',
'title': '앞산 카페와 산책',
'summary': '앞산 카페거리에서 시작해요.',
'notices': ['식당 동반 정보가 없어요'],
'planNotices': [
{'code': 'stop_removed', 'name': '안일사', 'cause': 'window'},
{'code': 'unknown_notice'},
],
'weather': null,
'window': {'date': '2026-11-14', 'start': '14:00', 'end': '20:00'},
'mobility': 'walk',
'totals': {
'stops': 2,
'totalMinutes': 350,
'travelMinutes': 19,
'petConfirmed': 1,
},
'stops': [
stopJson(
'p1',
legToNext: {
'minutes': 6,
'meters': 450,
'mode': 'walk',
'estimated': true,
},
),
stopJson('p2'),
],
'alternates': [
{
...stopJson('p9'),
'forIndex': 1,
'legFromPrev': {
'minutes': 9,
'meters': 700,
'mode': 'walk',
'estimated': true,
},
},
],
'sources': ['한국관광공사 TourAPI · 공공누리 제1유형'],
},
'failure': null,
'remaining': 1,
'rating': 'like',
});
final result = generation.result!;
expect(result.outcome, CourseOutcome.partial);
expect(result.date, '2026-11-14');
expect(result.planNotices, hasLength(1));
expect(result.planNotices.single.code, PlanNoticeCode.stopRemoved);
expect(result.totals.travelMinutes, 19);
expect(result.stops, hasLength(2));
final first = result.stops.first;
expect(first.arrive, DateTime.utc(2026, 11, 14, 5));
expect(first.photo!.license, ImageLicense.type1);
expect(first.facts.hoursOnDate, [(start: '10:00', end: '22:00')]);
expect(first.facts.petPolicy, PetPolicy.partial);
expect(first.facts.petOrigin, ValueOrigin.curation);
expect(first.facts.petSizes, [PetSize.small]);
expect(first.facts.setting, StopSetting.outdoor);
expect(first.warnings, [StopWarning.petUnknown]);
expect(first.legToNext!.meters, 450);
expect(result.stops.last.legToNext, isNull);
final alternate = result.alternates.single;
expect(alternate.forIndex, 1);
expect(alternate.stop.id, 'p9');
expect(alternate.legFromPrev!.minutes, 9);
expect(generation.rating, CourseRating.like);
});
});
test('CourseQuota — 남은 횟수와 대상', () {
final quota = CourseQuota.fromJson({
'limit': 2,
'used': 1,
'remaining': 1,
'resetsAt': '2026-11-12T15:00:00.000Z',
'tier': 'guest',
});
expect(quota.remaining, 1);
expect(quota.tier, QuotaTier.guest);
expect(quota.resetsAt, DateTime.utc(2026, 11, 12, 15));
});
}
@@ -0,0 +1,712 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:wandergo/core/api/api_exception.dart';
import 'package:wandergo/core/clock.dart';
import 'package:wandergo/core/l10n/l10n.dart';
import 'package:wandergo/core/l10n/locale_provider.dart';
import 'package:wandergo/core/router/app_routes.dart';
import 'package:wandergo/core/storage/preferences.dart';
import 'package:wandergo/core/theme/app_theme.dart';
import 'package:wandergo/features/ai_course/data/models/course_conditions.dart';
import 'package:wandergo/features/ai_course/data/models/course_generation.dart';
import 'package:wandergo/features/ai_course/data/repositories/ai_course_repository.dart';
import 'package:wandergo/features/ai_course/presentation/pages/ai_course_generation_page.dart';
import 'package:wandergo/features/ai_course/presentation/pages/ai_course_input_page.dart';
import 'package:wandergo/features/ai_course/presentation/providers/ai_course_providers.dart';
import 'package:wandergo/features/ai_course/presentation/widgets/condition_sheets.dart';
import 'package:wandergo/features/location/domain/base_location.dart';
import 'package:wandergo/features/location/presentation/providers/base_location_provider.dart';
import 'package:wandergo/features/place/data/models/place_models.dart'
show PetPolicy, PetSize;
/// 2026-11-12(목) 한국 12:00
final DateTime _now = DateTime.utc(2026, 11, 12, 3);
const CourseConditions _conditions = CourseConditions(
area: NeighborhoodArea(Neighborhood.suseongmot),
date: '2026-11-14',
start: '14:00',
end: '20:00',
party: 2,
pet: PetCondition(kind: PetKind.dog),
mobility: Mobility.walk,
indoorIfRain: true,
);
CourseStop _stop(String id, String name, int hour) => CourseStop(
kind: StopKind.place,
id: id,
name: name,
category: 'nature',
arrive: DateTime.utc(2026, 11, 14, hour - 9),
leave: DateTime.utc(2026, 11, 14, hour - 8),
minutes: 60,
reason: '걷기 좋은 호숫가',
facts: const StopFacts(),
);
final CourseResult _result = CourseResult(
outcome: CourseOutcome.course,
title: '수성못 반려견 산책',
summary: '호숫가를 따라 걸어요.',
date: '2026-11-14',
mobility: Mobility.walk,
totals: const CourseTotals(
stops: 2,
totalMinutes: 180,
travelMinutes: 10,
petConfirmed: 1,
),
stops: [_stop('p1', '수성못 유원지', 14), _stop('p2', '수성못상화동산', 16)],
);
/// 결과 화면 확인용 — 사실 · 경고 · 알림 · 날씨와 2번 자리의 바꾸기 후보
final CourseResult _resultWithAlternate = CourseResult(
outcome: CourseOutcome.partial,
title: '수성못 반려견 산책',
summary: '호숫가를 따라 걸어요.',
notices: const ['반려견과 들어갈 수 있는 식당이 없어 식사를 넣지 못했어요'],
planNotices: const [
PlanNotice(code: PlanNoticeCode.stopRemoved, name: '안일사', cause: 'window'),
],
weather: const CourseWeather(
date: '2026-11-14',
precipitationProbability: 20,
temperatureMin: 9,
temperatureMax: 15,
),
date: '2026-11-14',
mobility: Mobility.walk,
totals: const CourseTotals(
stops: 2,
totalMinutes: 130,
travelMinutes: 10,
petConfirmed: 1,
),
stops: [
CourseStop(
kind: StopKind.place,
id: 'p1',
name: '수성못 유원지',
category: 'nature',
arrive: DateTime.utc(2026, 11, 14, 5),
leave: DateTime.utc(2026, 11, 14, 6),
minutes: 60,
reason: '호숫가를 걸어요',
facts: const StopFacts(
hoursOnDate: [(start: '09:00', end: '22:00')],
petPolicy: PetPolicy.partial,
provider: '한국관광공사',
),
legToNext: const CourseLeg(minutes: 10, meters: 700, mode: Mobility.walk),
),
CourseStop(
kind: StopKind.place,
id: 'p2',
name: '수성못상화동산',
category: 'nature',
arrive: DateTime.utc(2026, 11, 14, 6, 10),
leave: DateTime.utc(2026, 11, 14, 7, 10),
minutes: 60,
reason: '언덕에서 호수를 봐요',
facts: const StopFacts(),
warnings: const [StopWarning.petUnknown],
),
],
alternates: [
CourseAlternate(
forIndex: 1,
stop: CourseStop(
kind: StopKind.place,
id: 'p9',
name: '들안길 공원',
category: 'nature',
arrive: DateTime.utc(2026, 11, 14, 6, 10),
leave: DateTime.utc(2026, 11, 14, 7, 10),
minutes: 60,
reason: '반려견과 걷기 좋은 공원',
facts: const StopFacts(petPolicy: PetPolicy.allowed),
),
legFromPrev: const CourseLeg(
minutes: 4,
meters: 300,
mode: Mobility.walk,
),
),
],
sources: const ['한국관광공사 TourAPI · 공공누리 제1유형'],
);
/// 가짜 AI 코스 API — 부른 것을 기록하고, 진행 조회는 [progress] 를 차례로 준다(끝에 닿으면 마지막 것)
class _FakeAiRepository implements AiCourseRepository {
_FakeAiRepository({
this.parse,
this.progress = const [],
this.remaining = 2,
this.createError,
});
final ConditionParse? parse;
final List<CourseGeneration> progress;
final int remaining;
final Object? createError;
final List<String> parsedTexts = [];
final List<CourseConditions> created = [];
final List<String> canceled = [];
final List<String> polled = [];
final List<String?> previousIds = [];
final List<CourseRating> rated = [];
int _polls = 0;
@override
Future<CourseQuota> quota({required String lang}) async =>
CourseQuota(limit: 2, used: 2 - remaining, remaining: remaining);
@override
Future<ConditionParse> parseConditions({
required String text,
required CourseArea baseArea,
required String lang,
}) async {
parsedTexts.add(text);
return parse!;
}
@override
Future<GenerationCreated> create({
required String request,
required CourseConditions conditions,
String? previousGenerationId,
required String lang,
}) async {
final error = createError;
if (error != null) throw error;
created.add(conditions);
previousIds.add(previousGenerationId);
// 만들 때마다 새 id — 다시 만들기가 새 생성으로 가는지 본다
return GenerationCreated(
id: 'g${created.length}',
status: GenerationStatus.queued,
remaining: 1,
);
}
@override
Future<CourseGeneration> get(String id, {required String lang}) async {
polled.add(id);
final index = _polls < progress.length ? _polls : progress.length - 1;
_polls++;
return progress[index];
}
@override
Future<CourseGeneration> cancel(String id, {required String lang}) async {
canceled.add(id);
return CourseGeneration(id: id, status: GenerationStatus.canceled);
}
@override
Future<CourseGeneration> rate(
String id,
CourseRating value, {
required String lang,
}) async {
rated.add(value);
final last = progress.last;
return CourseGeneration(
id: id,
status: last.status,
found: last.found,
kept: last.kept,
result: last.result,
remaining: last.remaining,
rating: value,
);
}
}
Future<void> _pump(
WidgetTester tester,
_FakeAiRepository repo, {
String initialLocation = AppRoutes.aiCourse,
Map<String, Object> prefs = const {},
Size size = const Size(800, 2000),
}) async {
SharedPreferences.setMockInitialValues(prefs);
final preferences = await SharedPreferences.getInstance();
final router = GoRouter(
initialLocation: initialLocation,
routes: [
GoRoute(
path: AppRoutes.aiCourse,
builder: (context, state) => const AiCourseInputPage(),
),
GoRoute(
path: AppRoutes.aiCourseGenerationPattern,
builder: (context, state) =>
AiCourseGenerationPage(id: state.pathParameters['id']!),
),
],
);
addTearDown(router.dispose);
await tester.binding.setSurfaceSize(size);
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(preferences),
aiCourseRepositoryProvider.overrideWithValue(repo),
contentLanguageProvider.overrideWithValue('ko'),
clockProvider.overrideWithValue(() => _now),
basePointProvider.overrideWith(
(ref) async => BasePoint(
point: Neighborhood.suseongmot.point,
choice: const BaseLocationChoice.neighborhood(
Neighborhood.suseongmot,
),
),
),
],
child: MaterialApp.router(
routerConfig: router,
theme: AppTheme.light(),
locale: const Locale('ko'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
),
),
);
await tester.pump();
}
/// 화면을 닫아 남은 타이머(조회 · 입력 멈춤)를 정리한다
Future<void> _dispose(WidgetTester tester) async {
await tester.pumpWidget(const SizedBox());
await tester.pump();
}
/// 화면 글자는 단어 단위 줄바꿈용 보이지 않는 글자를 품고 있어, 그 글자를 빼고 찾는다
Finder _text(String text) => find.byWidgetPredicate(
(w) =>
w is Text &&
(w.data ?? w.textSpan?.toPlainText() ?? '').replaceAll('⁠', '') == text,
);
Finder _button(String label) => find.ancestor(
of: find.text(label),
matching: find.byWidgetPredicate((w) => w is ButtonStyleButton),
);
void main() {
testWidgets('입력: 문장을 멈추면 조건을 채우고, 만들면 진행을 거쳐 결과를 보인다', (tester) async {
final repo = _FakeAiRepository(
parse: const ConditionParse(
conditions: _conditions,
inferred: {ConditionField.date, ConditionField.pet},
weather: CourseWeather(
date: '2026-11-14',
precipitationProbability: 60,
),
),
progress: [
const CourseGeneration(
id: 'g1',
status: GenerationStatus.running,
stage: GenerationStage.filtering,
found: 128,
),
const CourseGeneration(
id: 'g1',
status: GenerationStatus.running,
stage: GenerationStage.planning,
found: 128,
kept: 31,
),
CourseGeneration(
id: 'g1',
status: GenerationStatus.succeeded,
found: 128,
kept: 31,
result: _result,
),
],
);
await _pump(tester, repo);
await tester.pump();
expect(find.text('오늘 2번 더 만들 수 있어요'), findsOneWidget);
expect(_text('문장을 쓰면 날짜·시간·인원을 채워 드려요.'), findsOneWidget);
// 해석 전에는 만들 수 없다
expect(
tester.widget<ButtonStyleButton>(_button('코스 만들기')).enabled,
isFalse,
);
await tester.enterText(find.byType(TextField), '토요일 오후 강아지랑 수성못');
await tester.pump(const Duration(milliseconds: 500));
expect(repo.parsedTexts, isEmpty, reason: '1초 멈추기 전에는 부르지 않는다');
await tester.pump(const Duration(milliseconds: 600));
await tester.pump();
expect(repo.parsedTexts, ['토요일 오후 강아지랑 수성못']);
expect(find.text('수성못'), findsOneWidget);
expect(find.text('11.14(토)'), findsOneWidget);
expect(find.text('14:00–20:00'), findsOneWidget);
expect(find.text('2명'), findsOneWidget);
expect(find.text('강아지 1마리'), findsOneWidget);
expect(find.text('토요일 비 60%'), findsOneWidget);
// 인원을 고치면 그 값으로 만든다
await tester.tap(
find.byWidgetPredicate(
(w) => w is RoundStepButton && w.label == '인원 늘리기',
),
);
await tester.pump();
expect(find.text('3명'), findsOneWidget);
await tester.tap(_button('코스 만들기'));
await tester.pump();
await tester.pump();
expect(repo.created.single.party, 3);
// 만드는 중 — 지역 이름과 찾은 수
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
expect(find.text('코스를 짜고 있어요'), findsOneWidget);
expect(_text('수성못 근처에서 128곳을 찾았어요'), findsOneWidget);
expect(_text('조건에 맞는 곳을 추리는 중'), findsOneWidget);
// 다음 조회 — 추린 결과를 요일 영업과 반려동물 조건으로 말한다
await tester.pump(kPollInterval);
await tester.pump();
expect(_text('토요일 영업·반려견 동반으로 31곳을 추렸어요'), findsOneWidget);
expect(
find.text('오늘 2번 더 만들 수 있어요'),
findsNothing,
reason: '만드는 중에는 횟수를 보이지 않는다',
);
// 그다음 조회에서 끝났다
await tester.pump(kPollInterval);
await tester.pump();
expect(_text('호숫가를 따라 걸어요.'), findsOneWidget);
expect(_text('수성못 유원지'), findsOneWidget);
expect(find.text('14:00–15:00 · 60분'), findsOneWidget);
await _dispose(tester);
});
testWidgets('결과: 사실 · 알림을 보이고, 한 곳을 바꿨다 되돌리고, 평가하고, 다시 만든다', (tester) async {
final repo = _FakeAiRepository(
parse: const ConditionParse(conditions: _conditions),
progress: [
CourseGeneration(
id: 'g1',
status: GenerationStatus.succeeded,
found: 40,
kept: 12,
result: _resultWithAlternate,
remaining: 1,
),
],
);
await _pump(tester, repo);
await tester.enterText(find.byType(TextField), '토요일 강아지랑 수성못');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
await tester.pump();
await tester.tap(_button('코스 만들기'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
// 요청 · 요약 · 알림
expect(find.text('내가 요청한 코스'), findsOneWidget);
expect(_text('“토요일 강아지랑 수성못”'), findsOneWidget);
expect(_text('호숫가를 따라 걸어요.'), findsOneWidget);
expect(_text('수성못 반려견 산책'), findsNothing, reason: '시안대로 코스 제목은 없다');
expect(_text('토요일 비 20% · 9–15°C'), findsOneWidget);
expect(_text('반려견과 들어갈 수 있는 식당이 없어 식사를 넣지 못했어요'), findsOneWidget);
expect(_text('안일사: 그 시간에 머물 수 없어 코스에서 뺐어요.'), findsOneWidget);
expect(find.text('10분'), findsOneWidget, reason: '걸어서 이동 합계');
// 코스 순서 — 시각 · 분류 · 동반 · 사실 · 경고 · 이동
expect(find.text('14:00 출발 · 16:10 마침'), findsOneWidget);
expect(find.text('걸어서 10분 · 700m'), findsOneWidget);
expect(_text('영업 09:00–22:00 · 출처: 한국관광공사'), findsOneWidget);
expect(_text('반려동물 동반 정보가 없어요. 현장 안내를 따라 주세요.'), findsOneWidget);
// 바꾸기 — 2번 자리를 후보로, 앞 장소에서 오는 이동도 바뀐다
final swapRow = find.text('2번 대신 · 자연·공원');
await tester.ensureVisible(swapRow);
await tester.tap(swapRow);
await tester.pump();
expect(find.text('2번 원래 장소로 되돌리기'), findsOneWidget);
expect(find.text('걸어서 4분 · 300m'), findsOneWidget);
expect(find.text('4분'), findsOneWidget, reason: '이동 합계도 다시 센다');
expect(
_text('반려동물 동반 정보가 없어요. 현장 안내를 따라 주세요.'),
findsNothing,
reason: '바뀐 장소는 동반이 확인됐다',
);
// 되돌리기
await tester.tap(find.text('2번 원래 장소로 되돌리기'));
await tester.pump();
expect(find.text('2번 대신 · 자연·공원'), findsOneWidget);
expect(find.text('걸어서 10분 · 700m'), findsOneWidget);
// 평가
final like = find.byTooltip('좋아요');
await tester.ensureVisible(like);
await tester.tap(like);
await tester.pump();
await tester.pump();
expect(repo.rated, [CourseRating.like]);
expect(find.text('의견 고마워요'), findsOneWidget);
// 다시 만들기 — 확인 뒤 지난 결과 id 를 넘겨 새 생성으로 간다
await tester.tap(_button('다시 만들기').first);
await tester.pumpAndSettle();
expect(find.text('다시 만들까요?'), findsOneWidget);
// 가짜 서버의 남은 횟수(2번)를 그대로 보인다
expect(_text('같은 조건으로 다른 곳을 골라 새 코스를 만들어요. 오늘 2번 남아요.'), findsOneWidget);
await tester.tap(
find.descendant(
of: find.byType(BottomSheet),
matching: _button('다시 만들기'),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
expect(repo.previousIds, [null, 'g1']);
expect(repo.polled, contains('g2'));
await _dispose(tester);
});
testWidgets('입력: 되물을 것이 있으면 그 줄에 알리고, 고치면 만들 수 있다', (tester) async {
final repo = _FakeAiRepository(
parse: const ConditionParse(
conditions: _conditions,
issues: [ConditionIssue.multiDay],
),
);
await _pump(tester, repo);
await tester.enterText(find.byType(TextField), '1박 2일 수성못');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
await tester.pump();
expect(_text('하루 코스만 만들 수 있어요. 날짜 하루를 골라 주세요.'), findsOneWidget);
expect(
tester.widget<ButtonStyleButton>(_button('코스 만들기')).enabled,
isFalse,
);
// 날짜를 고르면 알림이 풀린다
await tester.tap(find.text('11.14(토)'));
await tester.pumpAndSettle();
await tester.tap(find.text('15'));
await tester.tap(find.text('확인'));
await tester.pumpAndSettle();
expect(find.text('11.15(일)'), findsOneWidget);
expect(_text('하루 코스만 만들 수 있어요. 날짜 하루를 골라 주세요.'), findsNothing);
expect(tester.widget<ButtonStyleButton>(_button('코스 만들기')).enabled, isTrue);
await _dispose(tester);
});
testWidgets('입력: 하루 횟수를 다 썼으면 만들지 않고 안내한다', (tester) async {
final repo = _FakeAiRepository(
parse: const ConditionParse(conditions: _conditions),
remaining: 0,
);
await _pump(tester, repo);
await tester.pump();
expect(find.text('오늘 만들 수 있는 횟수를 다 썼어요'), findsOneWidget);
await tester.enterText(find.byType(TextField), '토요일 수성못');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
await tester.pump();
await tester.tap(_button('코스 만들기'));
await tester.pumpAndSettle();
expect(
_text('오늘 만들 수 있는 AI 코스를 모두 만들었어요. 내일 다시 만들 수 있어요.'),
findsOneWidget,
);
expect(repo.created, isEmpty);
await _dispose(tester);
});
testWidgets('입력: 휴대폰 폭(390)에서도 조건 줄과 시트가 넘치지 않는다', (tester) async {
final repo = _FakeAiRepository(
parse: const ConditionParse(
conditions: CourseConditions(
area: NeighborhoodArea(Neighborhood.apsan),
date: '2026-11-14',
start: '10:00',
end: '22:00',
party: 10,
pet: PetCondition(kind: PetKind.dog, size: PetSize.medium, count: 5),
mobility: Mobility.transit,
indoorIfRain: false,
),
issues: [ConditionIssue.unknownPlace],
weather: CourseWeather(
date: '2026-11-14',
precipitationProbability: 100,
),
),
);
await _pump(tester, repo, size: const Size(390, 844));
await tester.enterText(find.byType(TextField), '앞산 하루 종일');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
await tester.pump();
expect(find.text('앞산 카페거리'), findsOneWidget);
expect(find.text('중형견 5마리'), findsOneWidget);
expect(tester.takeException(), isNull);
// 시간 · 반려동물 시트
await tester.tap(find.text('10:00–22:00'));
await tester.pumpAndSettle();
expect(find.text('12시간'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.tapAt(const Offset(195, 40));
await tester.pumpAndSettle();
await tester.tap(find.text('중형견 5마리'));
await tester.pumpAndSettle();
expect(find.text('누구와 함께 가요?'), findsOneWidget);
expect(tester.takeException(), isNull);
await _dispose(tester);
});
testWidgets('입력: 이미 만드는 중(AI_004)이면 저장해 둔 생성으로 이어 간다', (tester) async {
final repo = _FakeAiRepository(
parse: const ConditionParse(conditions: _conditions),
createError: DioException(
requestOptions: RequestOptions(path: '/course-generations'),
error: ApiException.fromCode('AI_004'),
),
progress: const [
CourseGeneration(
id: 'g7',
status: GenerationStatus.running,
stage: GenerationStage.searching,
),
],
);
await _pump(tester, repo, prefs: {PrefKeys.pendingGeneration: 'g7'});
expect(find.text('만들던 코스가 있어요'), findsOneWidget);
await tester.enterText(find.byType(TextField), '토요일 수성못');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
await tester.pump();
await tester.tap(_button('코스 만들기'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.pump();
expect(find.text('코스를 짜고 있어요'), findsOneWidget);
expect(repo.polled, contains('g7'));
await _dispose(tester);
});
testWidgets('결과: 휴대폰 폭(390)에서도 넘치지 않고, 입력 없이 이어 보면 다시 만들기를 숨긴다', (
tester,
) async {
final repo = _FakeAiRepository(
progress: [
CourseGeneration(
id: 'g5',
status: GenerationStatus.succeeded,
result: _resultWithAlternate,
),
],
);
await _pump(
tester,
repo,
initialLocation: AppRoutes.aiCourseGeneration('g5'),
size: const Size(390, 844),
);
await tester.pump(const Duration(milliseconds: 10));
await tester.pump();
expect(_text('호숫가를 따라 걸어요.'), findsOneWidget);
expect(tester.takeException(), isNull);
// 이 기기에서 입력한 요청이 없다(앱을 다시 켜 이어 봄) — 요청 칸과 다시 만들기를 숨긴다
expect(find.text('내가 요청한 코스'), findsNothing);
expect(_button('다시 만들기'), findsNothing);
await tester.dragUntilVisible(
_text('이 코스, 마음에 드세요?'),
find.byType(ListView),
const Offset(0, -300),
);
await tester.pump();
expect(tester.takeException(), isNull);
await _dispose(tester);
});
testWidgets('만드는 중: 실패하면 사전 문구와 AI 가 남긴 이유를 보인다', (tester) async {
final repo = _FakeAiRepository(
progress: const [
CourseGeneration(
id: 'g9',
status: GenerationStatus.failed,
failure: GenerationFailure(
code: 'AI_003',
notices: ['반려견과 함께 들어갈 수 있는 실내가 없어요'],
),
),
],
);
await _pump(
tester,
repo,
initialLocation: AppRoutes.aiCourseGeneration('g9'),
);
// 첫 조회 타이머가 돌 시간을 준다
await tester.pump(const Duration(milliseconds: 10));
await tester.pump();
expect(_text('코스를 만들지 못했어요'), findsOneWidget);
expect(
_text('조건에 맞는 장소를 찾지 못했어요. 시간이나 조건을 바꿔 다시 만들어 보세요.'),
findsOneWidget,
);
expect(_text('반려견과 함께 들어갈 수 있는 실내가 없어요'), findsOneWidget);
await _dispose(tester);
});
testWidgets('만드는 중: 취소하면 서버에 취소를 보낸다', (tester) async {
final repo = _FakeAiRepository(
progress: const [
CourseGeneration(
id: 'g2',
status: GenerationStatus.running,
stage: GenerationStage.planning,
found: 40,
kept: 12,
),
],
);
await _pump(
tester,
repo,
initialLocation: AppRoutes.aiCourseGeneration('g2'),
);
await tester.pump(const Duration(milliseconds: 10));
await tester.pump();
expect(_text('근처에서 40곳을 찾았어요'), findsOneWidget);
expect(_text('조건에 맞는 12곳을 추렸어요'), findsOneWidget);
await tester.tap(find.text('취소'));
await tester.pump();
expect(repo.canceled, ['g2']);
await _dispose(tester);
});
}
@@ -0,0 +1,91 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:wandergo/features/ai_course/data/models/course_conditions.dart';
import 'package:wandergo/features/ai_course/data/models/course_generation.dart';
import 'package:wandergo/features/ai_course/domain/course_swap.dart';
import 'package:wandergo/features/place/data/models/place_models.dart';
CourseStop _stop(
String id, {
required int hour,
PetPolicy pet = PetPolicy.unknown,
int? legMinutes,
}) => CourseStop(
kind: StopKind.place,
id: id,
name: id,
category: 'nature',
arrive: DateTime.utc(2026, 11, 14, hour - 9),
leave: DateTime.utc(2026, 11, 14, hour - 8),
minutes: 60,
reason: '',
facts: StopFacts(petPolicy: pet),
legToNext: legMinutes == null
? null
: CourseLeg(minutes: legMinutes, meters: 500, mode: Mobility.walk),
);
CourseLeg _leg(int minutes) =>
CourseLeg(minutes: minutes, meters: 300, mode: Mobility.walk);
final CourseResult _result = CourseResult(
outcome: CourseOutcome.course,
title: '',
summary: '',
date: '2026-11-14',
mobility: Mobility.walk,
totals: const CourseTotals(
stops: 3,
totalMinutes: 300,
travelMinutes: 20,
petConfirmed: 1,
),
stops: [
_stop('a', hour: 14, pet: PetPolicy.allowed, legMinutes: 10),
_stop('b', hour: 16, legMinutes: 10),
_stop('c', hour: 18),
],
alternates: [
CourseAlternate(
forIndex: 1,
stop: _stop('b2', hour: 16, pet: PetPolicy.partial, legMinutes: 3),
legFromPrev: _leg(4),
),
CourseAlternate(forIndex: 0, stop: _stop('a2', hour: 14, legMinutes: 8)),
CourseAlternate(forIndex: 9, stop: _stop('z', hour: 20)),
],
);
void main() {
test('바꾸지 않으면 그대로', () {
expect(stopsWithSwap(_result, null).map((s) => s.id), ['a', 'b', 'c']);
});
test('자리를 바꾸면 그 장소와 앞 장소에서 오는 이동이 바뀐다', () {
final stops = stopsWithSwap(_result, 1);
expect(stops.map((s) => s.id), ['a', 'b2', 'c']);
expect(stops[0].legToNext!.minutes, 4, reason: '앞 장소의 이동은 legFromPrev');
expect(stops[1].legToNext!.minutes, 3, reason: '다음 장소로의 이동은 후보에 들어 있다');
});
test('첫 자리를 바꾸면 앞 장소가 없다', () {
final stops = stopsWithSwap(_result, 0);
expect(stops.map((s) => s.id), ['a2', 'b', 'c']);
expect(stops[0].legToNext!.minutes, 8);
});
test('자리마다 후보 하나 — 범위 밖 자리는 버린다', () {
expect(alternatesBySlot(_result).keys.toSet(), {0, 1});
});
test('숫자 요약을 장소에서 다시 센다', () {
final before = totalsOf(stopsWithSwap(_result, null));
expect(before.travelMinutes, 20);
expect(before.petConfirmed, 1);
expect(before.totalMinutes, 300);
final after = totalsOf(stopsWithSwap(_result, 1));
expect(after.travelMinutes, 7);
expect(after.petConfirmed, 2);
expect(totalsOf(const []).stops, 0);
});
}
@@ -0,0 +1,106 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:wandergo/core/api/api_exception.dart';
import 'package:wandergo/core/l10n/l10n.dart';
import 'package:wandergo/core/theme/app_theme.dart';
import 'package:wandergo/shared/widgets/app_switch.dart';
import 'package:wandergo/shared/widgets/load_error.dart';
Widget _wrap(Widget child) => MaterialApp(
locale: const Locale('ko'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
theme: AppTheme.light(),
home: Scaffold(body: Center(child: child)),
);
/// 인터셉터가 실어 주는 모양 그대로 — DioException.error 에 ApiException
DioException _failure({String? traceId}) => DioException(
requestOptions: RequestOptions(path: '/places'),
error: ApiException.fromCode('SYS_001', traceId: traceId),
);
/// keepAll 이 넣은 단어 잇기 문자를 빼고 글자를 비교한다
Finder _text(String text) => find.byWidgetPredicate(
(w) =>
w is Text &&
(w.data ?? w.textSpan?.toPlainText() ?? '').replaceAll('⁠', '') == text,
);
void main() {
group('LoadErrorBlock', () {
testWidgets('사전 문구 · 다시 시도와 오류 코드 · 추적 ID 앞 8자를 보인다', (tester) async {
var retried = 0;
await tester.pumpWidget(
_wrap(
LoadErrorBlock(
error: _failure(traceId: '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d'),
onRetry: () => retried++,
),
),
);
expect(_text('일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.'), findsOneWidget);
expect(find.text('오류 코드 SYS_001 · 추적 ID 3f1c0a2e'), findsOneWidget);
await tester.tap(find.text('다시 시도'));
expect(retried, 1);
});
testWidgets('응답이 없던 실패(추적 ID 없음)는 오류 코드만 보인다', (tester) async {
await tester.pumpWidget(
_wrap(LoadErrorBlock(error: _failure(), onRetry: () {})),
);
expect(find.text('오류 코드 SYS_001'), findsOneWidget);
});
test('추적 ID 는 ApiException 에서만 꺼낸다', () {
expect(traceIdOf(_failure(traceId: 'abc')), 'abc');
expect(traceIdOf(StateError('x')), isNull);
});
});
group('AppSwitch', () {
testWidgets('누르면 반대 값을 알리고, 화면 읽기에는 켜짐 상태와 이름을 준다', (tester) async {
final handle = tester.ensureSemantics();
bool? changed;
await tester.pumpWidget(
_wrap(
AppSwitch(
value: true,
onChanged: (value) => changed = value,
semanticLabel: '비가 오면 실내 위주로',
),
),
);
await tester.tap(find.byType(AppSwitch));
expect(changed, isFalse);
expect(
tester.getSemantics(find.byType(AppSwitch)),
matchesSemantics(
label: '비가 오면 실내 위주로',
hasToggledState: true,
isToggled: true,
hasEnabledState: true,
isEnabled: true,
hasTapAction: true,
),
);
handle.dispose();
});
testWidgets('onChanged 가 없으면 누를 수 없다', (tester) async {
await tester.pumpWidget(
_wrap(
const AppSwitch(
value: false,
onChanged: null,
semanticLabel: '비가 오면 실내 위주로',
),
),
);
await tester.tap(find.byType(AppSwitch));
await tester.pump();
expect(tester.takeException(), isNull);
});
});
}
+21
View File
@@ -6,6 +6,8 @@ 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/ai_course/data/models/course_generation.dart';
import 'package:wandergo/features/ai_course/data/repositories/ai_course_repository.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';
@@ -178,6 +180,19 @@ class _FakePlaceRepository implements PlaceRepository {
}
}
/// 가짜 AI 코스 API — 홈 'AI에게 코스 맡기기' 칸이 남은 횟수만 묻는다(화면 흐름은 ai_course_pages_test)
class _FakeAiCourseRepository implements AiCourseRepository {
const _FakeAiCourseRepository();
@override
Future<CourseQuota> quota({required String lang}) async =>
const CourseQuota(limit: 2, used: 0, remaining: 2);
@override
dynamic noSuchMethod(Invocation invocation) =>
throw UnimplementedError('홈 테스트는 남은 횟수만 쓴다');
}
class _FakeLocationService implements LocationService {
const _FakeLocationService({
this.granted = true,
@@ -218,6 +233,9 @@ Future<_FakeEventRepository> _pumpApp(
locationServiceProvider.overrideWithValue(
_FakeLocationService(granted: locationGranted, point: currentPoint),
),
aiCourseRepositoryProvider.overrideWithValue(
const _FakeAiCourseRepository(),
),
clockProvider.overrideWithValue(() => _now),
],
child: const WanderGoApp(),
@@ -242,6 +260,8 @@ Future<void> _useTallScreen(WidgetTester tester) async {
void main() {
testWidgets('첫 실행: 동네를 고르면 홈으로 가고, 그 동네 기준 이번 주말 행사를 보인다', (tester) async {
// 행사 칸은 'AI에게 코스 맡기기' 아래라 기본 화면 높이로는 보이지 않는다
await _useTallScreen(tester);
final repo = await _pumpApp(
tester,
repo: _FakeEventRepository(weekend: [_event('e1', '수성빛예술제')]),
@@ -273,6 +293,7 @@ void main() {
});
testWidgets('주말 행사가 없으면 다가오는 공연·행사(30일)로 바꿔 보인다', (tester) async {
await _useTallScreen(tester);
final repo = await _pumpApp(
tester,
prefs: {'onboarding_done': true},