From af1a0f26fc4e0375c6c53edb14a4a6af3f48d84b Mon Sep 17 00:00:00 2001 From: ttipo Date: Thu, 12 Nov 2026 15:48:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20AI=20=EC=BD=94=EC=8A=A4=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=C2=B7=EB=A7=8C=EB=93=9C=EB=8A=94=20=EC=A4=91?= =?UTF-8?q?=C2=B7=EA=B2=B0=EA=B3=BC=20=ED=99=94=EB=A9=B4(8=E2=80=9310)=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 백엔드 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 --- docs/design/ai-course.md | 15 +- flutter/lib/core/api/dio_client.dart | 41 +- flutter/lib/core/device/device_id.dart | 24 + flutter/lib/core/icons/app_icons.dart | 52 + flutter/lib/core/l10n/locale_provider.dart | 21 + flutter/lib/core/router/app_router.dart | 11 + flutter/lib/core/router/app_routes.dart | 8 + flutter/lib/core/storage/preferences.dart | 3 + flutter/lib/core/theme/app_colors.dart | 13 + flutter/lib/core/utils/uuid.dart | 28 + .../data/models/course_conditions.dart | 392 +++++++ .../data/models/course_generation.dart | 602 ++++++++++ .../repositories/ai_course_repository.dart | 105 ++ .../ai_course/domain/ai_course_draft.dart | 149 +++ .../ai_course/domain/condition_rules.dart | 163 +++ .../ai_course/domain/course_swap.dart | 60 + .../ai_course/domain/generation_steps.dart | 42 + .../pages/ai_course_generation_page.dart | 460 ++++++++ .../pages/ai_course_input_page.dart | 402 +++++++ .../providers/ai_course_providers.dart | 286 +++++ .../presentation/widgets/ai_course_title.dart | 34 + .../presentation/widgets/ai_home_section.dart | 131 +++ .../widgets/condition_labels.dart | 81 ++ .../widgets/condition_section.dart | 611 ++++++++++ .../widgets/condition_sheets.dart | 361 ++++++ .../widgets/course_result_view.dart | 1027 +++++++++++++++++ .../widgets/course_stop_tile.dart | 391 +++++++ .../widgets/quota_exhausted_sheet.dart | 50 + .../widgets/regenerate_sheet.dart | 47 + .../home/presentation/pages/home_page.dart | 11 +- .../presentation/widgets/region_sheet.dart | 19 +- flutter/lib/l10n/app_en.arb | 149 ++- flutter/lib/l10n/app_ko.arb | 425 ++++++- .../lib/shared/widgets/app_back_button.dart | 39 + flutter/lib/shared/widgets/app_switch.dart | 81 ++ .../lib/shared/widgets/character_image.dart | 23 + flutter/lib/shared/widgets/load_error.dart | 102 ++ flutter/test/core/device_id_test.dart | 50 + .../test/features/ai_course_domain_test.dart | 180 +++ .../test/features/ai_course_models_test.dart | 264 +++++ .../test/features/ai_course_pages_test.dart | 712 ++++++++++++ .../test/features/ai_course_swap_test.dart | 91 ++ flutter/test/shared/shared_widgets_test.dart | 106 ++ flutter/test/widget_test.dart | 21 + 44 files changed, 7864 insertions(+), 19 deletions(-) create mode 100644 flutter/lib/core/device/device_id.dart create mode 100644 flutter/lib/core/utils/uuid.dart create mode 100644 flutter/lib/features/ai_course/data/models/course_conditions.dart create mode 100644 flutter/lib/features/ai_course/data/models/course_generation.dart create mode 100644 flutter/lib/features/ai_course/data/repositories/ai_course_repository.dart create mode 100644 flutter/lib/features/ai_course/domain/ai_course_draft.dart create mode 100644 flutter/lib/features/ai_course/domain/condition_rules.dart create mode 100644 flutter/lib/features/ai_course/domain/course_swap.dart create mode 100644 flutter/lib/features/ai_course/domain/generation_steps.dart create mode 100644 flutter/lib/features/ai_course/presentation/pages/ai_course_generation_page.dart create mode 100644 flutter/lib/features/ai_course/presentation/pages/ai_course_input_page.dart create mode 100644 flutter/lib/features/ai_course/presentation/providers/ai_course_providers.dart create mode 100644 flutter/lib/features/ai_course/presentation/widgets/ai_course_title.dart create mode 100644 flutter/lib/features/ai_course/presentation/widgets/ai_home_section.dart create mode 100644 flutter/lib/features/ai_course/presentation/widgets/condition_labels.dart create mode 100644 flutter/lib/features/ai_course/presentation/widgets/condition_section.dart create mode 100644 flutter/lib/features/ai_course/presentation/widgets/condition_sheets.dart create mode 100644 flutter/lib/features/ai_course/presentation/widgets/course_result_view.dart create mode 100644 flutter/lib/features/ai_course/presentation/widgets/course_stop_tile.dart create mode 100644 flutter/lib/features/ai_course/presentation/widgets/quota_exhausted_sheet.dart create mode 100644 flutter/lib/features/ai_course/presentation/widgets/regenerate_sheet.dart create mode 100644 flutter/lib/shared/widgets/app_back_button.dart create mode 100644 flutter/lib/shared/widgets/app_switch.dart create mode 100644 flutter/lib/shared/widgets/character_image.dart create mode 100644 flutter/test/core/device_id_test.dart create mode 100644 flutter/test/features/ai_course_domain_test.dart create mode 100644 flutter/test/features/ai_course_models_test.dart create mode 100644 flutter/test/features/ai_course_pages_test.dart create mode 100644 flutter/test/features/ai_course_swap_test.dart create mode 100644 flutter/test/shared/shared_widgets_test.dart diff --git a/docs/design/ai-course.md b/docs/design/ai-course.md index c1e4335..de19294 100644 --- a/docs/design/ai-course.md +++ b/docs/design/ai-course.md @@ -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 한도 · 보관 정리 작업 diff --git a/flutter/lib/core/api/dio_client.dart b/flutter/lib/core/api/dio_client.dart index 13141bb..9d409e6 100644 --- a/flutter/lib/core/api/dio_client.dart +++ b/flutter/lib/core/api/dio_client.dart @@ -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? _deviceId; + factory DioClient() { return _instance; } + Future _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); }, diff --git a/flutter/lib/core/device/device_id.dart b/flutter/lib/core/device/device_id.dart new file mode 100644 index 0000000..adefa7c --- /dev/null +++ b/flutter/lib/core/device/device_id.dart @@ -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 loadOrCreateDeviceId({ + required Future Function() read, + required Future 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; +} diff --git a/flutter/lib/core/icons/app_icons.dart b/flutter/lib/core/icons/app_icons.dart index ab466f9..df1e30a 100644 --- a/flutter/lib/core/icons/app_icons.dart +++ b/flutter/lib/core/icons/app_icons.dart @@ -27,6 +27,7 @@ enum AppIconData { chevronDown(''), chevronRight(''), chevronLeft(''), + arrowRight(''), search(''), close(''), check(''), @@ -34,6 +35,11 @@ enum AppIconData { '', ), ban(''), + + /// 연결 끊김 — 사선이 그어진 구름(오류 화면) + cloudOff( + '', + ), clock(''), parking( '', @@ -44,6 +50,52 @@ enum AppIconData { externalLink( '', ), + calendar( + '', + ), + people( + '', + ), + walk( + '', + ), + umbrella( + '', + ), + plus(''), + minus(''), + + /// 안내 — 원 안의 i. 원 안의 !(주의)는 [info] + notice( + '', + ), + pencil( + '', + ), + refresh( + '', + ), + thumbUp( + '', + ), + thumbDown( + '', + ), + + /// 비 — 꽉 찬 구름과 빗줄기(글자 옆 작은 기호) + rainSolid( + '', + ), + + /// AI 가 고른 이유 — 꽉 찬 반짝임(글자 앞 작은 기호) + sparkleSolid( + '', + ), + + /// AI — 반짝임(늘 같은 색 16% 채움) + sparkle( + '', + ), /// 반려동물 — 선 + 16% 채움(한눈에 보기) paw( diff --git a/flutter/lib/core/l10n/locale_provider.dart b/flutter/lib/core/l10n/locale_provider.dart index c78b1dc..75a6472 100644 --- a/flutter/lib/core/l10n/locale_provider.dart +++ b/flutter/lib/core/l10n/locale_provider.dart @@ -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( + (ref) => resolveLanguageCode( + ref.watch(appLocaleProvider), + PlatformDispatcher.instance.locale, + ), +); + /// 사용자가 고른 앱 언어. null 이면 기기 언어를 따른다 /// (지원하지 않는 기기 언어는 영어로 보인다 — AppLocalizations.supportedLocales 의 첫 항목). final appLocaleProvider = NotifierProvider( diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index 6e1ea7d..5636bd1 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -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((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); diff --git a/flutter/lib/core/router/app_routes.dart b/flutter/lib/core/router/app_routes.dart index 7986d49..29b30bf 100644 --- a/flutter/lib/core/router/app_routes.dart +++ b/flutter/lib/core/router/app_routes.dart @@ -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(), diff --git a/flutter/lib/core/storage/preferences.dart b/flutter/lib/core/storage/preferences.dart index 83a3af0..7264755 100644 --- a/flutter/lib/core/storage/preferences.dart +++ b/flutter/lib/core/storage/preferences.dart @@ -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'; } diff --git a/flutter/lib/core/theme/app_colors.dart b/flutter/lib/core/theme/app_colors.dart index 84a213e..4ffbd8e 100644 --- a/flutter/lib/core/theme/app_colors.dart +++ b/flutter/lib/core/theme/app_colors.dart @@ -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); } diff --git a/flutter/lib/core/utils/uuid.dart b/flutter/lib/core/utils/uuid.dart new file mode 100644 index 0000000..f1b1aa0 --- /dev/null +++ b/flutter/lib/core/utils/uuid.dart @@ -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.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)}'; +} diff --git a/flutter/lib/features/ai_course/data/models/course_conditions.dart b/flutter/lib/features/ai_course/data/models/course_conditions.dart new file mode 100644 index 0000000..bf484a8 --- /dev/null +++ b/flutter/lib/features/ai_course/data/models/course_conditions.dart @@ -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( + List 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) 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 toJson(); +} + +/// 동네(도심 · 수성못 · 앞산) +final class NeighborhoodArea extends CourseArea { + const NeighborhoodArea(this.neighborhood); + + final Neighborhood neighborhood; + + @override + Map 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 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 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) 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 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) 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 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) 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 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 inferred; + final List issues; + final CourseWeather? weather; +} diff --git a/flutter/lib/features/ai_course/data/models/course_generation.dart b/flutter/lib/features/ai_course/data/models/course_generation.dart new file mode 100644 index 0000000..b2d4833 --- /dev/null +++ b/flutter/lib/features/ai_course/data/models/course_generation.dart @@ -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( + List values, + Object? value, + String Function(T) code, +) { + for (final v in values) { + if (code(v) == value) return v; + } + return null; +} + +List _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 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 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 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 + ? _intOrNull(progress['found']) + : null, + kept: progress is Map + ? _intOrNull(progress['kept']) + : null, + result: result is Map + ? CourseResult.fromJson(result) + : null, + failure: failure is Map + ? 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 json) => + GenerationFailure( + code: json['code'] is String ? json['code'] as String : 'AI_002', + notices: _strings(json['notices']), + ); + + final String code; + final List 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 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 && + 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 && + 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? hoursOnDate; + + /// 영업시간 원문(행사는 운영 시간 문구) + final String? hoursText; + final ValueOrigin hoursOrigin; + final PetPolicy petPolicy; + final ValueOrigin petOrigin; + + /// 동반 가능 크기(확인한 곳만) + final List? 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) 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) 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 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 + ? 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 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 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) 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 ? json : const {}; + 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 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 && 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) CourseStop.fromJson(s), + ], + alternates: [ + if (alternates is List) + for (final a in alternates) + if (a is Map) CourseAlternate.fromJson(a), + ], + sources: _strings(json['sources']), + ); + } + + final CourseOutcome outcome; + final String title; + final String summary; + + /// AI 가 남긴 알림(맞추지 못한 조건 등) + final List notices; + final List planNotices; + final CourseWeather? weather; + + /// 코스 날짜('YYYY-MM-DD') + final String date; + final Mobility mobility; + final CourseTotals totals; + final List stops; + final List alternates; + + /// 출처 문구(공공누리 유형 · 기상청 발표 시각 등) + final List sources; +} diff --git a/flutter/lib/features/ai_course/data/repositories/ai_course_repository.dart b/flutter/lib/features/ai_course/data/repositories/ai_course_repository.dart new file mode 100644 index 0000000..560073a --- /dev/null +++ b/flutter/lib/features/ai_course/data/repositories/ai_course_repository.dart @@ -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 quota({required String lang}) async { + final response = await _dio.get>( + '$_generations/quota', + options: _options(lang), + ); + return CourseQuota.fromJson(response.data!); + } + + /// 문장을 조건으로 바꾼다 — 횟수를 차감하지 않는다. [baseArea] 는 문장에 지역이 없을 때의 기준이다 + Future parseConditions({ + required String text, + required CourseArea baseArea, + required String lang, + }) async { + final response = await _dio.post>( + '/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 create({ + required String request, + required CourseConditions conditions, + String? previousGenerationId, + required String lang, + }) async { + final response = await _dio.post>( + _generations, + data: { + 'request': request, + 'conditions': conditions.toJson(), + 'previousGenerationId': ?previousGenerationId, + }, + options: _options(lang), + ); + return GenerationCreated.fromJson(response.data!); + } + + Future get(String id, {required String lang}) async { + final response = await _dio.get>( + '$_generations/$id', + options: _options(lang), + ); + return CourseGeneration.fromJson(response.data!); + } + + /// 취소 — AI 호출을 끊는다. 차감하지 않는다. 이미 끝난 생성이면 그대로 돌려준다 + Future cancel(String id, {required String lang}) async { + final response = await _dio.post>( + '$_generations/$id/cancellation', + options: _options(lang), + ); + return CourseGeneration.fromJson(response.data!); + } + + /// "이 코스, 마음에 드세요?" — 결과가 나온 생성만 받는다 + Future rate( + String id, + CourseRating value, { + required String lang, + }) async { + final response = await _dio.put>( + '$_generations/$id/rating', + data: {'value': value.name}, + options: _options(lang), + ); + return CourseGeneration.fromJson(response.data!); + } +} + +final aiCourseRepositoryProvider = Provider( + (ref) => AiCourseRepository(ref.watch(dioProvider)), +); diff --git a/flutter/lib/features/ai_course/domain/ai_course_draft.dart b/flutter/lib/features/ai_course/domain/ai_course_draft.dart new file mode 100644 index 0000000..df7657b --- /dev/null +++ b/flutter/lib/features/ai_course/domain/ai_course_draft.dart @@ -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 inferred; + + /// 사용자가 고친 항목 — 문장을 고쳐 다시 해석해도 이 값을 지킨다 + final Set edited; + + /// 아직 풀지 않은 것(그 항목을 고치면 사라진다) + final List 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? inferred, + Set? edited, + List? 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 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 keep, + }) { + T pick(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, + ), + ); + } +} diff --git a/flutter/lib/features/ai_course/domain/condition_rules.dart b/flutter/lib/features/ai_course/domain/condition_rules.dart new file mode 100644 index 0000000..c5d11a7 --- /dev/null +++ b/flutter/lib/features/ai_course/domain/condition_rules.dart @@ -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 _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'; diff --git a/flutter/lib/features/ai_course/domain/course_swap.dart b/flutter/lib/features/ai_course/domain/course_swap.dart new file mode 100644 index 0000000..c4ad535 --- /dev/null +++ b/flutter/lib/features/ai_course/domain/course_swap.dart @@ -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 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 alternatesBySlot(CourseResult result) { + final bySlot = {}; + 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 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, + ); +} diff --git a/flutter/lib/features/ai_course/domain/generation_steps.dart b/flutter/lib/features/ai_course/domain/generation_steps.dart new file mode 100644 index 0000000..82ac7eb --- /dev/null +++ b/flutter/lib/features/ai_course/domain/generation_steps.dart @@ -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 _kStepProgress = [0.1, 0.3, 0.6, 0.9, 1]; + +double progressValue(GenerationStatus status, GenerationStage? stage) => + _kStepProgress[activeStepIndex(status, stage)]; diff --git a/flutter/lib/features/ai_course/presentation/pages/ai_course_generation_page.dart b/flutter/lib/features/ai_course/presentation/pages/ai_course_generation_page.dart new file mode 100644 index 0000000..d4ab055 --- /dev/null +++ b/flutter/lib/features/ai_course/presentation/pages/ai_course_generation_page.dart @@ -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 createState() => + _AiCourseGenerationPageState(); +} + +class _AiCourseGenerationPageState + extends ConsumerState { + 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( + 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 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), + ), + ); + } +} diff --git a/flutter/lib/features/ai_course/presentation/pages/ai_course_input_page.dart b/flutter/lib/features/ai_course/presentation/pages/ai_course_input_page.dart new file mode 100644 index 0000000..8f81226 --- /dev/null +++ b/flutter/lib/features/ai_course/presentation/pages/ai_course_input_page.dart @@ -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 createState() => _AiCourseInputPageState(); +} + +class _AiCourseInputPageState extends ConsumerState { + 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 _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 onChanged; + final ValueChanged 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( + 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), + ), + ], + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/ai_course/presentation/providers/ai_course_providers.dart b/flutter/lib/features/ai_course/presentation/providers/ai_course_providers.dart new file mode 100644 index 0000000..ab9ca3a --- /dev/null +++ b/flutter/lib/features/ai_course/presentation/providers/ai_course_providers.dart @@ -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( + (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 save(String id) => + _prefs.setString(PrefKeys.pendingGeneration, id); + + /// [id] 가 저장된 값일 때만 지운다(다른 생성이 이미 시작됐으면 그대로 둔다) + Future clear(String id) async { + if (read() == id) await _prefs.remove(PrefKeys.pendingGeneration); + } +} + +final pendingGenerationProvider = Provider( + (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.new, + ); + +class AiCourseDraftNotifier extends Notifier { + 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 useExample(String text) { + _debounce?.cancel(); + state = state.copyWith(text: text); + return parseNow(); + } + + /// 지금 해석한다(키보드 '완료'·예시·다시 시도). 같은 문장을 이미 해석했으면 부르지 않는다 + Future 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 fields, + ) { + final current = state.conditions; + if (current == null) return; + state = state.applyEdit(change(current), fields); + } + + /// 생성을 시작한다. 실패하면 그대로 던진다(화면이 에러 코드로 나눈다). + /// [previousGenerationId] 는 결과 화면의 '다시 만들기' — 서버가 지난 결과와 다른 곳을 고른다 + Future 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.new); + +class GenerationNotifier extends Notifier { + 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 _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 _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 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 retry() { + _failures = 0; + state = state.copyWith(clearError: true); + return _poll(); + } + + /// 취소 — AI 호출을 끊는다. 차감하지 않는다 + Future 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(); + } + } +} diff --git a/flutter/lib/features/ai_course/presentation/widgets/ai_course_title.dart b/flutter/lib/features/ai_course/presentation/widgets/ai_course_title.dart new file mode 100644 index 0000000..3f182f8 --- /dev/null +++ b/flutter/lib/features/ai_course/presentation/widgets/ai_course_title.dart @@ -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, + ), + ], + ], + ); + } +} diff --git a/flutter/lib/features/ai_course/presentation/widgets/ai_home_section.dart b/flutter/lib/features/ai_course/presentation/widgets/ai_home_section.dart new file mode 100644 index 0000000..bd75b5a --- /dev/null +++ b/flutter/lib/features/ai_course/presentation/widgets/ai_home_section.dart @@ -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, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/flutter/lib/features/ai_course/presentation/widgets/condition_labels.dart b/flutter/lib/features/ai_course/presentation/widgets/condition_labels.dart new file mode 100644 index 0000000..f503de3 --- /dev/null +++ b/flutter/lib/features/ai_course/presentation/widgets/condition_labels.dart @@ -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); +} diff --git a/flutter/lib/features/ai_course/presentation/widgets/condition_section.dart b/flutter/lib/features/ai_course/presentation/widgets/condition_section.dart new file mode 100644 index 0000000..880c054 --- /dev/null +++ b/flutter/lib/features/ai_course/presentation/widgets/condition_section.dart @@ -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 _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 _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 _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 _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 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 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, + ), + ], + ), + ); + } +} diff --git a/flutter/lib/features/ai_course/presentation/widgets/condition_sheets.dart b/flutter/lib/features/ai_course/presentation/widgets/condition_sheets.dart new file mode 100644 index 0000000..ad18fef --- /dev/null +++ b/flutter/lib/features/ai_course/presentation/widgets/condition_sheets.dart @@ -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 showPetSheet(BuildContext context, {PetCondition? current}) { + return showModalBottomSheet( + 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), + ), + ], + ), + ), + ); + } +} diff --git a/flutter/lib/features/ai_course/presentation/widgets/course_result_view.dart b/flutter/lib/features/ai_course/presentation/widgets/course_result_view.dart new file mode 100644 index 0000000..a7ba0c2 --- /dev/null +++ b/flutter/lib/features/ai_course/presentation/widgets/course_result_view.dart @@ -0,0 +1,1027 @@ +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/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/data/models/course_generation.dart'; +import 'package:wandergo/features/ai_course/domain/condition_rules.dart'; +import 'package:wandergo/features/ai_course/domain/course_swap.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/course_stop_tile.dart'; +import 'package:wandergo/features/ai_course/presentation/widgets/quota_exhausted_sheet.dart'; +import 'package:wandergo/features/ai_course/presentation/widgets/regenerate_sheet.dart'; +import 'package:wandergo/features/place/presentation/widgets/place_visuals.dart' + show petSpan; +import 'package:wandergo/shared/widgets/character_image.dart'; +import 'package:wandergo/shared/widgets/load_error.dart'; + +/// 10 AI 결과 — 요청 · 요약 · 코스 순서 · 바꾸기 후보 · 평가 · AI 생성 표시와 출처 · 다시 만들기(설계 ai-course §3.7). +/// 시안(AiCourse)대로 코스 제목 없이 'AI 추천 코스'와 요약으로 시작한다. +/// 저장(로그인 뒤)과 지도 미리보기(지도 SDK 뒤)는 그 기능을 만들 때 붙인다. +class CourseResultView extends ConsumerStatefulWidget { + const CourseResultView({ + super.key, + required this.generation, + required this.result, + }); + + final CourseGeneration generation; + final CourseResult result; + + @override + ConsumerState createState() => _CourseResultViewState(); +} + +class _CourseResultViewState extends ConsumerState { + /// 바꾸기 후보로 바꾼 자리(한 번에 한 곳) + int? _swapped; + bool _rating = false; + bool _regenerating = false; + + void _tell(String message) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + Future _rate(CourseRating value) async { + if (_rating) return; + final thanks = context.l10n.aiRatingThanks; + setState(() => _rating = true); + try { + await ref + .read(generationProvider(widget.generation.id).notifier) + .rate(value); + if (mounted) _tell(thanks); + } catch (error) { + if (mounted) _tell(errorMessageOf(error)); + } finally { + if (mounted) setState(() => _rating = false); + } + } + + /// 같은 조건으로 다른 곳을 골라 새로 만든다 — 하루 횟수가 차감되므로 한 번 묻는다 + Future _regenerate() async { + final quota = ref.read(aiQuotaProvider).value; + if (quota != null && quota.remaining <= 0) { + await showQuotaExhaustedSheet(context); + return; + } + final confirmed = await showRegenerateSheet( + context, + remaining: quota?.remaining, + ); + if (!confirmed || !mounted) return; + setState(() => _regenerating = true); + try { + final created = await ref + .read(aiCourseDraftProvider.notifier) + .submit(previousGenerationId: widget.generation.id); + if (!mounted) return; + context.pushReplacement(AppRoutes.aiCourseGeneration(created.id)); + } catch (error) { + if (!mounted) return; + setState(() => _regenerating = false); + switch (errorCodeOf(error)) { + case 'AI_001': + ref.invalidate(aiQuotaProvider); + await showQuotaExhaustedSheet(context); + case 'AI_004': + final pending = ref.read(pendingGenerationProvider).read(); + if (pending != null) { + context.pushReplacement(AppRoutes.aiCourseGeneration(pending)); + return; + } + _tell(errorMessageOf(error)); + default: + _tell(errorMessageOf(error)); + } + } + } + + void _open(CourseStop stop) { + unawaited( + context.push( + stop.kind == StopKind.event + ? AppRoutes.eventDetail(stop.id) + : AppRoutes.placeDetail(stop.id), + ), + ); + } + + @override + Widget build(BuildContext context) { + final result = widget.result; + final draft = ref.watch(aiCourseDraftProvider); + // 이 입력으로 만든 결과일 때만 요청 · 조건과 '다시 만들기'를 보인다(앱을 다시 켜 이어 보면 입력이 없다) + final conditions = draft.generationId == widget.generation.id + ? draft.conditions + : null; + final stops = stopsWithSwap(result, _swapped); + final totals = totalsOf(stops); + final showPet = + conditions?.pet != null || + (conditions == null && totals.petConfirmed > 0); + final alternates = alternatesBySlot(result); + + return Column( + children: [ + Expanded( + child: ListView( + padding: const EdgeInsets.only(top: 20, bottom: 28), + children: [ + if (conditions != null) + _Inset( + child: _RequestSection( + text: draft.text, + conditions: conditions, + ), + ), + _Inset( + child: _SummarySection( + result: result, + totals: totals, + showPet: showPet, + isDog: conditions?.pet?.kind == PetKind.dog, + topPadding: conditions != null ? 24 : 0, + ), + ), + _Inset( + child: _OrderSection( + stops: stops, + showPet: showPet, + onOpen: _open, + ), + ), + if (alternates.isNotEmpty) + _AlternatesSection( + stops: result.stops, + alternates: alternates, + swapped: _swapped, + showPet: showPet, + onSwap: (slot) => + setState(() => _swapped = _swapped == slot ? null : slot), + ), + _Inset( + child: _RatingSection( + rating: widget.generation.rating, + busy: _rating, + onRate: _rate, + ), + ), + _Inset(child: _FooterSection(sources: result.sources)), + ], + ), + ), + if (conditions != null) + _RegenerateBar(busy: _regenerating, onTap: _regenerate), + ], + ); + } +} + +/// 화면 좌우 여백 — 바꾸기 후보 가로 목록만 끝까지 닿게 여백 밖에 둔다 +class _Inset extends StatelessWidget { + const _Inset({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: kPagePadding), + child: child, + ); + } +} + +/// 아래 구분선 +const BoxDecoration _bottomLine = BoxDecoration( + border: Border(bottom: BorderSide(color: AppColors.divider)), +); + +/// 위 구분선 +const BoxDecoration _topLine = BoxDecoration( + border: Border(top: BorderSide(color: AppColors.divider)), +); + +/// 내가 요청한 코스 — 요청 문장 · 조건(두 칸) · 조건 수정 +class _RequestSection extends ConsumerWidget { + const _RequestSection({required this.text, required this.conditions}); + + final String text; + final CourseConditions conditions; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final textTheme = Theme.of(context).textTheme; + final localeName = l10n.localeName; + final pet = conditions.pet; + final items = <_ConditionItem>[ + (icon: AppIconData.pin, label: areaLabel(conditions.area, l10n, ref)), + ( + icon: AppIconData.calendar, + label: + '${formatCourseDate(conditions.date, localeName: localeName)} ' + '${formatTimeRange(conditions.start, conditions.end)}', + ), + (icon: AppIconData.people, label: l10n.aiPartyCount(conditions.party)), + if (pet != null) (icon: AppIconData.pawSolid, label: petLabel(pet, l10n)), + if (conditions.indoorIfRain) + (icon: AppIconData.umbrella, label: l10n.aiResultIndoor), + (icon: AppIconData.walk, label: mobilityLabel(conditions.mobility, l10n)), + ]; + return DecoratedBox( + decoration: _bottomLine, + child: Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.aiResultRequestTitle, style: textTheme.bodySmall), + const SizedBox(height: 6), + Text( + keepAll('“${text.trim()}”'), + style: textTheme.titleSmall?.copyWith( + fontSize: 17, + height: 25 / 17, + ), + ), + const SizedBox(height: 14), + _ConditionGrid(items: items), + // 누르기 쉽게 44 높이로 두고 위아래 여백을 그만큼 줄인다(시안 margin -6 · -10) + const SizedBox(height: 8), + TextButton.icon( + onPressed: () => Navigator.of(context).maybePop(), + style: TextButton.styleFrom( + foregroundColor: AppColors.textSecondary, + padding: EdgeInsets.zero, + minimumSize: const Size(0, 44), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + textStyle: textTheme.labelLarge, + ), + icon: const AppIcon( + AppIconData.pencil, + size: 16, + color: AppColors.textSecondary, + ), + label: Text(l10n.aiEditConditions), + ), + ], + ), + ), + ); + } +} + +typedef _ConditionItem = ({AppIconData icon, String label}); + +/// 조건 — 두 칸 격자, 아이콘 + 글자. 반려동물은 초록 굵은 글자 +class _ConditionGrid extends StatelessWidget { + const _ConditionGrid({required this.items}); + + final List<_ConditionItem> items; + + @override + Widget build(BuildContext context) { + final style = Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(height: 18 / 13, color: AppColors.label); + return LayoutBuilder( + builder: (context, constraints) { + const gap = 12.0; + final cell = (constraints.maxWidth - gap) / 2; + return Wrap( + spacing: gap, + runSpacing: 10, + children: [ + for (final item in items) + SizedBox( + width: cell, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 1), + child: AppIcon( + item.icon, + size: 16, + color: item.icon == AppIconData.pawSolid + ? AppColors.pet + : AppColors.textTertiary, + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + keepAll(item.label), + style: item.icon == AppIconData.pawSolid + ? style?.copyWith( + color: AppColors.pet, + fontWeight: FontWeight.w600, + ) + : style, + ), + ), + ], + ), + ), + ], + ); + }, + ); + } +} + +/// AI 추천 코스 — 요약과 그림 · 날씨 · 알림 · 숫자 요약 +class _SummarySection extends StatelessWidget { + const _SummarySection({ + required this.result, + required this.totals, + required this.showPet, + required this.isDog, + required this.topPadding, + }); + + final CourseResult result; + final CourseTotals totals; + final bool showPet; + + /// 반려견과 함께면 '반려견 동반', 아니면 '반려동물 동반' + final bool isDog; + + /// 요청 칸이 위에 있으면 24, 없으면(이어 보기) 0 + final double topPadding; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final textTheme = Theme.of(context).textTheme; + final meta = textTheme.bodyMedium?.copyWith( + height: 18 / 13, + color: AppColors.textSecondary, + ); + final weather = result.weather; + final notices = [ + ...result.notices, + for (final notice in result.planNotices) planNoticeText(notice, l10n), + ]; + return DecoratedBox( + decoration: _bottomLine, + child: Padding( + padding: EdgeInsets.only(top: topPadding, bottom: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const AppIcon( + AppIconData.sparkle, + size: 20, + color: AppColors.brand, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + l10n.aiResultHeading, + style: textTheme.titleMedium, + ), + ), + ], + ), + if (result.summary.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + keepAll(result.summary), + style: textTheme.bodyLarge?.copyWith( + fontSize: 14, + height: 22 / 14, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 12), + const Padding( + padding: EdgeInsets.only(top: 2), + child: CharacterImage(width: 72, height: 52), + ), + ], + ), + if (weather != null) ...[ + const SizedBox(height: 14), + _WeatherLine(weather: weather, style: meta), + ], + for (final notice in notices) ...[ + const SizedBox(height: 10), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 1), + child: AppIcon( + AppIconData.info, + size: 16, + color: AppColors.textSecondary, + ), + ), + const SizedBox(width: 6), + Expanded(child: Text(keepAll(notice), style: meta)), + ], + ), + ], + const SizedBox(height: 18), + _Stats( + result: result, + totals: totals, + showPet: showPet, + isDog: isDog, + ), + ], + ), + ), + ); + } +} + +/// 날씨 — "토요일 비 60%"만 파란 굵은 글자, 뒤에 " · 12–15°C" +class _WeatherLine extends StatelessWidget { + const _WeatherLine({required this.weather, required this.style}); + + final CourseWeather weather; + final TextStyle? style; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final temperature = switch (( + weather.temperatureMin, + weather.temperatureMax, + )) { + (final min?, final max?) => '${min.round()}–${max.round()}°C', + _ => null, + }; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 1), + child: AppIcon( + AppIconData.rainSolid, + size: 16, + color: AppColors.rain, + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text.rich( + TextSpan( + children: [ + TextSpan( + text: l10n.aiRainChance( + formatWeekday(weather.date, localeName: l10n.localeName), + weather.precipitationProbability, + ), + style: const TextStyle( + fontWeight: FontWeight.w600, + color: AppColors.rain, + ), + ), + if (temperature != null) TextSpan(text: ' · $temperature'), + ], + ), + style: style, + ), + ), + ], + ); + } +} + +/// 숫자 요약 — 장소 · 전체 일정 · 이동 · 반려 동반, 값이 위 · 이름이 아래(가운데 정렬) +class _Stats extends StatelessWidget { + const _Stats({ + required this.result, + required this.totals, + required this.showPet, + required this.isDog, + }); + + final CourseResult result; + final CourseTotals totals; + final bool showPet; + final bool isDog; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final textTheme = Theme.of(context).textTheme; + final travelLabel = switch (result.mobility) { + Mobility.walk => l10n.aiStatTravelWalk, + Mobility.transit => l10n.aiStatTravelTransit, + Mobility.car => l10n.aiStatTravelCar, + }; + final stats = <({String label, String value, bool pet})>[ + ( + label: l10n.aiStatStops, + value: l10n.aiStatStopsValue(totals.stops), + pet: false, + ), + ( + label: l10n.aiStatTotal, + value: durationLabel(totals.totalMinutes, l10n), + pet: false, + ), + ( + label: travelLabel, + value: durationLabel(totals.travelMinutes, l10n), + pet: false, + ), + if (showPet) + ( + label: isDog ? l10n.aiStatPetDog : l10n.aiStatPet, + value: l10n.aiStatStopsValue(totals.petConfirmed), + pet: true, + ), + ]; + final valueStyle = textTheme.titleSmall?.copyWith( + fontSize: 16, + height: 22 / 16, + fontWeight: FontWeight.w700, + ); + return DecoratedBox( + decoration: const BoxDecoration( + border: Border.symmetric( + horizontal: BorderSide(color: AppColors.divider), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final stat in stats) + Expanded( + child: Column( + children: [ + Text( + stat.value, + textAlign: TextAlign.center, + style: stat.pet + ? valueStyle?.copyWith(color: AppColors.pet) + : valueStyle, + ), + const SizedBox(height: 2), + Text( + keepAll(stat.label), + textAlign: TextAlign.center, + style: textTheme.bodySmall, + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// 앱이 남긴 알림의 문구 — 서버는 코드만 주고 문구는 앱이 언어별로 만든다 +String planNoticeText(PlanNotice notice, AppLocalizations l10n) => + switch (notice.code) { + PlanNoticeCode.stopRemoved => l10n.aiPlanStopRemoved( + notice.name ?? l10n.aiOneStop, + switch (notice.cause) { + 'closed' => l10n.aiCauseClosed, + 'last_order' => l10n.aiCauseLastOrder, + 'window' => l10n.aiCauseWindow, + 'leg_too_long' => l10n.aiCauseLegTooLong, + 'pet' => l10n.aiCausePet, + _ => l10n.aiCauseOther, + }, + ), + PlanNoticeCode.indoorRatioUnmet => l10n.aiPlanIndoorUnmet, + }; + +/// 코스 순서 — 출발 · 마침과 장소 타임라인 +class _OrderSection extends StatelessWidget { + const _OrderSection({ + required this.stops, + required this.showPet, + required this.onOpen, + }); + + final List stops; + final bool showPet; + final ValueChanged onOpen; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final textTheme = Theme.of(context).textTheme; + return Padding( + padding: const EdgeInsets.only(top: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Expanded( + child: Text(l10n.aiOrderTitle, style: textTheme.titleMedium), + ), + if (stops.isNotEmpty) ...[ + const SizedBox(width: 12), + Text( + l10n.aiOrderTimes( + formatKstTime(stops.first.arrive), + formatKstTime(stops.last.leave), + ), + style: textTheme.bodyMedium?.copyWith(height: 18 / 13), + ), + ], + ], + ), + const SizedBox(height: 16), + for (final (index, stop) in stops.indexed) + CourseStopTile( + number: index + 1, + stop: stop, + isLast: index == stops.length - 1, + showPet: showPet, + onOpen: () => onOpen(stop), + ), + ], + ), + ); + } +} + +/// 이런 곳으로 바꿀 수도 있어요 — 가로로 넘기는 후보. 한 번에 한 곳만 바꾸고, 바꾼 자리는 '원래 장소로'가 된다 +class _AlternatesSection extends StatelessWidget { + const _AlternatesSection({ + required this.stops, + required this.alternates, + required this.swapped, + required this.showPet, + required this.onSwap, + }); + + /// 원래 장소들 + final List stops; + final Map alternates; + final int? swapped; + final bool showPet; + final ValueChanged onSwap; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final textTheme = Theme.of(context).textTheme; + final slots = alternates.keys.toList()..sort(); + return Padding( + padding: const EdgeInsets.only(top: 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Inset( + child: DecoratedBox( + decoration: _topLine, + child: Padding( + padding: const EdgeInsets.only(top: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.aiAlternatesTitle, style: textTheme.titleMedium), + const SizedBox(height: 4), + Text( + keepAll(l10n.aiAlternatesBody), + style: textTheme.bodyMedium?.copyWith(height: 18 / 13), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 14), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: kPagePadding), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final (index, slot) in slots.indexed) ...[ + if (index > 0) const SizedBox(width: 12), + _AlternateCard( + label: swapped == slot + ? l10n.aiAlternateRevert(slot + 1) + : l10n.aiAlternateFor( + slot + 1, + stopCategoryLabel(alternates[slot]!.stop, l10n), + ), + isRevert: swapped == slot, + // 바꾼 자리에는 원래 장소를 보여 되돌릴 수 있게 한다 + stop: swapped == slot + ? stops[slot] + : alternates[slot]!.stop, + showPet: showPet, + onTap: () => onSwap(slot), + ), + ], + ], + ), + ), + ], + ), + ); + } +} + +/// 후보 한 칸 — 사진 · "1번 대신 · 체험" · 이름 · 반려 동반 +class _AlternateCard extends StatelessWidget { + const _AlternateCard({ + required this.label, + required this.isRevert, + required this.stop, + required this.showPet, + required this.onTap, + }); + + /// 시안의 카드 너비 + static const double _width = 148; + + final String label; + + /// 바꾼 자리를 되돌리는 칸 — 이름 위 글자를 브랜드색으로 보인다 + final bool isRevert; + final CourseStop stop; + final bool showPet; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final textTheme = Theme.of(context).textTheme; + final pet = showPet ? stopPetLabel(stop, l10n) : null; + return SizedBox( + width: _width, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(kRadius), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CourseStopImage(stop: stop, width: _width, height: 104), + const SizedBox(height: 10), + Text( + label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: isRevert + ? textTheme.bodySmall?.copyWith( + color: AppColors.brand, + fontWeight: FontWeight.w600, + ) + : textTheme.bodySmall, + ), + const SizedBox(height: 4), + Text( + stop.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.titleSmall?.copyWith(height: 20 / 15), + ), + if (pet != null) ...[ + const SizedBox(height: 4), + Text.rich( + petSpan(pet, fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodySmall, + ), + ], + ], + ), + ), + ); + } +} + +/// 이 코스, 마음에 드세요? — 좋아요 · 아쉬워요(테두리 원 버튼) +class _RatingSection extends StatelessWidget { + const _RatingSection({ + required this.rating, + required this.busy, + required this.onRate, + }); + + final CourseRating? rating; + final bool busy; + final ValueChanged onRate; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Container( + margin: const EdgeInsets.only(top: 28), + padding: const EdgeInsets.symmetric(vertical: 20), + decoration: const BoxDecoration( + border: Border.symmetric( + horizontal: BorderSide(color: AppColors.divider), + ), + ), + child: Row( + children: [ + Expanded( + child: Text( + keepAll(l10n.aiRatingTitle), + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontSize: 14, height: 20 / 14), + ), + ), + const SizedBox(width: 12), + _RatingButton( + icon: AppIconData.thumbUp, + label: l10n.aiRatingLike, + selected: rating == CourseRating.like, + onPressed: busy ? null : () => onRate(CourseRating.like), + ), + const SizedBox(width: 8), + _RatingButton( + icon: AppIconData.thumbDown, + label: l10n.aiRatingDislike, + selected: rating == CourseRating.dislike, + onPressed: busy ? null : () => onRate(CourseRating.dislike), + ), + ], + ), + ); + } +} + +class _RatingButton extends StatelessWidget { + const _RatingButton({ + required this.icon, + required this.label, + required this.selected, + required this.onPressed, + }); + + final AppIconData icon; + final String label; + final bool selected; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return IconButton( + tooltip: label, + isSelected: selected, + onPressed: onPressed, + style: IconButton.styleFrom( + fixedSize: const Size.square(44), + backgroundColor: selected ? AppColors.brandSoft : AppColors.background, + side: BorderSide( + color: selected ? AppColors.chipBorderSelected : AppColors.chipBorder, + ), + ), + icon: AppIcon( + icon, + size: 20, + color: selected ? AppColors.brand : AppColors.textSecondary, + filled: selected, + ), + ); + } +} + +/// AI 생성 표시와 출처 +class _FooterSection extends StatelessWidget { + const _FooterSection({required this.sources}); + + final List sources; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final style = Theme.of( + context, + ).textTheme.bodySmall?.copyWith(height: 18 / 12); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 20), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 1), + child: AppIcon( + AppIconData.notice, + size: 16, + color: AppColors.textSecondary, + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + keepAll(l10n.aiGeneratedNote), + style: style?.copyWith(color: AppColors.textSecondary), + ), + ), + ], + ), + if (sources.isNotEmpty) + Container( + width: double.infinity, + margin: const EdgeInsets.only(top: 16), + padding: const EdgeInsets.only(top: 16), + decoration: _topLine, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final source in sources) + Text( + keepAll(source), + style: style?.copyWith(color: AppColors.textHint), + ), + ], + ), + ), + ], + ); + } +} + +/// 아래 고정 — 다시 만들기(하루 횟수가 차감된다). '코스로 저장'은 로그인 기능 뒤에 붙인다 +class _RegenerateBar extends StatelessWidget { + const _RegenerateBar({required this.busy, required this.onTap}); + + final bool busy; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + 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: OutlinedButton.icon( + onPressed: busy ? null : onTap, + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(52), + side: const BorderSide(color: AppColors.chipBorder), + textStyle: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(fontSize: 15), + ), + icon: busy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const AppIcon( + AppIconData.refresh, + size: 18, + strokeWidth: 2, + color: AppColors.text, + ), + label: Text(context.l10n.aiRegenerate), + ), + ), + ), + ); + } +} diff --git a/flutter/lib/features/ai_course/presentation/widgets/course_stop_tile.dart b/flutter/lib/features/ai_course/presentation/widgets/course_stop_tile.dart new file mode 100644 index 0000000..152d2f5 --- /dev/null +++ b/flutter/lib/features/ai_course/presentation/widgets/course_stop_tile.dart @@ -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 []; + 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), + ], + ), + ); + } +} diff --git a/flutter/lib/features/ai_course/presentation/widgets/quota_exhausted_sheet.dart b/flutter/lib/features/ai_course/presentation/widgets/quota_exhausted_sheet.dart new file mode 100644 index 0000000..cb34c2d --- /dev/null +++ b/flutter/lib/features/ai_course/presentation/widgets/quota_exhausted_sheet.dart @@ -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 showQuotaExhaustedSheet(BuildContext context) { + return showModalBottomSheet( + 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), + ), + ], + ), + ), + ); + }, + ); +} diff --git a/flutter/lib/features/ai_course/presentation/widgets/regenerate_sheet.dart b/flutter/lib/features/ai_course/presentation/widgets/regenerate_sheet.dart new file mode 100644 index 0000000..ded7b6d --- /dev/null +++ b/flutter/lib/features/ai_course/presentation/widgets/regenerate_sheet.dart @@ -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 showRegenerateSheet(BuildContext context, {int? remaining}) async { + final confirmed = await showModalBottomSheet( + 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; +} diff --git a/flutter/lib/features/home/presentation/pages/home_page.dart b/flutter/lib/features/home/presentation/pages/home_page.dart index b4959ad..9d2588c 100644 --- a/flutter/lib/features/home/presentation/pages/home_page.dart +++ b/flutter/lib/features/home/presentation/pages/home_page.dart @@ -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(), ], ), ), diff --git a/flutter/lib/features/location/presentation/widgets/region_sheet.dart b/flutter/lib/features/location/presentation/widgets/region_sheet.dart index 8eb0745..c0b1fa0 100644 --- a/flutter/lib/features/location/presentation/widgets/region_sheet.dart +++ b/flutter/lib/features/location/presentation/widgets/region_sheet.dart @@ -33,11 +33,15 @@ Future showRegionSheet( BuildContext context, { BaseLocationChoice? selected, bool includeCurrent = true, + String? title, }) { return showModalBottomSheet( context: context, - builder: (context) => - _RegionSheet(selected: selected, includeCurrent: includeCurrent), + builder: (context) => _RegionSheet( + selected: selected, + includeCurrent: includeCurrent, + title: title, + ), ); } @@ -62,11 +66,18 @@ Future 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), diff --git a/flutter/lib/l10n/app_en.arb b/flutter/lib/l10n/app_en.arb index d3e9d4c..c5392b1 100644 --- a/flutter/lib/l10n/app_en.arb +++ b/flutter/lib/l10n/app_en.arb @@ -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." } diff --git a/flutter/lib/l10n/app_ko.arb b/flutter/lib/l10n/app_ko.arb index 6473427..e195187 100644 --- a/flutter/lib/l10n/app_ko.arb +++ b/flutter/lib/l10n/app_ko.arb @@ -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": "비 예보가 있지만 실내 장소가 모자라 실외도 넣었어요." } diff --git a/flutter/lib/shared/widgets/app_back_button.dart b/flutter/lib/shared/widgets/app_back_button.dart new file mode 100644 index 0000000..dc9cce8 --- /dev/null +++ b/flutter/lib/shared/widgets/app_back_button.dart @@ -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), + ); + } +} diff --git a/flutter/lib/shared/widgets/app_switch.dart b/flutter/lib/shared/widgets/app_switch.dart new file mode 100644 index 0000000..2050664 --- /dev/null +++ b/flutter/lib/shared/widgets/app_switch.dart @@ -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? 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), + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/flutter/lib/shared/widgets/character_image.dart b/flutter/lib/shared/widgets/character_image.dart new file mode 100644 index 0000000..e262755 --- /dev/null +++ b/flutter/lib/shared/widgets/character_image.dart @@ -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, + ); + } +} diff --git a/flutter/lib/shared/widgets/load_error.dart b/flutter/lib/shared/widgets/load_error.dart index 0f3991b..8ec875c 100644 --- a/flutter/lib/shared/widgets/load_error.dart +++ b/flutter/lib/shared/widgets/load_error.dart @@ -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, + ), + ), + ], + ), + ); + } +} diff --git a/flutter/test/core/device_id_test.dart b/flutter/test/core/device_id_test.dart new file mode 100644 index 0000000..4863fc8 --- /dev/null +++ b/flutter/test/core/device_id_test.dart @@ -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 = []; + 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 = []; + final id = await loadOrCreateDeviceId( + read: () async => saved, + write: (id) async => written.add(id), + create: () => 'new-id', + ); + expect(id, 'new-id'); + expect(written, ['new-id']); + } + }); + }); +} diff --git a/flutter/test/features/ai_course_domain_test.dart b/flutter/test/features/ai_course_domain_test.dart new file mode 100644 index 0000000..b4664d7 --- /dev/null +++ b/flutter/test/features/ai_course_domain_test.dart @@ -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); + }); + }); +} diff --git a/flutter/test/features/ai_course_models_test.dart b/flutter/test/features/ai_course_models_test.dart new file mode 100644 index 0000000..5a73684 --- /dev/null +++ b/flutter/test/features/ai_course_models_test.dart @@ -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 conditionsJson({ + Map 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 stopJson(String id, {Map? 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)); + }); +} diff --git a/flutter/test/features/ai_course_pages_test.dart b/flutter/test/features/ai_course_pages_test.dart new file mode 100644 index 0000000..b0b1ca5 --- /dev/null +++ b/flutter/test/features/ai_course_pages_test.dart @@ -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 progress; + final int remaining; + final Object? createError; + + final List parsedTexts = []; + final List created = []; + final List canceled = []; + final List polled = []; + final List previousIds = []; + final List rated = []; + int _polls = 0; + + @override + Future quota({required String lang}) async => + CourseQuota(limit: 2, used: 2 - remaining, remaining: remaining); + + @override + Future parseConditions({ + required String text, + required CourseArea baseArea, + required String lang, + }) async { + parsedTexts.add(text); + return parse!; + } + + @override + Future 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 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 cancel(String id, {required String lang}) async { + canceled.add(id); + return CourseGeneration(id: id, status: GenerationStatus.canceled); + } + + @override + Future 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 _pump( + WidgetTester tester, + _FakeAiRepository repo, { + String initialLocation = AppRoutes.aiCourse, + Map 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 _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(_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(_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(_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); + }); +} diff --git a/flutter/test/features/ai_course_swap_test.dart b/flutter/test/features/ai_course_swap_test.dart new file mode 100644 index 0000000..3738d71 --- /dev/null +++ b/flutter/test/features/ai_course_swap_test.dart @@ -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); + }); +} diff --git a/flutter/test/shared/shared_widgets_test.dart b/flutter/test/shared/shared_widgets_test.dart new file mode 100644 index 0000000..718f5c7 --- /dev/null +++ b/flutter/test/shared/shared_widgets_test.dart @@ -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); + }); + }); +} diff --git a/flutter/test/widget_test.dart b/flutter/test/widget_test.dart index 0ee0ce0..01a6dcc 100644 --- a/flutter/test/widget_test.dart +++ b/flutter/test/widget_test.dart @@ -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 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 _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},