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,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,
),
),
],
),
);
}
}