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
@@ -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);
});
});
}