generated from ttipo/project-base
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:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user