generated from ttipo/project-base
feat(flutter): AI 코스 입력·만드는 중·결과 화면(8–10) 추가
백엔드 AI 코스 API(4–6단계)를 앱에서 쓸 수 있게 화면 8–10 을 붙였다(설계 ai-course §14 7단계). - 기반: 첫 실행 때 만든 기기 ID 를 안전 저장소에 두고 X-Device-Id 로 보낸다(비로그인 하루 횟수 기준). 앱 언어를 Accept-Language 로 보내 AI 문구 언어를 맞춘다 - 홈 'AI에게 코스 맡기기' → 입력(8): 문장을 1초 멈추면 조건을 해석해 줄마다 채우고 고칠 수 있다. 서버와 같은 조건 규칙을 앱에서도 먼저 본다. 하루 횟수를 다 썼으면 만들지 않고 안내한다 - 만드는 중(9): 1.5초마다 진행을 조회한다. 나가면 취소하고(차감 없음), 만들던 생성 id 를 남겨 앱을 다시 켜거나 AI_004 를 받으면 이어 본다 - 결과(10): 사실 값 · 경고 · 이동 · 바꾸기 후보(한 번에 한 곳, 횟수 안 듦) · 평가 · 다시 만들기(확인 뒤 차감) · 출처와 AI 생성 표시. 요청 칸과 다시 만들기는 이 기기에서 입력한 생성일 때만 보인다 - 디자인 캔버스와 맞춤: 결과의 코스 제목은 빼고 'AI 추천 코스'와 요약으로 시작한다(사용자 결정). 마스코트 그림을 홈 · 입력 · 만드는 중 · 결과 · 횟수 소진에 넣었다 - 화면 전체를 못 불러왔을 때의 공통 오류 화면(그림 · 다시 시도 · 오류 코드와 추적 ID)을 만들어 만드는 중 화면에 쓴다 코스로 저장 · 지도 미리보기 · 홈의 비 소식 줄 · 횟수 소진의 로그인 버튼은 그 기능이 생기면 붙인다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
/// AI 코스 조건 — 백엔드 CourseConditionsDto · ParseConditionsResponseDto 와 1:1 대응한다(설계 ai-course §2).
|
||||
library;
|
||||
|
||||
import 'package:wandergo/features/location/domain/base_location.dart';
|
||||
import 'package:wandergo/features/place/data/models/place_models.dart'
|
||||
show PetSize;
|
||||
|
||||
/// 코드값을 enum 으로 읽는다. 모르는 값이면 null — 백엔드가 값을 늘려도 앱이 죽지 않게 한다
|
||||
T? _parseCode<T extends Enum>(
|
||||
List<T> values,
|
||||
Object? value,
|
||||
String Function(T) code,
|
||||
) {
|
||||
for (final v in values) {
|
||||
if (code(v) == value) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 코스 지역 — 동네 · 좌표(현재 위치) · 장소("서문시장 근처")
|
||||
sealed class CourseArea {
|
||||
const CourseArea();
|
||||
|
||||
/// 모르는 모양이면 null
|
||||
static CourseArea? fromJson(Object? json) {
|
||||
if (json is! Map<String, dynamic>) return null;
|
||||
switch (json['type']) {
|
||||
case 'neighborhood':
|
||||
final key = json['key'];
|
||||
for (final n in Neighborhood.values) {
|
||||
if (n.name == key) return NeighborhoodArea(n);
|
||||
}
|
||||
return null;
|
||||
case 'point':
|
||||
final lat = json['lat'];
|
||||
final lng = json['lng'];
|
||||
if (lat is! num || lng is! num) return null;
|
||||
return PointArea(lat: lat.toDouble(), lng: lng.toDouble());
|
||||
case 'place':
|
||||
final placeId = json['placeId'];
|
||||
return placeId is String ? PlaceArea(placeId) : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, Object> toJson();
|
||||
}
|
||||
|
||||
/// 동네(도심 · 수성못 · 앞산)
|
||||
final class NeighborhoodArea extends CourseArea {
|
||||
const NeighborhoodArea(this.neighborhood);
|
||||
|
||||
final Neighborhood neighborhood;
|
||||
|
||||
@override
|
||||
Map<String, Object> toJson() => {
|
||||
'type': 'neighborhood',
|
||||
'key': neighborhood.name,
|
||||
};
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is NeighborhoodArea && other.neighborhood == neighborhood;
|
||||
|
||||
@override
|
||||
int get hashCode => neighborhood.hashCode;
|
||||
}
|
||||
|
||||
/// 현재 위치 좌표 — 대구 안이어야 만들 수 있다
|
||||
final class PointArea extends CourseArea {
|
||||
const PointArea({required this.lat, required this.lng});
|
||||
|
||||
final double lat;
|
||||
final double lng;
|
||||
|
||||
@override
|
||||
Map<String, Object> toJson() => {'type': 'point', 'lat': lat, 'lng': lng};
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is PointArea && other.lat == lat && other.lng == lng;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(lat, lng);
|
||||
}
|
||||
|
||||
/// 장소 DB 의 장소 근처
|
||||
final class PlaceArea extends CourseArea {
|
||||
const PlaceArea(this.placeId);
|
||||
|
||||
final String placeId;
|
||||
|
||||
@override
|
||||
Map<String, Object> toJson() => {'type': 'place', 'placeId': placeId};
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is PlaceArea && other.placeId == placeId;
|
||||
|
||||
@override
|
||||
int get hashCode => placeId.hashCode;
|
||||
}
|
||||
|
||||
enum PetKind {
|
||||
dog,
|
||||
cat,
|
||||
other;
|
||||
|
||||
static PetKind? parse(Object? value) =>
|
||||
_parseCode(values, value, (k) => k.name);
|
||||
}
|
||||
|
||||
/// 함께 가는 반려동물
|
||||
class PetCondition {
|
||||
const PetCondition({required this.kind, this.size, this.count = 1});
|
||||
|
||||
/// 모르는 종류면 null
|
||||
static PetCondition? fromJson(Object? json) {
|
||||
if (json is! Map<String, dynamic>) return null;
|
||||
final kind = PetKind.parse(json['kind']);
|
||||
if (kind == null) return null;
|
||||
final count = json['count'];
|
||||
return PetCondition(
|
||||
kind: kind,
|
||||
size: PetSize.parse(json['size']),
|
||||
count: count is int && count > 0 ? count : 1,
|
||||
);
|
||||
}
|
||||
|
||||
final PetKind kind;
|
||||
|
||||
/// 개가 아니거나 모르면 null
|
||||
final PetSize? size;
|
||||
final int count;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'kind': kind.name,
|
||||
'size': size?.name,
|
||||
'count': count,
|
||||
};
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is PetCondition &&
|
||||
other.kind == kind &&
|
||||
other.size == size &&
|
||||
other.count == count;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(kind, size, count);
|
||||
}
|
||||
|
||||
/// 장소 사이 이동 수단
|
||||
enum Mobility {
|
||||
walk,
|
||||
transit,
|
||||
car;
|
||||
|
||||
static Mobility? parse(Object? value) =>
|
||||
_parseCode(values, value, (m) => m.name);
|
||||
}
|
||||
|
||||
/// 생성 조건(사용자가 확인한 값). 날짜·시각은 한국 기준 'YYYY-MM-DD' · 'HH:mm'(끝은 '24:00' 까지)
|
||||
class CourseConditions {
|
||||
const CourseConditions({
|
||||
required this.area,
|
||||
required this.date,
|
||||
required this.start,
|
||||
required this.end,
|
||||
required this.party,
|
||||
required this.pet,
|
||||
required this.mobility,
|
||||
required this.indoorIfRain,
|
||||
});
|
||||
|
||||
/// 모양이 틀리면 null(지역 · 날짜 · 시각이 없으면 만들 수 없다)
|
||||
static CourseConditions? fromJson(Object? json) {
|
||||
if (json is! Map<String, dynamic>) return null;
|
||||
final area = CourseArea.fromJson(json['area']);
|
||||
final date = json['date'];
|
||||
final start = json['start'];
|
||||
final end = json['end'];
|
||||
if (area == null || date is! String || start is! String || end is! String) {
|
||||
return null;
|
||||
}
|
||||
final party = json['party'];
|
||||
return CourseConditions(
|
||||
area: area,
|
||||
date: date,
|
||||
start: start,
|
||||
end: end,
|
||||
party: party is int && party > 0 ? party : 2,
|
||||
pet: PetCondition.fromJson(json['pet']),
|
||||
mobility: Mobility.parse(json['mobility']) ?? Mobility.walk,
|
||||
indoorIfRain: json['indoorIfRain'] != false,
|
||||
);
|
||||
}
|
||||
|
||||
final CourseArea area;
|
||||
final String date;
|
||||
final String start;
|
||||
final String end;
|
||||
final int party;
|
||||
final PetCondition? pet;
|
||||
final Mobility mobility;
|
||||
final bool indoorIfRain;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'area': area.toJson(),
|
||||
'date': date,
|
||||
'start': start,
|
||||
'end': end,
|
||||
'party': party,
|
||||
'pet': pet?.toJson(),
|
||||
'mobility': mobility.name,
|
||||
'indoorIfRain': indoorIfRain,
|
||||
};
|
||||
|
||||
CourseConditions copyWith({
|
||||
CourseArea? area,
|
||||
String? date,
|
||||
String? start,
|
||||
String? end,
|
||||
int? party,
|
||||
Mobility? mobility,
|
||||
bool? indoorIfRain,
|
||||
}) => CourseConditions(
|
||||
area: area ?? this.area,
|
||||
date: date ?? this.date,
|
||||
start: start ?? this.start,
|
||||
end: end ?? this.end,
|
||||
party: party ?? this.party,
|
||||
pet: pet,
|
||||
mobility: mobility ?? this.mobility,
|
||||
indoorIfRain: indoorIfRain ?? this.indoorIfRain,
|
||||
);
|
||||
|
||||
/// 반려동물만 바꾼다(없앨 때는 null)
|
||||
CourseConditions withPet(PetCondition? value) => CourseConditions(
|
||||
area: area,
|
||||
date: date,
|
||||
start: start,
|
||||
end: end,
|
||||
party: party,
|
||||
pet: value,
|
||||
mobility: mobility,
|
||||
indoorIfRain: indoorIfRain,
|
||||
);
|
||||
}
|
||||
|
||||
/// 조건 항목 — 조건 해석이 문장에서 뽑은 항목(`inferred`)과 사용자가 고친 항목을 가리킨다
|
||||
enum ConditionField {
|
||||
area,
|
||||
date,
|
||||
start,
|
||||
end,
|
||||
party,
|
||||
pet,
|
||||
mobility,
|
||||
indoorIfRain;
|
||||
|
||||
static ConditionField? parse(Object? value) =>
|
||||
_parseCode(values, value, (f) => f.name);
|
||||
}
|
||||
|
||||
/// 조건 해석이 되묻거나 알릴 것(설계 §2)
|
||||
enum ConditionIssue {
|
||||
unsupportedArea('unsupported_area'),
|
||||
unknownPlace('unknown_place'),
|
||||
pastDate('past_date'),
|
||||
dateOutOfRange('date_out_of_range'),
|
||||
multiDay('multi_day'),
|
||||
tooShort('too_short'),
|
||||
pastMidnight('past_midnight'),
|
||||
unclearRequest('unclear_request');
|
||||
|
||||
const ConditionIssue(this.code);
|
||||
|
||||
final String code;
|
||||
|
||||
static ConditionIssue? parse(Object? value) =>
|
||||
_parseCode(values, value, (i) => i.code);
|
||||
}
|
||||
|
||||
/// 강수 형태(강수확률이 가장 높은 때)
|
||||
enum PrecipitationType {
|
||||
none('none'),
|
||||
rain('rain'),
|
||||
rainSnow('rain_snow'),
|
||||
snow('snow'),
|
||||
shower('shower');
|
||||
|
||||
const PrecipitationType(this.code);
|
||||
|
||||
final String code;
|
||||
|
||||
static PrecipitationType? parse(Object? value) =>
|
||||
_parseCode(values, value, (p) => p.code);
|
||||
}
|
||||
|
||||
/// 하늘 상태(가장 잦은 것)
|
||||
enum SkyState {
|
||||
clear('clear'),
|
||||
partlyCloudy('partly_cloudy'),
|
||||
cloudy('cloudy');
|
||||
|
||||
const SkyState(this.code);
|
||||
|
||||
final String code;
|
||||
|
||||
static SkyState? parse(Object? value) =>
|
||||
_parseCode(values, value, (s) => s.code);
|
||||
}
|
||||
|
||||
/// 그날 시간 범위의 날씨(기상청 단기예보). 예보가 없는 날(4일 뒤부터)은 응답에서 null 이다
|
||||
class CourseWeather {
|
||||
const CourseWeather({
|
||||
required this.date,
|
||||
required this.precipitationProbability,
|
||||
this.precipitationType,
|
||||
this.sky,
|
||||
this.temperatureMin,
|
||||
this.temperatureMax,
|
||||
this.issuedAt,
|
||||
});
|
||||
|
||||
static CourseWeather? fromJson(Object? json) {
|
||||
if (json is! Map<String, dynamic>) return null;
|
||||
final date = json['date'];
|
||||
final probability = json['precipitationProbability'];
|
||||
if (date is! String || probability is! num) return null;
|
||||
final issuedAt = json['issuedAt'];
|
||||
return CourseWeather(
|
||||
date: date,
|
||||
precipitationProbability: probability.round(),
|
||||
precipitationType: PrecipitationType.parse(json['precipitationType']),
|
||||
sky: SkyState.parse(json['sky']),
|
||||
temperatureMin: (json['temperatureMin'] as num?)?.toDouble(),
|
||||
temperatureMax: (json['temperatureMax'] as num?)?.toDouble(),
|
||||
issuedAt: issuedAt is String ? DateTime.tryParse(issuedAt) : null,
|
||||
);
|
||||
}
|
||||
|
||||
final String date;
|
||||
|
||||
/// 시간 범위 중 가장 높은 강수확률(%)
|
||||
final int precipitationProbability;
|
||||
final PrecipitationType? precipitationType;
|
||||
final SkyState? sky;
|
||||
final double? temperatureMin;
|
||||
final double? temperatureMax;
|
||||
|
||||
/// 발표 시각(UTC)
|
||||
final DateTime? issuedAt;
|
||||
}
|
||||
|
||||
/// 조건 해석 결과(`POST /course-conditions`) — 횟수를 차감하지 않는다
|
||||
class ConditionParse {
|
||||
const ConditionParse({
|
||||
required this.conditions,
|
||||
this.inferred = const {},
|
||||
this.issues = const [],
|
||||
this.weather,
|
||||
});
|
||||
|
||||
/// 조건이 없거나 모양이 틀리면 null
|
||||
static ConditionParse? fromJson(Map<String, dynamic> json) {
|
||||
final conditions = CourseConditions.fromJson(json['conditions']);
|
||||
if (conditions == null) return null;
|
||||
final inferred = json['inferred'];
|
||||
final issues = json['issues'];
|
||||
return ConditionParse(
|
||||
conditions: conditions,
|
||||
inferred: {
|
||||
if (inferred is List)
|
||||
for (final f in inferred) ?ConditionField.parse(f),
|
||||
},
|
||||
issues: [
|
||||
if (issues is List)
|
||||
for (final i in issues) ?ConditionIssue.parse(i),
|
||||
],
|
||||
weather: CourseWeather.fromJson(json['weather']),
|
||||
);
|
||||
}
|
||||
|
||||
final CourseConditions conditions;
|
||||
|
||||
/// 문장에서 뽑은 항목 — 화면의 "적은 내용에서 자동으로 채웠어요"
|
||||
final Set<ConditionField> inferred;
|
||||
final List<ConditionIssue> issues;
|
||||
final CourseWeather? weather;
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
/// AI 코스 생성 — 백엔드 CourseGenerationDto · CourseResultDto · CourseQuotaDto 와 1:1 대응한다(설계 ai-course §3.7 · §4).
|
||||
library;
|
||||
|
||||
import 'package:wandergo/features/ai_course/data/models/course_conditions.dart';
|
||||
import 'package:wandergo/features/place/data/models/place_models.dart'
|
||||
show PetPolicy, PetSize, ValueOrigin;
|
||||
import 'package:wandergo/shared/models/source_models.dart';
|
||||
|
||||
/// 코드값을 enum 으로 읽는다. 모르는 값이면 null — 백엔드가 값을 늘려도 앱이 죽지 않게 한다
|
||||
T? _parseCode<T extends Enum>(
|
||||
List<T> values,
|
||||
Object? value,
|
||||
String Function(T) code,
|
||||
) {
|
||||
for (final v in values) {
|
||||
if (code(v) == value) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<String> _strings(Object? value) => [
|
||||
if (value is List)
|
||||
for (final v in value)
|
||||
if (v is String && v.trim().isNotEmpty) v,
|
||||
];
|
||||
|
||||
int _int(Object? value) => value is num ? value.round() : 0;
|
||||
|
||||
int? _intOrNull(Object? value) => value is num ? value.round() : null;
|
||||
|
||||
DateTime? _time(Object? value) =>
|
||||
value is String ? DateTime.tryParse(value) : null;
|
||||
|
||||
/// 하루 횟수의 대상 — 비로그인(기기) · 로그인(사용자)
|
||||
enum QuotaTier {
|
||||
guest,
|
||||
member;
|
||||
|
||||
static QuotaTier parse(Object? value) =>
|
||||
_parseCode(values, value, (t) => t.name) ?? guest;
|
||||
}
|
||||
|
||||
/// 오늘 남은 생성 횟수(`GET /course-generations/quota`)
|
||||
class CourseQuota {
|
||||
const CourseQuota({
|
||||
required this.limit,
|
||||
required this.used,
|
||||
required this.remaining,
|
||||
this.resetsAt,
|
||||
this.tier = QuotaTier.guest,
|
||||
});
|
||||
|
||||
factory CourseQuota.fromJson(Map<String, dynamic> json) => CourseQuota(
|
||||
limit: _int(json['limit']),
|
||||
used: _int(json['used']),
|
||||
remaining: _int(json['remaining']),
|
||||
resetsAt: _time(json['resetsAt']),
|
||||
tier: QuotaTier.parse(json['tier']),
|
||||
);
|
||||
|
||||
final int limit;
|
||||
final int used;
|
||||
final int remaining;
|
||||
|
||||
/// 다시 채워지는 시각(한국 자정, UTC 로 받는다)
|
||||
final DateTime? resetsAt;
|
||||
final QuotaTier tier;
|
||||
}
|
||||
|
||||
/// 생성 상태
|
||||
enum GenerationStatus {
|
||||
queued,
|
||||
running,
|
||||
succeeded,
|
||||
failed,
|
||||
canceled;
|
||||
|
||||
/// 끝나지 않았다 — 진행을 계속 조회한다
|
||||
bool get isActive => this == queued || this == running;
|
||||
|
||||
static GenerationStatus? parse(Object? value) =>
|
||||
_parseCode(values, value, (s) => s.name);
|
||||
}
|
||||
|
||||
/// 서버가 하는 일(설계 §3.1) — 찾기 → 거르기 → AI 와 시간표 → 정리
|
||||
enum GenerationStage {
|
||||
searching,
|
||||
filtering,
|
||||
planning,
|
||||
finishing;
|
||||
|
||||
static GenerationStage? parse(Object? value) =>
|
||||
_parseCode(values, value, (s) => s.name);
|
||||
}
|
||||
|
||||
enum CourseRating {
|
||||
like,
|
||||
dislike;
|
||||
|
||||
static CourseRating? parse(Object? value) =>
|
||||
_parseCode(values, value, (r) => r.name);
|
||||
}
|
||||
|
||||
/// 생성 요청을 받은 결과(`POST /course-generations`)
|
||||
class GenerationCreated {
|
||||
const GenerationCreated({
|
||||
required this.id,
|
||||
required this.status,
|
||||
required this.remaining,
|
||||
});
|
||||
|
||||
factory GenerationCreated.fromJson(Map<String, dynamic> json) =>
|
||||
GenerationCreated(
|
||||
id: json['id'] as String,
|
||||
status:
|
||||
GenerationStatus.parse(json['status']) ?? GenerationStatus.queued,
|
||||
remaining: _int(json['remaining']),
|
||||
);
|
||||
|
||||
final String id;
|
||||
final GenerationStatus status;
|
||||
|
||||
/// 이번 생성을 뺀 오늘 남은 횟수
|
||||
final int remaining;
|
||||
}
|
||||
|
||||
/// 생성 상태(`GET /course-generations/{id}`) — 실패해도 조회는 성공하고 이유는 [failure] 에 있다
|
||||
class CourseGeneration {
|
||||
const CourseGeneration({
|
||||
required this.id,
|
||||
required this.status,
|
||||
this.stage,
|
||||
this.found,
|
||||
this.kept,
|
||||
this.result,
|
||||
this.failure,
|
||||
this.remaining = 0,
|
||||
this.rating,
|
||||
});
|
||||
|
||||
factory CourseGeneration.fromJson(Map<String, dynamic> json) {
|
||||
final progress = json['progress'];
|
||||
final failure = json['failure'];
|
||||
final result = json['result'];
|
||||
return CourseGeneration(
|
||||
id: json['id'] as String,
|
||||
status: GenerationStatus.parse(json['status']) ?? GenerationStatus.failed,
|
||||
stage: GenerationStage.parse(json['stage']),
|
||||
found: progress is Map<String, dynamic>
|
||||
? _intOrNull(progress['found'])
|
||||
: null,
|
||||
kept: progress is Map<String, dynamic>
|
||||
? _intOrNull(progress['kept'])
|
||||
: null,
|
||||
result: result is Map<String, dynamic>
|
||||
? CourseResult.fromJson(result)
|
||||
: null,
|
||||
failure: failure is Map<String, dynamic>
|
||||
? GenerationFailure.fromJson(failure)
|
||||
: null,
|
||||
remaining: _int(json['remaining']),
|
||||
rating: CourseRating.parse(json['rating']),
|
||||
);
|
||||
}
|
||||
|
||||
final String id;
|
||||
final GenerationStatus status;
|
||||
final GenerationStage? stage;
|
||||
|
||||
/// 찾은 후보 수 · 거른 뒤 남은 후보 수(그 단계 전이면 null)
|
||||
final int? found;
|
||||
final int? kept;
|
||||
final CourseResult? result;
|
||||
final GenerationFailure? failure;
|
||||
final int remaining;
|
||||
final CourseRating? rating;
|
||||
}
|
||||
|
||||
/// 생성 실패 — 문구는 에러 코드 사전([code]), [notices] 는 AI 가 남긴 이유
|
||||
class GenerationFailure {
|
||||
const GenerationFailure({required this.code, this.notices = const []});
|
||||
|
||||
factory GenerationFailure.fromJson(Map<String, dynamic> json) =>
|
||||
GenerationFailure(
|
||||
code: json['code'] is String ? json['code'] as String : 'AI_002',
|
||||
notices: _strings(json['notices']),
|
||||
);
|
||||
|
||||
final String code;
|
||||
final List<String> notices;
|
||||
}
|
||||
|
||||
enum CourseOutcome {
|
||||
course,
|
||||
|
||||
/// 장소를 뺐거나 AI 가 맞추지 못한 조건을 알렸다
|
||||
partial;
|
||||
|
||||
static CourseOutcome parse(Object? value) =>
|
||||
_parseCode(values, value, (o) => o.name) ?? course;
|
||||
}
|
||||
|
||||
/// 장소 · 행사
|
||||
enum StopKind {
|
||||
place,
|
||||
event;
|
||||
|
||||
static StopKind parse(Object? value) =>
|
||||
_parseCode(values, value, (k) => k.name) ?? place;
|
||||
}
|
||||
|
||||
/// 실내·외(운영자 확인 값, 없으면 분류로 추정)
|
||||
enum StopSetting {
|
||||
indoor,
|
||||
outdoor,
|
||||
unknown;
|
||||
|
||||
static StopSetting parse(Object? value) =>
|
||||
_parseCode(values, value, (s) => s.name) ?? unknown;
|
||||
}
|
||||
|
||||
/// 장소마다 보일 경고
|
||||
enum StopWarning {
|
||||
hoursUnknown('hours_unknown'),
|
||||
eventTimeUnknown('event_time_unknown'),
|
||||
petUnknown('pet_unknown'),
|
||||
|
||||
/// 그날이 행사 마지막 날
|
||||
eventLastDay('event_last_day');
|
||||
|
||||
const StopWarning(this.code);
|
||||
|
||||
final String code;
|
||||
|
||||
static StopWarning? parse(Object? value) =>
|
||||
_parseCode(values, value, (w) => w.code);
|
||||
}
|
||||
|
||||
/// 코드가 남긴 알림 — 문구는 앱이 언어별로 만든다
|
||||
enum PlanNoticeCode {
|
||||
stopRemoved('stop_removed'),
|
||||
indoorRatioUnmet('indoor_ratio_unmet');
|
||||
|
||||
const PlanNoticeCode(this.code);
|
||||
|
||||
final String code;
|
||||
|
||||
static PlanNoticeCode? parse(Object? value) =>
|
||||
_parseCode(values, value, (c) => c.code);
|
||||
}
|
||||
|
||||
/// 'HH:mm' 두 개 — 그날 영업 구간
|
||||
typedef TimeRange = ({String start, String end});
|
||||
|
||||
/// 'YYYY-MM-DD' 두 개 — 행사 기간
|
||||
typedef DateSpan = ({String start, String end});
|
||||
|
||||
/// 장소의 사실 값(생성 시점 스냅샷)
|
||||
class StopFacts {
|
||||
const StopFacts({
|
||||
this.hoursOnDate,
|
||||
this.hoursText,
|
||||
this.hoursOrigin = ValueOrigin.unknown,
|
||||
this.petPolicy = PetPolicy.unknown,
|
||||
this.petOrigin = ValueOrigin.unknown,
|
||||
this.petSizes,
|
||||
this.verifiedAt,
|
||||
this.setting = StopSetting.unknown,
|
||||
this.eventPeriod,
|
||||
this.provider = '',
|
||||
});
|
||||
|
||||
factory StopFacts.fromJson(Map<String, dynamic> json) {
|
||||
final hours = json['hoursOnDate'];
|
||||
final sizes = json['petSizes'];
|
||||
final period = json['eventPeriod'];
|
||||
return StopFacts(
|
||||
hoursOnDate: hours is List
|
||||
? [
|
||||
for (final h in hours)
|
||||
if (h is Map<String, dynamic> &&
|
||||
h['start'] is String &&
|
||||
h['end'] is String)
|
||||
(start: h['start'] as String, end: h['end'] as String),
|
||||
]
|
||||
: null,
|
||||
hoursText: json['hoursText'] is String
|
||||
? json['hoursText'] as String
|
||||
: null,
|
||||
hoursOrigin: ValueOrigin.parse(json['hoursOrigin']),
|
||||
petPolicy: PetPolicy.parse(json['petPolicy']),
|
||||
petOrigin: ValueOrigin.parse(json['petOrigin']),
|
||||
petSizes: sizes is List
|
||||
? [for (final s in sizes) ?PetSize.parse(s)]
|
||||
: null,
|
||||
verifiedAt: _time(json['verifiedAt']),
|
||||
setting: StopSetting.parse(json['setting']),
|
||||
eventPeriod:
|
||||
period is Map<String, dynamic> &&
|
||||
period['start'] is String &&
|
||||
period['end'] is String
|
||||
? (start: period['start'] as String, end: period['end'] as String)
|
||||
: null,
|
||||
provider: json['provider'] is String ? json['provider'] as String : '',
|
||||
);
|
||||
}
|
||||
|
||||
/// 그날 영업 구간. 모르면 null, 쉬는 날이면 빈 목록
|
||||
final List<TimeRange>? hoursOnDate;
|
||||
|
||||
/// 영업시간 원문(행사는 운영 시간 문구)
|
||||
final String? hoursText;
|
||||
final ValueOrigin hoursOrigin;
|
||||
final PetPolicy petPolicy;
|
||||
final ValueOrigin petOrigin;
|
||||
|
||||
/// 동반 가능 크기(확인한 곳만)
|
||||
final List<PetSize>? petSizes;
|
||||
|
||||
/// 운영자가 확인한 날
|
||||
final DateTime? verifiedAt;
|
||||
final StopSetting setting;
|
||||
final DateSpan? eventPeriod;
|
||||
|
||||
/// 출처(예: 한국관광공사)
|
||||
final String provider;
|
||||
}
|
||||
|
||||
/// 장소 사이 이동(직선거리 추정)
|
||||
class CourseLeg {
|
||||
const CourseLeg({
|
||||
required this.minutes,
|
||||
required this.meters,
|
||||
required this.mode,
|
||||
this.estimated = true,
|
||||
});
|
||||
|
||||
static CourseLeg? fromJson(Object? json) {
|
||||
if (json is! Map<String, dynamic>) return null;
|
||||
return CourseLeg(
|
||||
minutes: _int(json['minutes']),
|
||||
meters: _int(json['meters']),
|
||||
mode: Mobility.parse(json['mode']) ?? Mobility.walk,
|
||||
estimated: json['estimated'] != false,
|
||||
);
|
||||
}
|
||||
|
||||
final int minutes;
|
||||
final int meters;
|
||||
|
||||
/// 실제 이동 수단 — 대중교통 · 자동차라도 걷는 편이 빠르면 걷기
|
||||
final Mobility mode;
|
||||
final bool estimated;
|
||||
}
|
||||
|
||||
class CoursePhoto {
|
||||
const CoursePhoto({required this.thumbUrl, this.license});
|
||||
|
||||
static CoursePhoto? fromJson(Object? json) {
|
||||
if (json is! Map<String, dynamic>) return null;
|
||||
final url = json['thumbUrl'];
|
||||
if (url is! String || url.isEmpty) return null;
|
||||
return CoursePhoto(
|
||||
thumbUrl: url,
|
||||
license: ImageLicense.parse(json['license']),
|
||||
);
|
||||
}
|
||||
|
||||
final String thumbUrl;
|
||||
final ImageLicense? license;
|
||||
}
|
||||
|
||||
/// 코스의 장소 하나
|
||||
class CourseStop {
|
||||
const CourseStop({
|
||||
required this.kind,
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.category,
|
||||
required this.arrive,
|
||||
required this.leave,
|
||||
required this.minutes,
|
||||
required this.reason,
|
||||
required this.facts,
|
||||
this.eventKind,
|
||||
this.photo,
|
||||
this.warnings = const [],
|
||||
this.legToNext,
|
||||
});
|
||||
|
||||
factory CourseStop.fromJson(Map<String, dynamic> json) {
|
||||
final warnings = json['warnings'];
|
||||
final facts = json['facts'];
|
||||
return CourseStop(
|
||||
kind: StopKind.parse(json['kind']),
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
category: json['category'] is String ? json['category'] as String : '',
|
||||
eventKind: json['eventKind'] is String
|
||||
? json['eventKind'] as String
|
||||
: null,
|
||||
photo: CoursePhoto.fromJson(json['photo']),
|
||||
arrive: DateTime.parse(json['arrive'] as String),
|
||||
leave: DateTime.parse(json['leave'] as String),
|
||||
minutes: _int(json['minutes']),
|
||||
reason: json['reason'] is String ? json['reason'] as String : '',
|
||||
facts: facts is Map<String, dynamic>
|
||||
? StopFacts.fromJson(facts)
|
||||
: const StopFacts(),
|
||||
warnings: [
|
||||
if (warnings is List)
|
||||
for (final w in warnings) ?StopWarning.parse(w),
|
||||
],
|
||||
legToNext: CourseLeg.fromJson(json['legToNext']),
|
||||
);
|
||||
}
|
||||
|
||||
final StopKind kind;
|
||||
|
||||
/// 장소 id 또는 행사 id
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
/// 분류(행사는 event)
|
||||
final String category;
|
||||
final String? eventKind;
|
||||
final CoursePhoto? photo;
|
||||
|
||||
/// 도착 · 떠남(UTC)
|
||||
final DateTime arrive;
|
||||
final DateTime leave;
|
||||
final int minutes;
|
||||
|
||||
/// 고른 이유(AI)
|
||||
final String reason;
|
||||
final StopFacts facts;
|
||||
final List<StopWarning> warnings;
|
||||
final CourseLeg? legToNext;
|
||||
|
||||
/// 다음 장소로 가는 이동만 바꾼 사본 — 바꾸기 후보를 넣으면 앞 장소의 이동이 바뀐다
|
||||
CourseStop withLegToNext(CourseLeg? leg) => CourseStop(
|
||||
kind: kind,
|
||||
id: id,
|
||||
name: name,
|
||||
category: category,
|
||||
arrive: arrive,
|
||||
leave: leave,
|
||||
minutes: minutes,
|
||||
reason: reason,
|
||||
facts: facts,
|
||||
eventKind: eventKind,
|
||||
photo: photo,
|
||||
warnings: warnings,
|
||||
legToNext: leg,
|
||||
);
|
||||
}
|
||||
|
||||
/// 바꾸기 후보 — 같은 시간대로 검증해 두어 앱이 AI 호출 없이 바꾼다
|
||||
class CourseAlternate {
|
||||
const CourseAlternate({
|
||||
required this.forIndex,
|
||||
required this.stop,
|
||||
this.legFromPrev,
|
||||
});
|
||||
|
||||
factory CourseAlternate.fromJson(Map<String, dynamic> json) =>
|
||||
CourseAlternate(
|
||||
forIndex: _int(json['forIndex']),
|
||||
stop: CourseStop.fromJson(json),
|
||||
legFromPrev: CourseLeg.fromJson(json['legFromPrev']),
|
||||
);
|
||||
|
||||
/// 바꿀 장소의 자리(stops 의 0부터)
|
||||
final int forIndex;
|
||||
|
||||
/// 바꿔 넣을 장소 — 다음 장소로 가는 이동([CourseStop.legToNext])까지 다시 계산돼 있다
|
||||
final CourseStop stop;
|
||||
|
||||
/// 앞 장소에서 오는 이동
|
||||
final CourseLeg? legFromPrev;
|
||||
}
|
||||
|
||||
class PlanNotice {
|
||||
const PlanNotice({required this.code, this.name, this.cause});
|
||||
|
||||
static PlanNotice? fromJson(Object? json) {
|
||||
if (json is! Map<String, dynamic>) return null;
|
||||
final code = PlanNoticeCode.parse(json['code']);
|
||||
if (code == null) return null;
|
||||
return PlanNotice(
|
||||
code: code,
|
||||
name: json['name'] is String ? json['name'] as String : null,
|
||||
cause: json['cause'] is String ? json['cause'] as String : null,
|
||||
);
|
||||
}
|
||||
|
||||
final PlanNoticeCode code;
|
||||
|
||||
/// 뺀 장소 이름
|
||||
final String? name;
|
||||
|
||||
/// 뺀 이유(closed · last_order · window · leg_too_long · pet)
|
||||
final String? cause;
|
||||
}
|
||||
|
||||
/// 숫자 요약
|
||||
class CourseTotals {
|
||||
const CourseTotals({
|
||||
required this.stops,
|
||||
required this.totalMinutes,
|
||||
required this.travelMinutes,
|
||||
required this.petConfirmed,
|
||||
});
|
||||
|
||||
factory CourseTotals.fromJson(Object? json) {
|
||||
final map = json is Map<String, dynamic> ? json : const <String, dynamic>{};
|
||||
return CourseTotals(
|
||||
stops: _int(map['stops']),
|
||||
totalMinutes: _int(map['totalMinutes']),
|
||||
travelMinutes: _int(map['travelMinutes']),
|
||||
petConfirmed: _int(map['petConfirmed']),
|
||||
);
|
||||
}
|
||||
|
||||
final int stops;
|
||||
final int totalMinutes;
|
||||
final int travelMinutes;
|
||||
|
||||
/// 반려 동반을 확인한 곳 수
|
||||
final int petConfirmed;
|
||||
}
|
||||
|
||||
/// 생성 결과 — 저장하기 전의 초안이다
|
||||
class CourseResult {
|
||||
const CourseResult({
|
||||
required this.outcome,
|
||||
required this.title,
|
||||
required this.summary,
|
||||
required this.date,
|
||||
required this.mobility,
|
||||
required this.totals,
|
||||
required this.stops,
|
||||
this.notices = const [],
|
||||
this.planNotices = const [],
|
||||
this.weather,
|
||||
this.alternates = const [],
|
||||
this.sources = const [],
|
||||
});
|
||||
|
||||
factory CourseResult.fromJson(Map<String, dynamic> json) {
|
||||
final window = json['window'];
|
||||
final stops = json['stops'];
|
||||
final alternates = json['alternates'];
|
||||
final planNotices = json['planNotices'];
|
||||
return CourseResult(
|
||||
outcome: CourseOutcome.parse(json['outcome']),
|
||||
title: json['title'] is String ? json['title'] as String : '',
|
||||
summary: json['summary'] is String ? json['summary'] as String : '',
|
||||
notices: _strings(json['notices']),
|
||||
planNotices: [
|
||||
if (planNotices is List)
|
||||
for (final n in planNotices) ?PlanNotice.fromJson(n),
|
||||
],
|
||||
weather: CourseWeather.fromJson(json['weather']),
|
||||
date: window is Map<String, dynamic> && window['date'] is String
|
||||
? window['date'] as String
|
||||
: '',
|
||||
mobility: Mobility.parse(json['mobility']) ?? Mobility.walk,
|
||||
totals: CourseTotals.fromJson(json['totals']),
|
||||
stops: [
|
||||
if (stops is List)
|
||||
for (final s in stops)
|
||||
if (s is Map<String, dynamic>) CourseStop.fromJson(s),
|
||||
],
|
||||
alternates: [
|
||||
if (alternates is List)
|
||||
for (final a in alternates)
|
||||
if (a is Map<String, dynamic>) CourseAlternate.fromJson(a),
|
||||
],
|
||||
sources: _strings(json['sources']),
|
||||
);
|
||||
}
|
||||
|
||||
final CourseOutcome outcome;
|
||||
final String title;
|
||||
final String summary;
|
||||
|
||||
/// AI 가 남긴 알림(맞추지 못한 조건 등)
|
||||
final List<String> notices;
|
||||
final List<PlanNotice> planNotices;
|
||||
final CourseWeather? weather;
|
||||
|
||||
/// 코스 날짜('YYYY-MM-DD')
|
||||
final String date;
|
||||
final Mobility mobility;
|
||||
final CourseTotals totals;
|
||||
final List<CourseStop> stops;
|
||||
final List<CourseAlternate> alternates;
|
||||
|
||||
/// 출처 문구(공공누리 유형 · 기상청 발표 시각 등)
|
||||
final List<String> sources;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:wandergo/core/api/dio_provider.dart';
|
||||
import 'package:wandergo/features/ai_course/data/models/course_conditions.dart';
|
||||
import 'package:wandergo/features/ai_course/data/models/course_generation.dart';
|
||||
|
||||
/// 조건 해석 응답이 모양이 틀렸다 — 화면은 SYS_001 처럼 다룬다
|
||||
class ConditionParseFormatException implements Exception {
|
||||
const ConditionParseFormatException();
|
||||
}
|
||||
|
||||
/// AI 코스 API(설계 ai-course §4) — 로그인 없이 쓰고, 기기 ID 헤더는 dio 인터셉터가 붙인다.
|
||||
///
|
||||
/// [lang] 은 `Accept-Language` 로 보낸다. 서버는 이 언어로 코스 제목·이유를 쓴다.
|
||||
/// 응답 언래핑과 에러 코드 매핑은 dio 인터셉터가 맡는다. 여기서는 파싱만 한다.
|
||||
class AiCourseRepository {
|
||||
const AiCourseRepository(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
static const String _generations = '/course-generations';
|
||||
|
||||
Options _options(String lang) => Options(headers: {'Accept-Language': lang});
|
||||
|
||||
/// 오늘 남은 횟수
|
||||
Future<CourseQuota> quota({required String lang}) async {
|
||||
final response = await _dio.get<Map<String, dynamic>>(
|
||||
'$_generations/quota',
|
||||
options: _options(lang),
|
||||
);
|
||||
return CourseQuota.fromJson(response.data!);
|
||||
}
|
||||
|
||||
/// 문장을 조건으로 바꾼다 — 횟수를 차감하지 않는다. [baseArea] 는 문장에 지역이 없을 때의 기준이다
|
||||
Future<ConditionParse> parseConditions({
|
||||
required String text,
|
||||
required CourseArea baseArea,
|
||||
required String lang,
|
||||
}) async {
|
||||
final response = await _dio.post<Map<String, dynamic>>(
|
||||
'/course-conditions',
|
||||
data: {'text': text, 'baseArea': baseArea.toJson()},
|
||||
options: _options(lang),
|
||||
);
|
||||
final parsed = ConditionParse.fromJson(response.data!);
|
||||
if (parsed == null) throw const ConditionParseFormatException();
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/// 생성을 시작한다 — 결과가 나와야 횟수가 차감된다.
|
||||
/// [previousGenerationId] 는 '다시 만들기'에서 지난 결과와 다른 곳을 고르게 할 때 넘긴다
|
||||
Future<GenerationCreated> create({
|
||||
required String request,
|
||||
required CourseConditions conditions,
|
||||
String? previousGenerationId,
|
||||
required String lang,
|
||||
}) async {
|
||||
final response = await _dio.post<Map<String, dynamic>>(
|
||||
_generations,
|
||||
data: {
|
||||
'request': request,
|
||||
'conditions': conditions.toJson(),
|
||||
'previousGenerationId': ?previousGenerationId,
|
||||
},
|
||||
options: _options(lang),
|
||||
);
|
||||
return GenerationCreated.fromJson(response.data!);
|
||||
}
|
||||
|
||||
Future<CourseGeneration> get(String id, {required String lang}) async {
|
||||
final response = await _dio.get<Map<String, dynamic>>(
|
||||
'$_generations/$id',
|
||||
options: _options(lang),
|
||||
);
|
||||
return CourseGeneration.fromJson(response.data!);
|
||||
}
|
||||
|
||||
/// 취소 — AI 호출을 끊는다. 차감하지 않는다. 이미 끝난 생성이면 그대로 돌려준다
|
||||
Future<CourseGeneration> cancel(String id, {required String lang}) async {
|
||||
final response = await _dio.post<Map<String, dynamic>>(
|
||||
'$_generations/$id/cancellation',
|
||||
options: _options(lang),
|
||||
);
|
||||
return CourseGeneration.fromJson(response.data!);
|
||||
}
|
||||
|
||||
/// "이 코스, 마음에 드세요?" — 결과가 나온 생성만 받는다
|
||||
Future<CourseGeneration> rate(
|
||||
String id,
|
||||
CourseRating value, {
|
||||
required String lang,
|
||||
}) async {
|
||||
final response = await _dio.put<Map<String, dynamic>>(
|
||||
'$_generations/$id/rating',
|
||||
data: {'value': value.name},
|
||||
options: _options(lang),
|
||||
);
|
||||
return CourseGeneration.fromJson(response.data!);
|
||||
}
|
||||
}
|
||||
|
||||
final aiCourseRepositoryProvider = Provider<AiCourseRepository>(
|
||||
(ref) => AiCourseRepository(ref.watch(dioProvider)),
|
||||
);
|
||||
@@ -0,0 +1,149 @@
|
||||
import 'package:wandergo/features/ai_course/data/models/course_conditions.dart';
|
||||
import 'package:wandergo/features/ai_course/domain/condition_rules.dart';
|
||||
|
||||
/// 입력 화면(8)의 상태 — 요청 문장과, 해석해 채운 뒤 사용자가 고친 조건
|
||||
class AiCourseDraft {
|
||||
const AiCourseDraft({
|
||||
this.text = '',
|
||||
this.conditions,
|
||||
this.inferred = const {},
|
||||
this.edited = const {},
|
||||
this.issues = const [],
|
||||
this.weather,
|
||||
this.parsing = false,
|
||||
this.parseError,
|
||||
this.parsedText,
|
||||
this.submitting = false,
|
||||
this.generationId,
|
||||
});
|
||||
|
||||
final String text;
|
||||
|
||||
/// 해석하기 전이면 null
|
||||
final CourseConditions? conditions;
|
||||
|
||||
/// 문장에서 뽑아 채운 항목(사용자가 고친 항목은 빠진다)
|
||||
final Set<ConditionField> inferred;
|
||||
|
||||
/// 사용자가 고친 항목 — 문장을 고쳐 다시 해석해도 이 값을 지킨다
|
||||
final Set<ConditionField> edited;
|
||||
|
||||
/// 아직 풀지 않은 것(그 항목을 고치면 사라진다)
|
||||
final List<ConditionIssue> issues;
|
||||
|
||||
/// 해석한 날의 날씨 — 날짜를 고치면 [weatherForDate] 가 null 이 된다
|
||||
final CourseWeather? weather;
|
||||
final bool parsing;
|
||||
final Object? parseError;
|
||||
|
||||
/// 마지막으로 해석한 문장 — 같은 문장은 다시 부르지 않는다
|
||||
final String? parsedText;
|
||||
final bool submitting;
|
||||
|
||||
/// 이 입력으로 마지막에 시작한 생성
|
||||
final String? generationId;
|
||||
|
||||
/// 조건 날짜의 날씨(예보가 없거나 날짜가 다르면 null)
|
||||
CourseWeather? get weatherForDate {
|
||||
final w = weather;
|
||||
return w != null && w.date == conditions?.date ? w : null;
|
||||
}
|
||||
|
||||
/// 만들기 전에 고쳐야 하는 것이 남았나
|
||||
bool get hasBlockingIssue => issues.any(isBlockingIssue);
|
||||
|
||||
AiCourseDraft copyWith({
|
||||
String? text,
|
||||
CourseConditions? conditions,
|
||||
Set<ConditionField>? inferred,
|
||||
Set<ConditionField>? edited,
|
||||
List<ConditionIssue>? issues,
|
||||
CourseWeather? weather,
|
||||
bool? parsing,
|
||||
Object? parseError,
|
||||
bool clearParseError = false,
|
||||
String? parsedText,
|
||||
bool? submitting,
|
||||
String? generationId,
|
||||
}) => AiCourseDraft(
|
||||
text: text ?? this.text,
|
||||
conditions: conditions ?? this.conditions,
|
||||
inferred: inferred ?? this.inferred,
|
||||
edited: edited ?? this.edited,
|
||||
issues: issues ?? this.issues,
|
||||
weather: weather ?? this.weather,
|
||||
parsing: parsing ?? this.parsing,
|
||||
parseError: clearParseError ? null : (parseError ?? this.parseError),
|
||||
parsedText: parsedText ?? this.parsedText,
|
||||
submitting: submitting ?? this.submitting,
|
||||
generationId: generationId ?? this.generationId,
|
||||
);
|
||||
|
||||
/// 해석 결과를 반영한다 — 사용자가 고친 항목은 지금 값을 지키고, 그 항목의 알림은 풀린 것으로 본다
|
||||
AiCourseDraft applyParse(ConditionParse parse, String parsedText) {
|
||||
final current = conditions;
|
||||
final merged = current == null
|
||||
? parse.conditions
|
||||
: _merge(parsed: parse.conditions, current: current, keep: edited);
|
||||
return AiCourseDraft(
|
||||
text: text,
|
||||
conditions: merged,
|
||||
inferred: parse.inferred.difference(edited),
|
||||
edited: edited,
|
||||
issues: [
|
||||
for (final issue in parse.issues)
|
||||
if (!edited.contains(issueField(issue))) issue,
|
||||
],
|
||||
weather: parse.weather,
|
||||
parsedText: parsedText,
|
||||
submitting: submitting,
|
||||
generationId: generationId,
|
||||
);
|
||||
}
|
||||
|
||||
/// 사용자가 조건을 고쳤다 — 그 항목은 고친 것으로 남고 알림이 풀린다
|
||||
AiCourseDraft applyEdit(CourseConditions next, Set<ConditionField> fields) =>
|
||||
AiCourseDraft(
|
||||
text: text,
|
||||
conditions: next,
|
||||
inferred: inferred.difference(fields),
|
||||
edited: edited.union(fields),
|
||||
issues: [
|
||||
for (final issue in issues)
|
||||
if (!fields.contains(issueField(issue))) issue,
|
||||
],
|
||||
weather: weather,
|
||||
parsing: parsing,
|
||||
parseError: parseError,
|
||||
parsedText: parsedText,
|
||||
submitting: submitting,
|
||||
generationId: generationId,
|
||||
);
|
||||
|
||||
static CourseConditions _merge({
|
||||
required CourseConditions parsed,
|
||||
required CourseConditions current,
|
||||
required Set<ConditionField> keep,
|
||||
}) {
|
||||
T pick<T>(ConditionField field, T now, T next) =>
|
||||
keep.contains(field) ? now : next;
|
||||
return CourseConditions(
|
||||
area: pick(ConditionField.area, current.area, parsed.area),
|
||||
date: pick(ConditionField.date, current.date, parsed.date),
|
||||
start: pick(ConditionField.start, current.start, parsed.start),
|
||||
end: pick(ConditionField.end, current.end, parsed.end),
|
||||
party: pick(ConditionField.party, current.party, parsed.party),
|
||||
pet: pick(ConditionField.pet, current.pet, parsed.pet),
|
||||
mobility: pick(
|
||||
ConditionField.mobility,
|
||||
current.mobility,
|
||||
parsed.mobility,
|
||||
),
|
||||
indoorIfRain: pick(
|
||||
ConditionField.indoorIfRain,
|
||||
current.indoorIfRain,
|
||||
parsed.indoorIfRain,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/// 생성 조건 규칙 — 백엔드 `course-conditions.ts` 의 `conditionsProblem` 과 같은 기준이다(설계 ai-course §2 · §4).
|
||||
/// 앱이 먼저 막아 서버의 VAL_001 을 보지 않게 한다. 기준을 바꾸면 두 곳을 함께 고친다.
|
||||
library;
|
||||
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import 'package:wandergo/core/utils/format.dart';
|
||||
import 'package:wandergo/features/ai_course/data/models/course_conditions.dart';
|
||||
|
||||
/// 요청 문장 최대 글자 수(디자인 200 — 서버는 300 까지 받는다)
|
||||
const int kRequestTextMax = 200;
|
||||
|
||||
/// 하루 코스 길이(분) — 2~12시간
|
||||
const int kWindowMinMinutes = 120;
|
||||
const int kWindowMaxMinutes = 720;
|
||||
|
||||
/// 오늘부터 며칠 뒤까지 만들 수 있나
|
||||
const int kDateRangeDays = 30;
|
||||
|
||||
const int kPartyMin = 1;
|
||||
const int kPartyMax = 10;
|
||||
const int kPetCountMax = 5;
|
||||
|
||||
/// 시각을 고치는 단위(분)
|
||||
const int kTimeStepMinutes = 30;
|
||||
|
||||
const int _kMinutesPerHour = 60;
|
||||
const int _kMinutesPerDay = 1440;
|
||||
|
||||
/// 한국 표준시(UTC+9) — 기기 시간대와 상관없이 한국 시각으로 계산한다(백엔드와 같은 기준)
|
||||
const Duration _kKstOffset = Duration(hours: 9);
|
||||
|
||||
/// 'HH:mm' → 분. 형식이 다르면 null('24:00' 은 1440)
|
||||
int? parseHhmm(String text) {
|
||||
final m = RegExp(r'^(\d{2}):(\d{2})$').firstMatch(text);
|
||||
if (m == null) return null;
|
||||
final hours = int.parse(m[1]!);
|
||||
final minutes = int.parse(m[2]!);
|
||||
final total = hours * _kMinutesPerHour + minutes;
|
||||
return minutes < _kMinutesPerHour && total <= _kMinutesPerDay ? total : null;
|
||||
}
|
||||
|
||||
/// 분 → 'HH:mm'('24:00' 까지)
|
||||
String formatHhmm(int minutes) {
|
||||
final h = (minutes ~/ _kMinutesPerHour).toString().padLeft(2, '0');
|
||||
final m = (minutes % _kMinutesPerHour).toString().padLeft(2, '0');
|
||||
return '$h:$m';
|
||||
}
|
||||
|
||||
/// 지금의 한국 날짜('YYYY-MM-DD')와 그날 지난 분
|
||||
({String date, int minutes}) kstClock(DateTime now) {
|
||||
final kst = now.toUtc().add(_kKstOffset);
|
||||
return (
|
||||
date: toYmd(DateTime.utc(kst.year, kst.month, kst.day)),
|
||||
minutes: kst.hour * _kMinutesPerHour + kst.minute,
|
||||
);
|
||||
}
|
||||
|
||||
/// UTC 시각 → 한국 시각 'HH:mm'
|
||||
String formatKstTime(DateTime utc) {
|
||||
final kst = utc.toUtc().add(_kKstOffset);
|
||||
return formatHhmm(kst.hour * _kMinutesPerHour + kst.minute);
|
||||
}
|
||||
|
||||
/// 두 날짜('YYYY-MM-DD') 사이의 날 수. 형식이 틀리면 null
|
||||
int? daysBetween(String from, String to) {
|
||||
final a = parseYmd(from);
|
||||
final b = parseYmd(to);
|
||||
if (a == null || b == null) return null;
|
||||
return b.difference(a).inDays;
|
||||
}
|
||||
|
||||
/// 'YYYY-MM-DD' 에 날을 더한다
|
||||
String addDays(String ymd, int days) {
|
||||
final d = parseYmd(ymd);
|
||||
return d == null ? ymd : toYmd(d.add(Duration(days: days)));
|
||||
}
|
||||
|
||||
const List<String> _kKoWeekdays = ['월', '화', '수', '목', '금', '토', '일'];
|
||||
|
||||
/// 조건의 날짜 표시 — 한국어 "11.14(토)", 다른 언어 "Sat, Nov 14"
|
||||
String formatCourseDate(String ymd, {required String localeName}) {
|
||||
final d = parseYmd(ymd);
|
||||
if (d == null) return ymd;
|
||||
if (localeName.startsWith('ko')) {
|
||||
return '${d.month}.${d.day}(${_kKoWeekdays[d.weekday - 1]})';
|
||||
}
|
||||
return DateFormat('EEE, MMM d', localeName).format(d);
|
||||
}
|
||||
|
||||
/// 요일 이름 — 한국어 "토요일", 다른 언어 "Saturday"
|
||||
String formatWeekday(String ymd, {required String localeName}) {
|
||||
final d = parseYmd(ymd);
|
||||
if (d == null) return ymd;
|
||||
if (localeName.startsWith('ko')) return '${_kKoWeekdays[d.weekday - 1]}요일';
|
||||
return DateFormat.EEEE(localeName).format(d);
|
||||
}
|
||||
|
||||
/// 만들 수 없는 조건의 이유
|
||||
enum ConditionProblem {
|
||||
/// 지난 날짜
|
||||
pastDate,
|
||||
|
||||
/// 30일보다 먼 날짜
|
||||
dateOutOfRange,
|
||||
|
||||
/// 형식이 틀린 시각
|
||||
invalidTime,
|
||||
|
||||
/// 2시간 미만(끝이 시작보다 이르면 여기 든다)
|
||||
tooShort,
|
||||
|
||||
/// 12시간 초과
|
||||
tooLong,
|
||||
|
||||
/// 오늘인데 이미 끝난 시간
|
||||
pastTime,
|
||||
}
|
||||
|
||||
/// 생성할 수 없는 조건이면 이유, 괜찮으면 null.
|
||||
/// 날짜는 오늘~30일 뒤, 시간은 2~12시간에 자정을 넘기지 않고, 오늘이면 아직 끝나지 않은 시간이어야 한다.
|
||||
/// 현재 위치가 대구 밖인지는 고를 때 막는다(지역 고르기).
|
||||
ConditionProblem? conditionsProblem(CourseConditions c, DateTime now) {
|
||||
final today = kstClock(now);
|
||||
final ahead = daysBetween(today.date, c.date);
|
||||
if (ahead == null || ahead < 0) return ConditionProblem.pastDate;
|
||||
if (ahead > kDateRangeDays) return ConditionProblem.dateOutOfRange;
|
||||
final start = parseHhmm(c.start);
|
||||
final end = parseHhmm(c.end);
|
||||
if (start == null || end == null) return ConditionProblem.invalidTime;
|
||||
final length = end - start;
|
||||
if (length < kWindowMinMinutes) return ConditionProblem.tooShort;
|
||||
if (length > kWindowMaxMinutes) return ConditionProblem.tooLong;
|
||||
if (ahead == 0 && end <= today.minutes) return ConditionProblem.pastTime;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 문제가 보일 조건 줄
|
||||
ConditionField problemField(ConditionProblem problem) => switch (problem) {
|
||||
ConditionProblem.pastDate ||
|
||||
ConditionProblem.dateOutOfRange => ConditionField.date,
|
||||
_ => ConditionField.start,
|
||||
};
|
||||
|
||||
/// 조건 해석이 알린 것이 보일 조건 줄. 문장 칸이면 null
|
||||
ConditionField? issueField(ConditionIssue issue) => switch (issue) {
|
||||
ConditionIssue.unsupportedArea ||
|
||||
ConditionIssue.unknownPlace => ConditionField.area,
|
||||
ConditionIssue.pastDate ||
|
||||
ConditionIssue.dateOutOfRange ||
|
||||
ConditionIssue.multiDay => ConditionField.date,
|
||||
ConditionIssue.tooShort ||
|
||||
ConditionIssue.pastMidnight => ConditionField.start,
|
||||
ConditionIssue.unclearRequest => null,
|
||||
};
|
||||
|
||||
/// 고치기 전에는 만들 수 없는 것 — 조건이 요청과 달라졌다.
|
||||
/// 장소를 못 찾은 것(unknownPlace)만 알리고 넘어간다(기준 위치로 두었다)
|
||||
bool isBlockingIssue(ConditionIssue issue) =>
|
||||
issue != ConditionIssue.unknownPlace;
|
||||
|
||||
/// 시각 범위 줄의 표시 — "14:00–20:00"
|
||||
String formatTimeRange(String start, String end) => '$start–$end';
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'package:wandergo/features/ai_course/data/models/course_generation.dart';
|
||||
import 'package:wandergo/features/place/data/models/place_models.dart'
|
||||
show PetPolicy;
|
||||
|
||||
/// 결과 화면(10)의 바꾸기 — 바꾸기 후보로 한 자리를 바꾼다(설계 ai-course §3.7).
|
||||
///
|
||||
/// 후보는 서버가 같은 시간대 · 앞뒤 이동까지 다시 계산해 두었다. 다만 계산의 기준이 원래 앞뒤 장소라,
|
||||
/// 두 자리를 함께 바꾸면 그 사이 이동을 알 수 없다. 그래서 한 번에 한 곳만 바꾼다
|
||||
/// (MVP 합의: 후보로 한 곳만 바꾸는 것은 하루 횟수에 들지 않는다).
|
||||
List<CourseStop> stopsWithSwap(CourseResult result, int? swappedIndex) {
|
||||
final stops = [...result.stops];
|
||||
if (swappedIndex == null) return stops;
|
||||
for (final alternate in result.alternates) {
|
||||
final index = alternate.forIndex;
|
||||
if (index != swappedIndex || index < 0 || index >= stops.length) continue;
|
||||
stops[index] = alternate.stop;
|
||||
if (index > 0) {
|
||||
stops[index - 1] = stops[index - 1].withLegToNext(alternate.legFromPrev);
|
||||
}
|
||||
break;
|
||||
}
|
||||
return stops;
|
||||
}
|
||||
|
||||
/// 자리마다 첫 바꾸기 후보 — 서버는 장소마다 1개까지 준다
|
||||
Map<int, CourseAlternate> alternatesBySlot(CourseResult result) {
|
||||
final bySlot = <int, CourseAlternate>{};
|
||||
for (final alternate in result.alternates) {
|
||||
final index = alternate.forIndex;
|
||||
if (index < 0 || index >= result.stops.length) continue;
|
||||
bySlot.putIfAbsent(index, () => alternate);
|
||||
}
|
||||
return bySlot;
|
||||
}
|
||||
|
||||
/// 반려 동반을 확인한 곳 — 백엔드 `isPetConfirmed` 와 같은 기준(동반 가능 · 일부 구역)
|
||||
bool isPetConfirmed(CourseStop stop) =>
|
||||
stop.facts.petPolicy == PetPolicy.allowed ||
|
||||
stop.facts.petPolicy == PetPolicy.partial;
|
||||
|
||||
/// 숫자 요약 — 바꾼 뒤 이동 시간 · 반려 동반 곳 수가 달라진다
|
||||
CourseTotals totalsOf(List<CourseStop> stops) {
|
||||
if (stops.isEmpty) {
|
||||
return const CourseTotals(
|
||||
stops: 0,
|
||||
totalMinutes: 0,
|
||||
travelMinutes: 0,
|
||||
petConfirmed: 0,
|
||||
);
|
||||
}
|
||||
return CourseTotals(
|
||||
stops: stops.length,
|
||||
totalMinutes: stops.last.leave.difference(stops.first.arrive).inMinutes,
|
||||
travelMinutes: stops.fold(
|
||||
0,
|
||||
(sum, stop) => sum + (stop.legToNext?.minutes ?? 0),
|
||||
),
|
||||
petConfirmed: stops.where(isPetConfirmed).length,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'package:wandergo/features/ai_course/data/models/course_generation.dart';
|
||||
|
||||
/// 만드는 중 화면(9)의 네 줄 — 서버 단계(설계 §3.1)와 같은 순서다
|
||||
enum GenerationStep {
|
||||
/// 후보 찾기 — "○○ 근처에서 128곳을 찾았어요"
|
||||
search,
|
||||
|
||||
/// 거르기 · 줄이기 — "31곳을 추렸어요"
|
||||
filter,
|
||||
|
||||
/// AI 호출과 시간표 · 검증 · 고치기
|
||||
plan,
|
||||
|
||||
/// 사실 값 · 바꾸기 후보 · 출처 붙이기
|
||||
finish,
|
||||
}
|
||||
|
||||
/// 줄 하나의 상태
|
||||
enum GenerationStepState { done, current, waiting }
|
||||
|
||||
/// 지금 하고 있는 줄의 번호. 기다리는 중이면 0, 끝났으면 줄 수(모두 끝남)
|
||||
int activeStepIndex(GenerationStatus status, GenerationStage? stage) {
|
||||
if (status == GenerationStatus.succeeded) return GenerationStep.values.length;
|
||||
return switch (stage) {
|
||||
null || GenerationStage.searching => 0,
|
||||
GenerationStage.filtering => 1,
|
||||
GenerationStage.planning => 2,
|
||||
GenerationStage.finishing => 3,
|
||||
};
|
||||
}
|
||||
|
||||
GenerationStepState stepState(GenerationStep step, int activeIndex) {
|
||||
if (step.index < activeIndex) return GenerationStepState.done;
|
||||
if (step.index == activeIndex) return GenerationStepState.current;
|
||||
return GenerationStepState.waiting;
|
||||
}
|
||||
|
||||
/// 진행 막대(0–1) — 시간이 가장 긴 AI 단계가 막대의 가운데를 차지한다
|
||||
const List<double> _kStepProgress = [0.1, 0.3, 0.6, 0.9, 1];
|
||||
|
||||
double progressValue(GenerationStatus status, GenerationStage? stage) =>
|
||||
_kStepProgress[activeStepIndex(status, stage)];
|
||||
@@ -0,0 +1,460 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:wandergo/core/api/error_lexicon.dart';
|
||||
import 'package:wandergo/core/icons/app_icons.dart';
|
||||
import 'package:wandergo/core/l10n/l10n.dart';
|
||||
import 'package:wandergo/core/theme/app_colors.dart';
|
||||
import 'package:wandergo/core/utils/keep_all.dart';
|
||||
import 'package:wandergo/features/ai_course/data/models/course_conditions.dart';
|
||||
import 'package:wandergo/features/ai_course/data/models/course_generation.dart';
|
||||
import 'package:wandergo/features/ai_course/domain/condition_rules.dart';
|
||||
import 'package:wandergo/features/ai_course/domain/generation_steps.dart';
|
||||
import 'package:wandergo/features/ai_course/presentation/providers/ai_course_providers.dart';
|
||||
import 'package:wandergo/features/ai_course/presentation/widgets/ai_course_title.dart';
|
||||
import 'package:wandergo/features/ai_course/presentation/widgets/condition_labels.dart';
|
||||
import 'package:wandergo/features/ai_course/presentation/widgets/course_result_view.dart';
|
||||
import 'package:wandergo/shared/widgets/app_back_button.dart';
|
||||
import 'package:wandergo/shared/widgets/character_image.dart';
|
||||
import 'package:wandergo/shared/widgets/load_error.dart';
|
||||
|
||||
/// 9 만드는 중 → 10 결과. 진행 상태로 화면을 고른다(같은 id 로 앱 복귀 · 이어 보기를 한다).
|
||||
/// 만드는 중에 나가면(닫기 · 뒤로) 취소한다 — 차감하지 않는다.
|
||||
class AiCourseGenerationPage extends ConsumerStatefulWidget {
|
||||
const AiCourseGenerationPage({super.key, required this.id});
|
||||
|
||||
final String id;
|
||||
|
||||
@override
|
||||
ConsumerState<AiCourseGenerationPage> createState() =>
|
||||
_AiCourseGenerationPageState();
|
||||
}
|
||||
|
||||
class _AiCourseGenerationPageState
|
||||
extends ConsumerState<AiCourseGenerationPage> {
|
||||
late final AppLifecycleListener _lifecycle;
|
||||
|
||||
GenerationNotifier get _notifier =>
|
||||
ref.read(generationProvider(widget.id).notifier);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 백그라운드에서는 조회를 멈추고, 돌아오면 바로 한 번 조회한다
|
||||
_lifecycle = AppLifecycleListener(
|
||||
onStateChange: (state) {
|
||||
switch (state) {
|
||||
case AppLifecycleState.resumed:
|
||||
_notifier.resume();
|
||||
case AppLifecycleState.paused || AppLifecycleState.hidden:
|
||||
_notifier.pause();
|
||||
case AppLifecycleState.inactive || AppLifecycleState.detached:
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_lifecycle.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 만드는 중이면 취소한다(이미 취소 중이거나 끝났으면 그대로 둔다)
|
||||
void _cancelIfActive() {
|
||||
final view = ref.read(generationProvider(widget.id));
|
||||
final active = view.generation?.status.isActive ?? true;
|
||||
if (active && !view.canceling) unawaited(_notifier.cancel());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final view = ref.watch(generationProvider(widget.id));
|
||||
final generation = view.generation;
|
||||
final active = generation?.status.isActive ?? true;
|
||||
|
||||
final Widget body;
|
||||
final error = view.error;
|
||||
if (generation == null && error != null) {
|
||||
body = _Centered(
|
||||
child: LoadErrorBlock(error: error, onRetry: _notifier.retry),
|
||||
);
|
||||
} else if (generation == null || active) {
|
||||
body = _ProgressView(id: widget.id, generation: generation);
|
||||
} else {
|
||||
body = switch (generation.status) {
|
||||
GenerationStatus.succeeded => switch (generation.result) {
|
||||
final result? => CourseResultView(
|
||||
generation: generation,
|
||||
result: result,
|
||||
),
|
||||
null => _FailureView(failure: generation.failure),
|
||||
},
|
||||
GenerationStatus.canceled => const _CanceledView(),
|
||||
_ => _FailureView(failure: generation.failure),
|
||||
};
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) _cancelIfActive();
|
||||
},
|
||||
child: Scaffold(
|
||||
// 만드는 중은 닫기(X)와 제목만, 끝나면 뒤로 · 남은 횟수 · 아래 구분선(시안 9 · 10)
|
||||
appBar: AppBar(
|
||||
leading: active
|
||||
? AppBackButton(icon: AppIconData.close, tooltip: l10n.aiClose)
|
||||
: const AppBackButton(),
|
||||
titleSpacing: 0,
|
||||
title: AiCourseTitle(showQuota: !active),
|
||||
shape: active
|
||||
? null
|
||||
: const Border(bottom: BorderSide(color: AppColors.divider)),
|
||||
),
|
||||
body: body,
|
||||
bottomNavigationBar: active
|
||||
? SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Center(
|
||||
heightFactor: 1,
|
||||
child: TextButton(
|
||||
onPressed: view.canceling
|
||||
? null
|
||||
: () {
|
||||
unawaited(_notifier.cancel());
|
||||
Navigator.of(context).maybePop();
|
||||
},
|
||||
child: Text(l10n.aiCancel),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 남은 높이의 세로 가운데 — 짧은 화면에서는 위에서부터 넘긴다
|
||||
class _Centered extends StatelessWidget {
|
||||
const _Centered({required this.child, this.padding = EdgeInsets.zero});
|
||||
|
||||
final Widget child;
|
||||
final EdgeInsets padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => SingleChildScrollView(
|
||||
padding: padding,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: (constraints.maxHeight - padding.vertical).clamp(
|
||||
0,
|
||||
double.infinity,
|
||||
),
|
||||
),
|
||||
child: Center(child: child),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 만드는 중 — 그림 · 제목 · 진행 막대 · 네 단계(시안 9, 세로 가운데)
|
||||
class _ProgressView extends ConsumerWidget {
|
||||
const _ProgressView({required this.id, required this.generation});
|
||||
|
||||
final String id;
|
||||
final CourseGeneration? generation;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = context.l10n;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final status = generation?.status ?? GenerationStatus.queued;
|
||||
final stage = generation?.stage;
|
||||
final active = activeStepIndex(status, stage);
|
||||
|
||||
// 이 입력으로 만든 생성이면 지역 · 요일 · 반려동물을 함께 보인다
|
||||
// ("수성못 근처에서 128곳을 찾았어요" · "토요일 영업·반려견 동반으로 31곳을 추렸어요")
|
||||
final draft = ref.watch(aiCourseDraftProvider);
|
||||
final conditions = draft.generationId == id ? draft.conditions : null;
|
||||
|
||||
String textFor(GenerationStep step) {
|
||||
final done = stepState(step, active) == GenerationStepState.done;
|
||||
final found = generation?.found;
|
||||
final kept = generation?.kept;
|
||||
return switch (step) {
|
||||
GenerationStep.search when done && found != null => switch (conditions
|
||||
?.area) {
|
||||
null => l10n.aiStepFound(found),
|
||||
// 장소 기준은 이름이 이미 "○○ 근처"
|
||||
final PlaceArea area => l10n.aiStepFoundAt(
|
||||
areaLabel(area, l10n, ref),
|
||||
found,
|
||||
),
|
||||
final area => l10n.aiStepFoundNear(areaLabel(area, l10n, ref), found),
|
||||
},
|
||||
GenerationStep.search => l10n.aiStepSearching,
|
||||
GenerationStep.filter when done && kept != null => _keptText(
|
||||
l10n,
|
||||
conditions,
|
||||
kept,
|
||||
),
|
||||
GenerationStep.filter => l10n.aiStepFiltering,
|
||||
GenerationStep.plan => l10n.aiStepPlanning,
|
||||
GenerationStep.finish => l10n.aiStepFinishing,
|
||||
};
|
||||
}
|
||||
|
||||
return _Centered(
|
||||
padding: const EdgeInsets.fromLTRB(28, 0, 28, 40),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Center(child: CharacterImage(width: 150, height: 109)),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
l10n.aiLoadingTitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
l10n.aiLoadingSubtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
fontSize: 14,
|
||||
height: 21 / 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
Semantics(
|
||||
label: l10n.aiProgressLabel,
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(end: progressValue(status, stage)),
|
||||
duration: const Duration(milliseconds: 600),
|
||||
builder: (context, value, _) => LinearProgressIndicator(
|
||||
value: value,
|
||||
minHeight: 6,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
color: AppColors.brand,
|
||||
backgroundColor: AppColors.progressTrack,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
for (final (index, step) in GenerationStep.values.indexed) ...[
|
||||
if (index > 0) const SizedBox(height: 16),
|
||||
_StepRow(state: stepState(step, active), text: textFor(step)),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 추린 결과 — 이 기기에서 만든 생성이면 요일 영업과 반려동물 조건을 말한다
|
||||
static String _keptText(
|
||||
AppLocalizations l10n,
|
||||
CourseConditions? conditions,
|
||||
int kept,
|
||||
) {
|
||||
if (conditions == null) return l10n.aiStepKept(kept);
|
||||
final day = formatWeekday(conditions.date, localeName: l10n.localeName);
|
||||
final pet = conditions.pet;
|
||||
if (pet == null) return l10n.aiStepKeptOpen(day, kept);
|
||||
return l10n.aiStepKeptOpenPet(
|
||||
day,
|
||||
pet.kind == PetKind.dog ? l10n.aiKeptPetDog : l10n.aiKeptPetAny,
|
||||
kept,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StepRow extends StatelessWidget {
|
||||
const _StepRow({required this.state, required this.text});
|
||||
|
||||
final GenerationStepState state;
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Widget mark = switch (state) {
|
||||
GenerationStepState.done => Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.brand,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Center(
|
||||
child: AppIcon(
|
||||
AppIconData.check,
|
||||
size: 13,
|
||||
color: Colors.white,
|
||||
strokeWidth: 3.25,
|
||||
),
|
||||
),
|
||||
),
|
||||
GenerationStepState.current => const SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
color: AppColors.brand,
|
||||
backgroundColor: AppColors.chipBorderSelected,
|
||||
),
|
||||
),
|
||||
GenerationStepState.waiting => Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: AppColors.chipBorder, width: 2),
|
||||
),
|
||||
),
|
||||
};
|
||||
return Row(
|
||||
children: [
|
||||
mark,
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
keepAll(text),
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
height: 21 / 15,
|
||||
fontWeight: state == GenerationStepState.current
|
||||
? FontWeight.w600
|
||||
: FontWeight.w400,
|
||||
color: switch (state) {
|
||||
GenerationStepState.done => AppColors.label,
|
||||
GenerationStepState.current => AppColors.text,
|
||||
// 아직 오지 않은 단계 — 비활성 표시라 옅은 회색(시안)
|
||||
GenerationStepState.waiting => AppColors.iconMuted,
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 끝난 화면(실패 · 취소)의 틀 — 공통 상태(시안 20)처럼 그림 타일 · 제목 · 설명 · 버튼
|
||||
class _EndState extends StatelessWidget {
|
||||
const _EndState({
|
||||
required this.title,
|
||||
this.messages = const [],
|
||||
required this.action,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final List<String> messages;
|
||||
final Widget action;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
return _Centered(
|
||||
padding: const EdgeInsets.fromLTRB(28, 0, 28, 40),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.placeholder,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const AppIcon(
|
||||
AppIconData.info,
|
||||
size: 30,
|
||||
strokeWidth: 1.75,
|
||||
color: AppColors.iconMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
keepAll(title),
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
fontSize: 17,
|
||||
height: 25 / 17,
|
||||
),
|
||||
),
|
||||
for (final message in messages) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
keepAll(message),
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
fontSize: 14,
|
||||
height: 21 / 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
action,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 공통 상태의 버튼 — 높이 44, 좌우 18
|
||||
final ButtonStyle _stateButtonStyle = FilledButton.styleFrom(
|
||||
minimumSize: const Size(0, 44),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18),
|
||||
);
|
||||
|
||||
/// 만들지 못했다 — 문구는 에러 코드 사전, AI 가 남긴 이유를 함께 보인다
|
||||
class _FailureView extends StatelessWidget {
|
||||
const _FailureView({required this.failure});
|
||||
|
||||
final GenerationFailure? failure;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final failure = this.failure;
|
||||
return _EndState(
|
||||
title: l10n.aiFailedTitle,
|
||||
messages: [
|
||||
ErrorLexicon.getMessage(failure?.code ?? 'AI_002'),
|
||||
...?failure?.notices,
|
||||
],
|
||||
action: FilledButton(
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
style: _stateButtonStyle,
|
||||
child: Text(l10n.aiEditConditions),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CanceledView extends StatelessWidget {
|
||||
const _CanceledView();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return _EndState(
|
||||
title: l10n.aiCanceledTitle,
|
||||
action: FilledButton(
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
style: _stateButtonStyle,
|
||||
child: Text(l10n.aiEditConditions),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:wandergo/core/clock.dart';
|
||||
import 'package:wandergo/core/icons/app_icons.dart';
|
||||
import 'package:wandergo/core/l10n/l10n.dart';
|
||||
import 'package:wandergo/core/router/app_routes.dart';
|
||||
import 'package:wandergo/core/theme/app_colors.dart';
|
||||
import 'package:wandergo/core/theme/app_theme.dart';
|
||||
import 'package:wandergo/core/utils/keep_all.dart';
|
||||
import 'package:wandergo/features/ai_course/data/models/course_conditions.dart';
|
||||
import 'package:wandergo/features/ai_course/domain/ai_course_draft.dart';
|
||||
import 'package:wandergo/features/ai_course/domain/condition_rules.dart';
|
||||
import 'package:wandergo/features/ai_course/presentation/providers/ai_course_providers.dart';
|
||||
import 'package:wandergo/features/ai_course/presentation/widgets/ai_course_title.dart';
|
||||
import 'package:wandergo/features/ai_course/presentation/widgets/condition_labels.dart';
|
||||
import 'package:wandergo/features/ai_course/presentation/widgets/condition_section.dart';
|
||||
import 'package:wandergo/features/ai_course/presentation/widgets/quota_exhausted_sheet.dart';
|
||||
import 'package:wandergo/shared/widgets/app_back_button.dart';
|
||||
import 'package:wandergo/shared/widgets/character_image.dart';
|
||||
import 'package:wandergo/shared/widgets/load_error.dart';
|
||||
|
||||
/// 8 AI 조건 입력 — 문장을 쓰면 조건을 채우고, 고친 뒤 코스를 만든다(설계 ai-course §2).
|
||||
/// 문장 · 조건은 화면을 나갔다 와도 남는다(결과에서 '조건 수정').
|
||||
class AiCourseInputPage extends ConsumerStatefulWidget {
|
||||
const AiCourseInputPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AiCourseInputPage> createState() => _AiCourseInputPageState();
|
||||
}
|
||||
|
||||
class _AiCourseInputPageState extends ConsumerState<AiCourseInputPage> {
|
||||
late final TextEditingController _controller;
|
||||
|
||||
/// 들어올 때 만들던 생성이 남아 있었으면 이어 보기를 보인다
|
||||
String? _pendingId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(
|
||||
text: ref.read(aiCourseDraftProvider).text,
|
||||
);
|
||||
_pendingId = ref.read(pendingGenerationProvider).read();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _useExample(String text) {
|
||||
_controller.text = text;
|
||||
unawaited(ref.read(aiCourseDraftProvider.notifier).useExample(text));
|
||||
}
|
||||
|
||||
void _openGeneration(String id) {
|
||||
setState(() => _pendingId = null);
|
||||
unawaited(context.push(AppRoutes.aiCourseGeneration(id)));
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
FocusScope.of(context).unfocus();
|
||||
final quota = ref.read(aiQuotaProvider).value;
|
||||
if (quota != null && quota.remaining <= 0) {
|
||||
await showQuotaExhaustedSheet(context);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final created = await ref.read(aiCourseDraftProvider.notifier).submit();
|
||||
if (!mounted) return;
|
||||
_openGeneration(created.id);
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
switch (errorCodeOf(error)) {
|
||||
case 'AI_001':
|
||||
ref.invalidate(aiQuotaProvider);
|
||||
await showQuotaExhaustedSheet(context);
|
||||
case 'AI_004':
|
||||
// 이미 만드는 중 — 그 생성을 이어 본다
|
||||
final pending = ref.read(pendingGenerationProvider).read();
|
||||
if (pending != null) {
|
||||
_openGeneration(pending);
|
||||
return;
|
||||
}
|
||||
_tell(errorMessageOf(error));
|
||||
default:
|
||||
_tell(errorMessageOf(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _tell(String message) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final draft = ref.watch(aiCourseDraftProvider);
|
||||
final now = ref.watch(clockProvider)();
|
||||
final pendingId = _pendingId;
|
||||
final unclear = draft.issues.contains(ConditionIssue.unclearRequest);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: const AppBackButton(),
|
||||
titleSpacing: 0,
|
||||
title: const AiCourseTitle(),
|
||||
shape: const Border(bottom: BorderSide(color: AppColors.divider)),
|
||||
),
|
||||
body: GestureDetector(
|
||||
onTap: () => FocusScope.of(context).unfocus(),
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
kPagePadding,
|
||||
20,
|
||||
kPagePadding,
|
||||
28,
|
||||
),
|
||||
children: [
|
||||
if (pendingId != null) ...[
|
||||
_PendingBanner(onOpen: () => _openGeneration(pendingId)),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
// 질문과 그림 — 그림은 두 줄 제목 높이에 맞춰 아래를 맞춘다
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
keepAll(l10n.aiAskTitle),
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
fontSize: 21,
|
||||
height: 29 / 21,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const CharacterImage(width: 80, height: 58),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_RequestBox(
|
||||
controller: _controller,
|
||||
hint: l10n.aiRequestHint,
|
||||
onChanged: ref.read(aiCourseDraftProvider.notifier).setText,
|
||||
onSubmitted: (_) =>
|
||||
ref.read(aiCourseDraftProvider.notifier).parseNow(),
|
||||
),
|
||||
if (unclear)
|
||||
Text(
|
||||
keepAll(issueMessage(ConditionIssue.unclearRequest, l10n)),
|
||||
style: textTheme.bodyMedium?.copyWith(color: AppColors.danger),
|
||||
),
|
||||
// 예시는 문장이 비었을 때만 — 누르면 쓰던 문장을 바꾸기 때문
|
||||
if (draft.text.trim().isEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(l10n.aiExamplesTitle, style: textTheme.bodySmall),
|
||||
const SizedBox(height: 2),
|
||||
for (final example in [l10n.aiExample1, l10n.aiExample2])
|
||||
_ExampleButton(
|
||||
text: example,
|
||||
onTap: () => _useExample(example),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
const Divider(),
|
||||
const SizedBox(height: 24),
|
||||
const ConditionSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: _SubmitBar(
|
||||
enabled: _canSubmit(draft, now),
|
||||
submitting: draft.submitting,
|
||||
onSubmit: _submit,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 조건을 해석했고, 고쳐야 할 것이 없고, 만들 수 있는 조건일 때
|
||||
bool _canSubmit(AiCourseDraft draft, DateTime now) {
|
||||
final conditions = draft.conditions;
|
||||
return conditions != null &&
|
||||
draft.text.trim().isNotEmpty &&
|
||||
!draft.parsing &&
|
||||
!draft.submitting &&
|
||||
!draft.hasBlockingIssue &&
|
||||
conditionsProblem(conditions, now) == null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 문장 칸 — 회색 칸 안 오른쪽 아래에 글자 수("22 / 200"). 칸은 112 높이부터 6줄까지 늘어난다
|
||||
class _RequestBox extends StatelessWidget {
|
||||
const _RequestBox({
|
||||
required this.controller,
|
||||
required this.hint,
|
||||
required this.onChanged,
|
||||
required this.onSubmitted,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final String hint;
|
||||
final ValueChanged<String> onChanged;
|
||||
final ValueChanged<String> onSubmitted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final textStyle = textTheme.bodyLarge?.copyWith(
|
||||
fontSize: 16,
|
||||
height: 24 / 16,
|
||||
color: AppColors.text,
|
||||
);
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inputFill,
|
||||
borderRadius: BorderRadius.circular(kRadius),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 3줄(72) + 글자 수(16) + 위아래 여백(24) = 시안의 최소 높이 112
|
||||
TextField(
|
||||
controller: controller,
|
||||
minLines: 3,
|
||||
maxLines: 6,
|
||||
maxLength: kRequestTextMax,
|
||||
buildCounter:
|
||||
(_, {required currentLength, required isFocused, maxLength}) =>
|
||||
null,
|
||||
textInputAction: TextInputAction.done,
|
||||
style: textStyle,
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: textStyle?.copyWith(color: AppColors.textHint),
|
||||
filled: false,
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
onChanged: onChanged,
|
||||
onSubmitted: onSubmitted,
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: controller,
|
||||
builder: (context, value, _) => Text(
|
||||
'${value.text.characters.length} / $kRequestTextMax',
|
||||
style: textTheme.bodySmall?.copyWith(color: AppColors.textHint),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 앱을 껐다 켰을 때 만들던 생성이 남아 있다
|
||||
class _PendingBanner extends StatelessWidget {
|
||||
const _PendingBanner({required this.onOpen});
|
||||
|
||||
final VoidCallback onOpen;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return Row(
|
||||
children: [
|
||||
const AppIcon(AppIconData.sparkle, size: 18, color: AppColors.brand),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.aiPendingTitle,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: onOpen,
|
||||
style: TextButton.styleFrom(foregroundColor: AppColors.brand),
|
||||
child: Text(l10n.aiPendingOpen),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "이렇게 써 봐도 좋아요" 예시 한 줄 — 누르면 그 문장으로 채운다
|
||||
class _ExampleButton extends StatelessWidget {
|
||||
const _ExampleButton({required this.text, required this.onTap});
|
||||
|
||||
final String text;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 36),
|
||||
child: Row(
|
||||
children: [
|
||||
const AppIcon(
|
||||
AppIconData.plus,
|
||||
size: 14,
|
||||
color: AppColors.iconMuted,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
keepAll(text),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
height: 20 / 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 아래 고정 — 안내 한 줄과 '코스 만들기'
|
||||
class _SubmitBar extends StatelessWidget {
|
||||
const _SubmitBar({
|
||||
required this.enabled,
|
||||
required this.submitting,
|
||||
required this.onSubmit,
|
||||
});
|
||||
|
||||
final bool enabled;
|
||||
final bool submitting;
|
||||
final VoidCallback onSubmit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.background,
|
||||
border: Border(top: BorderSide(color: AppColors.divider)),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
kPagePadding,
|
||||
12,
|
||||
kPagePadding,
|
||||
12,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
keepAll(l10n.aiCreateNote),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(height: 18 / 12),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
FilledButton.icon(
|
||||
onPressed: enabled ? onSubmit : null,
|
||||
icon: submitting
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const AppIcon(
|
||||
AppIconData.sparkle,
|
||||
size: 20,
|
||||
color: Colors.white,
|
||||
),
|
||||
label: Text(l10n.aiCreate),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:wandergo/core/l10n/locale_provider.dart';
|
||||
import 'package:wandergo/core/storage/preferences.dart';
|
||||
import 'package:wandergo/features/ai_course/data/models/course_conditions.dart';
|
||||
import 'package:wandergo/features/ai_course/data/models/course_generation.dart';
|
||||
import 'package:wandergo/features/ai_course/data/repositories/ai_course_repository.dart';
|
||||
import 'package:wandergo/features/ai_course/domain/ai_course_draft.dart';
|
||||
import 'package:wandergo/features/location/domain/base_location.dart';
|
||||
import 'package:wandergo/features/location/presentation/providers/base_location_provider.dart';
|
||||
|
||||
/// 문장 입력이 이만큼 멈추면 조건 해석을 부른다(설계 §2 — 글자마다 부르지 않는다)
|
||||
const Duration kParseDebounce = Duration(seconds: 1);
|
||||
|
||||
/// 진행 조회 간격(설계 §3.1 — 1~2초)
|
||||
const Duration kPollInterval = Duration(milliseconds: 1500);
|
||||
|
||||
/// 진행 조회가 이만큼 잇달아 실패하면 오류를 보인다(잠깐 끊긴 것은 넘긴다)
|
||||
const int kPollFailureLimit = 3;
|
||||
|
||||
/// 오늘 남은 생성 횟수
|
||||
final aiQuotaProvider = FutureProvider.autoDispose<CourseQuota>(
|
||||
(ref) => ref
|
||||
.watch(aiCourseRepositoryProvider)
|
||||
.quota(lang: ref.watch(contentLanguageProvider)),
|
||||
);
|
||||
|
||||
/// 만드는 중인 생성 id — 앱을 다시 켜거나 '이미 만드는 중'(AI_004)을 받으면 이어 본다
|
||||
class PendingGenerationStore {
|
||||
const PendingGenerationStore(this._prefs);
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
String? read() => _prefs.getString(PrefKeys.pendingGeneration);
|
||||
|
||||
Future<void> save(String id) =>
|
||||
_prefs.setString(PrefKeys.pendingGeneration, id);
|
||||
|
||||
/// [id] 가 저장된 값일 때만 지운다(다른 생성이 이미 시작됐으면 그대로 둔다)
|
||||
Future<void> clear(String id) async {
|
||||
if (read() == id) await _prefs.remove(PrefKeys.pendingGeneration);
|
||||
}
|
||||
}
|
||||
|
||||
final pendingGenerationProvider = Provider<PendingGenerationStore>(
|
||||
(ref) => PendingGenerationStore(ref.watch(sharedPreferencesProvider)),
|
||||
);
|
||||
|
||||
/// 문장에 지역이 없을 때의 기준 — 앱의 기준 위치(동네 또는 현재 위치)
|
||||
CourseArea baseAreaOf(BasePoint base) {
|
||||
final neighborhood = base.choice.neighborhood;
|
||||
if (neighborhood != null) return NeighborhoodArea(neighborhood);
|
||||
if (base.fellBack) return const NeighborhoodArea(kFallbackNeighborhood);
|
||||
return PointArea(lat: base.point.lat, lng: base.point.lng);
|
||||
}
|
||||
|
||||
/// 입력 화면(8)의 문장 · 조건. 화면을 나갔다 와도(결과에서 '조건 수정') 그대로 남는다
|
||||
final aiCourseDraftProvider =
|
||||
NotifierProvider<AiCourseDraftNotifier, AiCourseDraft>(
|
||||
AiCourseDraftNotifier.new,
|
||||
);
|
||||
|
||||
class AiCourseDraftNotifier extends Notifier<AiCourseDraft> {
|
||||
Timer? _debounce;
|
||||
|
||||
/// 늦게 온 해석 응답이 새 문장의 결과를 덮지 않게 요청마다 번호를 붙인다
|
||||
int _parseSeq = 0;
|
||||
|
||||
@override
|
||||
AiCourseDraft build() {
|
||||
ref.onDispose(() => _debounce?.cancel());
|
||||
return const AiCourseDraft();
|
||||
}
|
||||
|
||||
/// 문장이 바뀌었다 — 1초 멈추면 해석한다
|
||||
void setText(String text) {
|
||||
_debounce?.cancel();
|
||||
state = state.copyWith(text: text);
|
||||
if (text.trim().isEmpty) return;
|
||||
_debounce = Timer(kParseDebounce, () => unawaited(parseNow()));
|
||||
}
|
||||
|
||||
/// 예시 문구를 눌렀다
|
||||
Future<void> useExample(String text) {
|
||||
_debounce?.cancel();
|
||||
state = state.copyWith(text: text);
|
||||
return parseNow();
|
||||
}
|
||||
|
||||
/// 지금 해석한다(키보드 '완료'·예시·다시 시도). 같은 문장을 이미 해석했으면 부르지 않는다
|
||||
Future<void> parseNow() async {
|
||||
_debounce?.cancel();
|
||||
final text = state.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
if (text == state.parsedText && state.parseError == null) return;
|
||||
final seq = ++_parseSeq;
|
||||
state = state.copyWith(parsing: true, clearParseError: true);
|
||||
try {
|
||||
final base = await ref.read(basePointProvider.future);
|
||||
final parse = await ref
|
||||
.read(aiCourseRepositoryProvider)
|
||||
.parseConditions(
|
||||
text: text,
|
||||
baseArea: baseAreaOf(base),
|
||||
lang: ref.read(contentLanguageProvider),
|
||||
);
|
||||
if (!ref.mounted || seq != _parseSeq) return;
|
||||
state = state.applyParse(parse, text);
|
||||
} catch (error) {
|
||||
if (!ref.mounted || seq != _parseSeq) return;
|
||||
state = state.copyWith(parsing: false, parseError: error);
|
||||
}
|
||||
}
|
||||
|
||||
/// 사용자가 조건을 고쳤다 — [change] 는 지금 조건에서 바꾼 값을 만든다(시트를 닫는 사이 다시 해석됐을 수 있다).
|
||||
/// [fields] 는 고친 항목(시간은 시작·끝을 함께 넘긴다)
|
||||
void edit(
|
||||
CourseConditions Function(CourseConditions current) change,
|
||||
Set<ConditionField> fields,
|
||||
) {
|
||||
final current = state.conditions;
|
||||
if (current == null) return;
|
||||
state = state.applyEdit(change(current), fields);
|
||||
}
|
||||
|
||||
/// 생성을 시작한다. 실패하면 그대로 던진다(화면이 에러 코드로 나눈다).
|
||||
/// [previousGenerationId] 는 결과 화면의 '다시 만들기' — 서버가 지난 결과와 다른 곳을 고른다
|
||||
Future<GenerationCreated> submit({String? previousGenerationId}) async {
|
||||
final conditions = state.conditions;
|
||||
if (conditions == null) throw StateError('조건을 해석하기 전에는 만들 수 없다');
|
||||
state = state.copyWith(submitting: true);
|
||||
try {
|
||||
final created = await ref
|
||||
.read(aiCourseRepositoryProvider)
|
||||
.create(
|
||||
request: state.text.trim(),
|
||||
conditions: conditions,
|
||||
previousGenerationId: previousGenerationId,
|
||||
lang: ref.read(contentLanguageProvider),
|
||||
);
|
||||
await ref.read(pendingGenerationProvider).save(created.id);
|
||||
if (ref.mounted) {
|
||||
state = state.copyWith(submitting: false, generationId: created.id);
|
||||
}
|
||||
ref.invalidate(aiQuotaProvider);
|
||||
return created;
|
||||
} catch (_) {
|
||||
if (ref.mounted) state = state.copyWith(submitting: false);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 만드는 중 · 결과 화면의 상태. [generation] 이 없고 [error] 가 있으면 처음 조회부터 실패했다
|
||||
class GenerationView {
|
||||
const GenerationView({this.generation, this.error, this.canceling = false});
|
||||
|
||||
final CourseGeneration? generation;
|
||||
final Object? error;
|
||||
final bool canceling;
|
||||
|
||||
GenerationView copyWith({
|
||||
CourseGeneration? generation,
|
||||
Object? error,
|
||||
bool clearError = false,
|
||||
bool? canceling,
|
||||
}) => GenerationView(
|
||||
generation: generation ?? this.generation,
|
||||
error: clearError ? null : (error ?? this.error),
|
||||
canceling: canceling ?? this.canceling,
|
||||
);
|
||||
}
|
||||
|
||||
/// 생성 하나의 진행 — 끝날 때까지 [kPollInterval] 마다 조회한다. 앱이 백그라운드면 멈춘다
|
||||
final generationProvider = NotifierProvider.autoDispose
|
||||
.family<GenerationNotifier, GenerationView, String>(GenerationNotifier.new);
|
||||
|
||||
class GenerationNotifier extends Notifier<GenerationView> {
|
||||
GenerationNotifier(this.id);
|
||||
|
||||
final String id;
|
||||
Timer? _timer;
|
||||
bool _paused = false;
|
||||
int _failures = 0;
|
||||
|
||||
@override
|
||||
GenerationView build() {
|
||||
ref.onDispose(() => _timer?.cancel());
|
||||
_timer = Timer(Duration.zero, () => unawaited(_poll()));
|
||||
return const GenerationView();
|
||||
}
|
||||
|
||||
bool get _active => state.generation?.status.isActive ?? true;
|
||||
|
||||
Future<void> _poll() async {
|
||||
_timer?.cancel();
|
||||
if (_paused) return;
|
||||
try {
|
||||
final generation = await ref
|
||||
.read(aiCourseRepositoryProvider)
|
||||
.get(id, lang: ref.read(contentLanguageProvider));
|
||||
if (!ref.mounted) return;
|
||||
_failures = 0;
|
||||
state = state.copyWith(generation: generation, clearError: true);
|
||||
if (generation.status.isActive) {
|
||||
_schedule();
|
||||
} else {
|
||||
await _finished();
|
||||
}
|
||||
} catch (error) {
|
||||
if (!ref.mounted) return;
|
||||
_failures++;
|
||||
// 잠깐 끊긴 것은 넘기고 다시 조회한다. 잇달아 실패하면 오류를 보이고 멈춘다
|
||||
if (state.generation != null && _failures < kPollFailureLimit) {
|
||||
_schedule();
|
||||
} else {
|
||||
state = state.copyWith(error: error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _schedule() {
|
||||
_timer?.cancel();
|
||||
if (_paused || !_active) return;
|
||||
_timer = Timer(kPollInterval, () => unawaited(_poll()));
|
||||
}
|
||||
|
||||
/// 끝났다 — 이어 보기 표시를 지우고 남은 횟수를 다시 받는다
|
||||
Future<void> _finished() async {
|
||||
final pending = ref.read(pendingGenerationProvider);
|
||||
await pending.clear(id);
|
||||
// 그사이 화면을 나갔으면 남은 횟수는 다음에 볼 때 받는다
|
||||
if (ref.mounted) ref.invalidate(aiQuotaProvider);
|
||||
}
|
||||
|
||||
/// 앱이 백그라운드로 갔다
|
||||
void pause() {
|
||||
_paused = true;
|
||||
_timer?.cancel();
|
||||
}
|
||||
|
||||
/// 앱으로 돌아왔다 — 바로 한 번 조회한다
|
||||
void resume() {
|
||||
if (!_paused) return;
|
||||
_paused = false;
|
||||
if (_active) unawaited(_poll());
|
||||
}
|
||||
|
||||
/// "이 코스, 마음에 드세요?" — 결과가 나온 생성만 받는다. 실패하면 던진다(화면이 알린다)
|
||||
Future<void> rate(CourseRating value) async {
|
||||
final generation = await ref
|
||||
.read(aiCourseRepositoryProvider)
|
||||
.rate(id, value, lang: ref.read(contentLanguageProvider));
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(generation: generation);
|
||||
}
|
||||
|
||||
/// 오류 뒤 다시 시도
|
||||
Future<void> retry() {
|
||||
_failures = 0;
|
||||
state = state.copyWith(clearError: true);
|
||||
return _poll();
|
||||
}
|
||||
|
||||
/// 취소 — AI 호출을 끊는다. 차감하지 않는다
|
||||
Future<void> cancel() async {
|
||||
_timer?.cancel();
|
||||
state = state.copyWith(canceling: true);
|
||||
final repository = ref.read(aiCourseRepositoryProvider);
|
||||
final pending = ref.read(pendingGenerationProvider);
|
||||
final lang = ref.read(contentLanguageProvider);
|
||||
try {
|
||||
final generation = await repository.cancel(id, lang: lang);
|
||||
await pending.clear(id);
|
||||
if (!ref.mounted) return;
|
||||
state = GenerationView(generation: generation);
|
||||
} catch (error) {
|
||||
if (!ref.mounted) return;
|
||||
state = state.copyWith(canceling: false, error: error);
|
||||
_schedule();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:wandergo/core/l10n/l10n.dart';
|
||||
import 'package:wandergo/features/ai_course/presentation/providers/ai_course_providers.dart';
|
||||
|
||||
/// AI 화면 위 제목 — "AI 코스 만들기"와 오늘 남은 횟수(만드는 중에는 횟수 없이)
|
||||
class AiCourseTitle extends ConsumerWidget {
|
||||
const AiCourseTitle({super.key, this.showQuota = true});
|
||||
|
||||
final bool showQuota;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = context.l10n;
|
||||
final quota = showQuota ? ref.watch(aiQuotaProvider).value : null;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(l10n.aiInputTitle),
|
||||
if (quota != null) ...[
|
||||
const SizedBox(height: 1),
|
||||
Text(
|
||||
quota.remaining > 0
|
||||
? l10n.aiQuotaRemaining(quota.remaining)
|
||||
: l10n.aiQuotaNone,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:wandergo/core/icons/app_icons.dart';
|
||||
import 'package:wandergo/core/l10n/l10n.dart';
|
||||
import 'package:wandergo/core/router/app_routes.dart';
|
||||
import 'package:wandergo/core/theme/app_colors.dart';
|
||||
import 'package:wandergo/core/theme/app_theme.dart';
|
||||
import 'package:wandergo/core/utils/keep_all.dart';
|
||||
import 'package:wandergo/features/ai_course/data/models/course_generation.dart';
|
||||
import 'package:wandergo/features/ai_course/presentation/providers/ai_course_providers.dart';
|
||||
import 'package:wandergo/shared/widgets/character_image.dart';
|
||||
|
||||
/// 홈 'AI에게 코스 맡기기' — 위아래 구분선 사이에 제목 · 설명 · 횟수와 그림, 누르면 AI 조건 입력(8)으로 가는 줄.
|
||||
/// 디자인의 '비 소식이 있으면 실내 코스로 짜기'는 홈에서 볼 날씨 API 가 생기면 붙인다
|
||||
class AiHomeSection extends ConsumerWidget {
|
||||
const AiHomeSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = context.l10n;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final quota = ref.watch(aiQuotaProvider).value;
|
||||
void open() => context.push(AppRoutes.aiCourse);
|
||||
|
||||
return DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
border: Border.symmetric(
|
||||
horizontal: BorderSide(color: AppColors.divider),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.aiHomeTitle, style: textTheme.titleMedium),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
keepAll(l10n.aiHomeBody),
|
||||
style: textTheme.bodyMedium,
|
||||
),
|
||||
if (quota != null && quota.tier == QuotaTier.guest) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
l10n.aiHomeQuota(quota.limit),
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.brand,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const CharacterImage(width: 84, height: 61),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: l10n.aiHomeTitle,
|
||||
excludeSemantics: true,
|
||||
onTap: open,
|
||||
child: Material(
|
||||
color: AppColors.inputFill,
|
||||
borderRadius: BorderRadius.circular(kRadius),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(kRadius),
|
||||
onTap: open,
|
||||
child: SizedBox(
|
||||
height: 52,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 6, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
const AppIcon(
|
||||
AppIconData.sparkle,
|
||||
size: 18,
|
||||
color: AppColors.brand,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.aiHomePlaceholder,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
fontSize: 14,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// 보내기 모양의 빨간 네모 — 장식(줄 전체가 버튼)
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.brand,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const AppIcon(
|
||||
AppIconData.arrowRight,
|
||||
size: 18,
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:wandergo/core/l10n/l10n.dart';
|
||||
import 'package:wandergo/features/ai_course/data/models/course_conditions.dart';
|
||||
import 'package:wandergo/features/ai_course/domain/condition_rules.dart';
|
||||
import 'package:wandergo/features/location/presentation/widgets/region_sheet.dart';
|
||||
import 'package:wandergo/features/place/data/models/place_models.dart'
|
||||
show PetSize;
|
||||
import 'package:wandergo/features/place/presentation/providers/place_providers.dart';
|
||||
|
||||
/// 조건 지역 이름 — 동네 이름 · 현재 위치 · "서문시장 근처"(장소 이름은 장소 API 로 받는다)
|
||||
String areaLabel(CourseArea area, AppLocalizations l10n, WidgetRef ref) =>
|
||||
switch (area) {
|
||||
NeighborhoodArea(:final neighborhood) => neighborhood.label(l10n),
|
||||
PointArea() => l10n.regionCurrentLocation,
|
||||
PlaceArea(:final placeId) => switch (ref
|
||||
.watch(placeDetailProvider(placeId))
|
||||
.value) {
|
||||
final detail? => l10n.aiPlaceNear(detail.item.displayName),
|
||||
null => l10n.aiPlaceNearUnknown,
|
||||
},
|
||||
};
|
||||
|
||||
/// "소형견 1마리" · "고양이 2마리" · 함께 안 가면 "함께 안 가요"
|
||||
String petLabel(PetCondition? pet, AppLocalizations l10n) {
|
||||
if (pet == null) return l10n.aiPetNone;
|
||||
final kind = switch (pet.kind) {
|
||||
PetKind.dog => switch (pet.size) {
|
||||
PetSize.small => l10n.aiPetSmallDog,
|
||||
PetSize.medium => l10n.aiPetMediumDog,
|
||||
PetSize.large => l10n.aiPetLargeDog,
|
||||
null => l10n.aiPetDog,
|
||||
},
|
||||
PetKind.cat => l10n.aiPetCat,
|
||||
PetKind.other => l10n.aiPetOther,
|
||||
};
|
||||
return l10n.aiPetSummary(pet.count, kind);
|
||||
}
|
||||
|
||||
String mobilityLabel(Mobility mobility, AppLocalizations l10n) =>
|
||||
switch (mobility) {
|
||||
Mobility.walk => l10n.aiMobilityWalk,
|
||||
Mobility.transit => l10n.aiMobilityTransit,
|
||||
Mobility.car => l10n.aiMobilityCar,
|
||||
};
|
||||
|
||||
/// 조건 해석이 알린 것의 문구
|
||||
String issueMessage(ConditionIssue issue, AppLocalizations l10n) =>
|
||||
switch (issue) {
|
||||
ConditionIssue.unsupportedArea => l10n.aiIssueUnsupportedArea,
|
||||
ConditionIssue.unknownPlace => l10n.aiIssueUnknownPlace,
|
||||
ConditionIssue.pastDate => l10n.aiIssuePastDate,
|
||||
ConditionIssue.dateOutOfRange => l10n.aiIssueDateOutOfRange,
|
||||
ConditionIssue.multiDay => l10n.aiIssueMultiDay,
|
||||
ConditionIssue.tooShort => l10n.aiIssueTooShort,
|
||||
ConditionIssue.pastMidnight => l10n.aiIssuePastMidnight,
|
||||
ConditionIssue.unclearRequest => l10n.aiIssueUnclear,
|
||||
};
|
||||
|
||||
/// 만들 수 없는 조건의 문구
|
||||
String problemMessage(ConditionProblem problem, AppLocalizations l10n) =>
|
||||
switch (problem) {
|
||||
ConditionProblem.pastDate => l10n.aiProblemPastDate,
|
||||
ConditionProblem.dateOutOfRange => l10n.aiProblemDateOutOfRange,
|
||||
ConditionProblem.invalidTime => l10n.aiProblemInvalidTime,
|
||||
ConditionProblem.tooShort => l10n.aiProblemTooShort,
|
||||
ConditionProblem.tooLong => l10n.aiProblemTooLong,
|
||||
ConditionProblem.pastTime => l10n.aiProblemPastTime,
|
||||
};
|
||||
|
||||
const int _kMinutesPerHour = 60;
|
||||
|
||||
/// 길이 표시 — "6시간" · "2시간 30분" · 1시간이 안 되면 "19분"
|
||||
String durationLabel(int minutes, AppLocalizations l10n) {
|
||||
final hours = minutes ~/ _kMinutesPerHour;
|
||||
final rest = minutes % _kMinutesPerHour;
|
||||
if (hours == 0) return l10n.aiMinutes(rest);
|
||||
return rest == 0
|
||||
? l10n.aiDurationHours(hours)
|
||||
: l10n.aiDurationHoursMinutes(hours, rest);
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'package:wandergo/core/clock.dart';
|
||||
import 'package:wandergo/core/icons/app_icons.dart';
|
||||
import 'package:wandergo/core/l10n/l10n.dart';
|
||||
import 'package:wandergo/core/theme/app_colors.dart';
|
||||
import 'package:wandergo/core/utils/format.dart';
|
||||
import 'package:wandergo/core/utils/keep_all.dart';
|
||||
import 'package:wandergo/features/ai_course/data/models/course_conditions.dart';
|
||||
import 'package:wandergo/features/ai_course/domain/ai_course_draft.dart';
|
||||
import 'package:wandergo/features/ai_course/domain/condition_rules.dart';
|
||||
import 'package:wandergo/features/ai_course/presentation/providers/ai_course_providers.dart';
|
||||
import 'package:wandergo/features/ai_course/presentation/widgets/condition_labels.dart';
|
||||
import 'package:wandergo/features/ai_course/presentation/widgets/condition_sheets.dart';
|
||||
import 'package:wandergo/features/location/data/location_service.dart';
|
||||
import 'package:wandergo/features/location/domain/base_location.dart';
|
||||
import 'package:wandergo/features/location/presentation/widgets/region_sheet.dart';
|
||||
import 'package:wandergo/shared/widgets/app_switch.dart';
|
||||
import 'package:wandergo/shared/widgets/load_error.dart';
|
||||
|
||||
/// 입력 화면(8)의 '조건' — 문장에서 채운 값을 보이고 줄마다 고친다
|
||||
class ConditionSection extends ConsumerWidget {
|
||||
const ConditionSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = context.l10n;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final draft = ref.watch(aiCourseDraftProvider);
|
||||
final conditions = draft.conditions;
|
||||
|
||||
final Widget body;
|
||||
if (conditions == null) {
|
||||
final error = draft.parseError;
|
||||
body = switch ((draft.parsing, error)) {
|
||||
(true, _) => _Parsing(text: l10n.aiParsing),
|
||||
(false, final error?) => LoadError(
|
||||
error: error,
|
||||
onRetry: () => ref.read(aiCourseDraftProvider.notifier).parseNow(),
|
||||
),
|
||||
_ => Text(keepAll(l10n.aiConditionsEmpty), style: textTheme.bodyMedium),
|
||||
};
|
||||
} else {
|
||||
body = _ConditionRows(draft: draft, conditions: conditions);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.aiConditionsTitle, style: textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
if (conditions != null) ...[
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
keepAll(l10n.aiConditionsAutoFilled),
|
||||
style: textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
if (draft.parsing) const _SmallSpinner(),
|
||||
],
|
||||
),
|
||||
if (draft.parseError case final error?)
|
||||
LoadError(
|
||||
error: error,
|
||||
onRetry: () =>
|
||||
ref.read(aiCourseDraftProvider.notifier).parseNow(),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
body,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Parsing extends StatelessWidget {
|
||||
const _Parsing({required this.text});
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const _SmallSpinner(),
|
||||
const SizedBox(width: 10),
|
||||
Text(text, style: Theme.of(context).textTheme.bodyMedium),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SmallSpinner extends StatelessWidget {
|
||||
const _SmallSpinner();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.brand),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConditionRows extends ConsumerWidget {
|
||||
const _ConditionRows({required this.draft, required this.conditions});
|
||||
|
||||
final AiCourseDraft draft;
|
||||
final CourseConditions conditions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = context.l10n;
|
||||
final localeName = Localizations.localeOf(context).toString();
|
||||
final notifier = ref.read(aiCourseDraftProvider.notifier);
|
||||
final problem = conditionsProblem(conditions, ref.watch(clockProvider)());
|
||||
|
||||
/// 그 줄에 보일 알림 — 만들 수 없는 조건이 먼저, 그다음 조건 해석이 알린 것
|
||||
_RowMessage? messageFor(ConditionField field) {
|
||||
if (problem != null && problemField(problem) == field) {
|
||||
return (text: problemMessage(problem, l10n), blocking: true);
|
||||
}
|
||||
for (final issue in draft.issues) {
|
||||
if (issueField(issue) == field) {
|
||||
return (
|
||||
text: issueMessage(issue, l10n),
|
||||
blocking: isBlockingIssue(issue),
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool inferred(ConditionField field) => draft.inferred.contains(field);
|
||||
final weather = draft.weatherForDate;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_ConditionRow(
|
||||
icon: AppIconData.pin,
|
||||
label: l10n.aiFieldArea,
|
||||
value: areaLabel(conditions.area, l10n, ref),
|
||||
inferred: inferred(ConditionField.area),
|
||||
message: messageFor(ConditionField.area),
|
||||
onTap: () => _pickArea(context, ref),
|
||||
),
|
||||
_ConditionRow(
|
||||
icon: AppIconData.calendar,
|
||||
label: l10n.aiFieldDate,
|
||||
value: formatCourseDate(conditions.date, localeName: localeName),
|
||||
inferred: inferred(ConditionField.date),
|
||||
message: messageFor(ConditionField.date),
|
||||
onTap: () => _pickDate(context, ref),
|
||||
),
|
||||
_ConditionRow(
|
||||
icon: AppIconData.clock,
|
||||
label: l10n.aiFieldTime,
|
||||
value: formatTimeRange(conditions.start, conditions.end),
|
||||
inferred:
|
||||
inferred(ConditionField.start) || inferred(ConditionField.end),
|
||||
message: messageFor(ConditionField.start),
|
||||
onTap: () => _pickTime(context, ref),
|
||||
),
|
||||
_ConditionRow(
|
||||
icon: AppIconData.people,
|
||||
label: l10n.aiFieldParty,
|
||||
value: l10n.aiPartyCount(conditions.party),
|
||||
inferred: inferred(ConditionField.party),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RoundStepButton(
|
||||
icon: AppIconData.minus,
|
||||
label: l10n.aiPartyDecrease,
|
||||
onPressed: conditions.party > kPartyMin
|
||||
? () => notifier.edit(
|
||||
(c) => c.copyWith(party: c.party - 1),
|
||||
{ConditionField.party},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
RoundStepButton(
|
||||
icon: AppIconData.plus,
|
||||
label: l10n.aiPartyIncrease,
|
||||
onPressed: conditions.party < kPartyMax
|
||||
? () => notifier.edit(
|
||||
(c) => c.copyWith(party: c.party + 1),
|
||||
{ConditionField.party},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_ConditionRow(
|
||||
icon: conditions.pet == null ? AppIconData.paw : AppIconData.pawSolid,
|
||||
iconColor: conditions.pet == null ? null : AppColors.pet,
|
||||
label: l10n.aiFieldPet,
|
||||
value: petLabel(conditions.pet, l10n),
|
||||
valueColor: conditions.pet == null ? null : AppColors.pet,
|
||||
inferred: inferred(ConditionField.pet),
|
||||
onTap: () => _pickPet(context, ref),
|
||||
),
|
||||
_MobilityRow(
|
||||
value: conditions.mobility,
|
||||
onChanged: (mobility) => notifier.edit(
|
||||
(c) => c.copyWith(mobility: mobility),
|
||||
{ConditionField.mobility},
|
||||
),
|
||||
),
|
||||
_IndoorRow(
|
||||
value: conditions.indoorIfRain,
|
||||
rainText: weather == null
|
||||
? null
|
||||
: l10n.aiRainChance(
|
||||
formatWeekday(weather.date, localeName: localeName),
|
||||
weather.precipitationProbability,
|
||||
),
|
||||
onChanged: (value) => notifier.edit(
|
||||
(c) => c.copyWith(indoorIfRain: value),
|
||||
{ConditionField.indoorIfRain},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 지역 — 동네 셋 또는 현재 위치(대구 안이어야 한다)
|
||||
Future<void> _pickArea(BuildContext context, WidgetRef ref) async {
|
||||
final l10n = context.l10n;
|
||||
final selected = switch (conditions.area) {
|
||||
NeighborhoodArea(:final neighborhood) => BaseLocationChoice.neighborhood(
|
||||
neighborhood,
|
||||
),
|
||||
PointArea() => const BaseLocationChoice.current(),
|
||||
PlaceArea() => null,
|
||||
};
|
||||
final picked = await showRegionSheet(
|
||||
context,
|
||||
selected: selected,
|
||||
title: l10n.aiAreaSheetTitle,
|
||||
);
|
||||
if (picked == null || !context.mounted) return;
|
||||
final neighborhood = picked.neighborhood;
|
||||
final CourseArea area;
|
||||
if (neighborhood != null) {
|
||||
area = NeighborhoodArea(neighborhood);
|
||||
} else {
|
||||
final location = ref.read(locationServiceProvider);
|
||||
final granted = await location.requestPermission();
|
||||
final point = granted ? await location.currentPoint() : null;
|
||||
if (!context.mounted) return;
|
||||
void tell(String message) => ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
if (point == null) {
|
||||
tell(l10n.locationUnavailable);
|
||||
return;
|
||||
}
|
||||
// 서버도 대구 밖 현재 위치를 받지 않는다(VAL_001) — 고를 때 막는다
|
||||
if (!isInServiceArea(point)) {
|
||||
tell(l10n.aiIssueUnsupportedArea);
|
||||
return;
|
||||
}
|
||||
area = PointArea(lat: point.lat, lng: point.lng);
|
||||
}
|
||||
ref.read(aiCourseDraftProvider.notifier).edit(
|
||||
(c) => c.copyWith(area: area),
|
||||
{ConditionField.area},
|
||||
);
|
||||
}
|
||||
|
||||
/// 날짜 — 오늘(한국 날짜)부터 30일 뒤까지
|
||||
Future<void> _pickDate(BuildContext context, WidgetRef ref) async {
|
||||
final today = parseYmd(kstClock(ref.read(clockProvider)()).date)!;
|
||||
final first = DateTime(today.year, today.month, today.day);
|
||||
final last = first.add(const Duration(days: kDateRangeDays));
|
||||
final current = parseYmd(conditions.date);
|
||||
var initial = current == null
|
||||
? first
|
||||
: DateTime(current.year, current.month, current.day);
|
||||
if (initial.isBefore(first)) initial = first;
|
||||
if (initial.isAfter(last)) initial = last;
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: initial,
|
||||
firstDate: first,
|
||||
lastDate: last,
|
||||
);
|
||||
if (picked == null) return;
|
||||
ref.read(aiCourseDraftProvider.notifier).edit(
|
||||
(c) => c.copyWith(date: toYmd(picked)),
|
||||
{ConditionField.date},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickTime(BuildContext context, WidgetRef ref) async {
|
||||
final picked = await showTimeSheet(
|
||||
context,
|
||||
start: conditions.start,
|
||||
end: conditions.end,
|
||||
);
|
||||
if (picked == null) return;
|
||||
ref.read(aiCourseDraftProvider.notifier).edit(
|
||||
(c) => c.copyWith(start: picked.start, end: picked.end),
|
||||
{ConditionField.start, ConditionField.end},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickPet(BuildContext context, WidgetRef ref) async {
|
||||
final picked = await showPetSheet(context, current: conditions.pet);
|
||||
if (picked == null) return;
|
||||
ref.read(aiCourseDraftProvider.notifier).edit(
|
||||
(c) => c.withPet(picked.pet),
|
||||
{ConditionField.pet},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 줄 아래 알림 — [blocking] 이면 고쳐야 만들 수 있다
|
||||
typedef _RowMessage = ({String text, bool blocking});
|
||||
|
||||
/// 조건 한 줄 — 아이콘 · 이름 · 값 · (자동으로 채웠으면 반짝임) · 오른쪽
|
||||
class _ConditionRow extends StatelessWidget {
|
||||
const _ConditionRow({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.iconColor,
|
||||
this.valueColor,
|
||||
this.inferred = false,
|
||||
this.message,
|
||||
this.onTap,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
final AppIconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
/// 없으면 보조 회색
|
||||
final Color? iconColor;
|
||||
|
||||
/// 강조 값(반려동물) — 이 색 굵은 글자. 없으면 본문색 보통 굵기
|
||||
final Color? valueColor;
|
||||
final bool inferred;
|
||||
final _RowMessage? message;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// 없으면 누를 수 있는 줄의 화살표
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final message = this.message;
|
||||
final row = ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 52),
|
||||
child: Row(
|
||||
children: [
|
||||
AppIcon(icon, size: 20, color: iconColor ?? AppColors.textTertiary),
|
||||
const SizedBox(width: 10),
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: Text(
|
||||
label,
|
||||
style: textTheme.bodyMedium?.copyWith(fontSize: 14),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
value,
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
fontWeight: valueColor == null
|
||||
? FontWeight.w400
|
||||
: FontWeight.w600,
|
||||
color: valueColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (inferred) ...[
|
||||
const SizedBox(width: 4),
|
||||
const AppIcon(
|
||||
AppIconData.sparkle,
|
||||
size: 14,
|
||||
color: AppColors.brand,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
trailing ??
|
||||
(onTap == null
|
||||
? const SizedBox.shrink()
|
||||
: const AppIcon(
|
||||
AppIconData.chevronRight,
|
||||
size: 16,
|
||||
color: AppColors.iconMuted,
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: AppColors.divider)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (onTap == null) row else InkWell(onTap: onTap, child: row),
|
||||
if (message != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 30, bottom: 10),
|
||||
child: Text(
|
||||
keepAll(message.text),
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: message.blocking
|
||||
? AppColors.danger
|
||||
: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 장소 사이 이동 — 걸어서 · 대중교통 · 자동차
|
||||
class _MobilityRow extends StatelessWidget {
|
||||
const _MobilityRow({required this.value, required this.onChanged});
|
||||
|
||||
final Mobility value;
|
||||
final ValueChanged<Mobility> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: AppColors.divider)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const AppIcon(
|
||||
AppIconData.walk,
|
||||
size: 20,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
l10n.aiFieldMobility,
|
||||
style: textTheme.bodyMedium?.copyWith(fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(3),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.inputFill,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final mobility in Mobility.values)
|
||||
Expanded(
|
||||
child: _Segment(
|
||||
label: mobilityLabel(mobility, l10n),
|
||||
selected: mobility == value,
|
||||
onTap: () => onChanged(mobility),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Segment extends StatelessWidget {
|
||||
const _Segment({
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
inMutuallyExclusiveGroup: true,
|
||||
checked: selected,
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
height: 36,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? AppColors.background : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: selected
|
||||
? const [
|
||||
BoxShadow(
|
||||
color: AppColors.raisedShadow,
|
||||
blurRadius: 2,
|
||||
offset: Offset(0, 1),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: selected ? AppColors.text : AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 비가 오면 실내 위주로 — 그날 예보가 있으면 강수확률을 함께 보인다
|
||||
class _IndoorRow extends StatelessWidget {
|
||||
const _IndoorRow({
|
||||
required this.value,
|
||||
required this.rainText,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final bool value;
|
||||
final String? rainText;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final rainText = this.rainText;
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 64),
|
||||
child: Row(
|
||||
children: [
|
||||
const AppIcon(
|
||||
AppIconData.umbrella,
|
||||
size: 20,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.aiIndoorIfRain,
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
height: 21 / 15,
|
||||
color: AppColors.text,
|
||||
),
|
||||
),
|
||||
if (rainText != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
rainText,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
height: 18 / 13,
|
||||
color: AppColors.rain,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
AppSwitch(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
semanticLabel: l10n.aiIndoorIfRain,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:wandergo/core/icons/app_icons.dart';
|
||||
import 'package:wandergo/core/l10n/l10n.dart';
|
||||
import 'package:wandergo/core/theme/app_colors.dart';
|
||||
import 'package:wandergo/core/theme/app_theme.dart';
|
||||
import 'package:wandergo/features/ai_course/data/models/course_conditions.dart';
|
||||
import 'package:wandergo/features/ai_course/domain/condition_rules.dart';
|
||||
import 'package:wandergo/features/ai_course/presentation/widgets/condition_labels.dart';
|
||||
import 'package:wandergo/features/place/data/models/place_models.dart'
|
||||
show PetSize;
|
||||
|
||||
const int _kMinutesPerDay = 1440;
|
||||
|
||||
/// 시각을 읽지 못했을 때 시작(13:00 — 백엔드 기본값과 같다)
|
||||
const int _kFallbackStartMinutes = 13 * 60;
|
||||
|
||||
/// 시간 고르기 — 시작 · 끝을 30분씩 옮긴다(2~12시간, 자정까지). 고르지 않고 닫으면 null
|
||||
Future<({String start, String end})?> showTimeSheet(
|
||||
BuildContext context, {
|
||||
required String start,
|
||||
required String end,
|
||||
}) {
|
||||
return showModalBottomSheet<({String start, String end})>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => _TimeSheet(start: start, end: end),
|
||||
);
|
||||
}
|
||||
|
||||
class _TimeSheet extends StatefulWidget {
|
||||
const _TimeSheet({required this.start, required this.end});
|
||||
|
||||
final String start;
|
||||
final String end;
|
||||
|
||||
@override
|
||||
State<_TimeSheet> createState() => _TimeSheetState();
|
||||
}
|
||||
|
||||
class _TimeSheetState extends State<_TimeSheet> {
|
||||
late int _start;
|
||||
late int _end;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_start = parseHhmm(widget.start) ?? _kFallbackStartMinutes;
|
||||
_end = parseHhmm(widget.end) ?? _start + kWindowMinMinutes;
|
||||
}
|
||||
|
||||
ConditionProblem? get _problem {
|
||||
final length = _end - _start;
|
||||
if (length < kWindowMinMinutes) return ConditionProblem.tooShort;
|
||||
if (length > kWindowMaxMinutes) return ConditionProblem.tooLong;
|
||||
return null;
|
||||
}
|
||||
|
||||
void _moveStart(int delta) {
|
||||
final next = _start + delta;
|
||||
if (next < 0 || next > _kMinutesPerDay - kWindowMinMinutes) return;
|
||||
setState(() {
|
||||
_start = next;
|
||||
// 끝을 함께 밀어 2시간을 지킨다(자정을 넘기지는 않는다)
|
||||
if (_end - _start < kWindowMinMinutes) _end = _start + kWindowMinMinutes;
|
||||
});
|
||||
}
|
||||
|
||||
void _moveEnd(int delta) {
|
||||
final next = _end + delta;
|
||||
if (next <= _start || next > _kMinutesPerDay) return;
|
||||
setState(() => _end = next);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final problem = _problem;
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(kPagePadding, 0, kPagePadding, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.aiTimeSheetTitle, style: textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
_Stepper(
|
||||
label: l10n.aiTimeStart,
|
||||
value: formatHhmm(_start),
|
||||
decreaseLabel: l10n.aiTimeEarlier(l10n.aiTimeStart),
|
||||
increaseLabel: l10n.aiTimeLater(l10n.aiTimeStart),
|
||||
onDecrease: () => _moveStart(-kTimeStepMinutes),
|
||||
onIncrease: () => _moveStart(kTimeStepMinutes),
|
||||
),
|
||||
const Divider(),
|
||||
_Stepper(
|
||||
label: l10n.aiTimeEnd,
|
||||
value: formatHhmm(_end),
|
||||
decreaseLabel: l10n.aiTimeEarlier(l10n.aiTimeEnd),
|
||||
increaseLabel: l10n.aiTimeLater(l10n.aiTimeEnd),
|
||||
onDecrease: () => _moveEnd(-kTimeStepMinutes),
|
||||
onIncrease: () => _moveEnd(kTimeStepMinutes),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
problem == null
|
||||
? durationLabel(_end - _start, l10n)
|
||||
: problemMessage(problem, l10n),
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: problem == null ? null : AppColors.danger,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: problem == null
|
||||
? () => Navigator.of(
|
||||
context,
|
||||
).pop((start: formatHhmm(_start), end: formatHhmm(_end)))
|
||||
: null,
|
||||
child: Text(l10n.aiApply),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 이름 · 값 · 줄이기/늘리기 버튼 한 줄
|
||||
class _Stepper extends StatelessWidget {
|
||||
const _Stepper({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.decreaseLabel,
|
||||
required this.increaseLabel,
|
||||
required this.onDecrease,
|
||||
required this.onIncrease,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final String decreaseLabel;
|
||||
final String increaseLabel;
|
||||
final VoidCallback? onDecrease;
|
||||
final VoidCallback? onIncrease;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
return SizedBox(
|
||||
height: 56,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
),
|
||||
RoundStepButton(
|
||||
icon: AppIconData.minus,
|
||||
label: decreaseLabel,
|
||||
onPressed: onDecrease,
|
||||
),
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: Text(
|
||||
value,
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
RoundStepButton(
|
||||
icon: AppIconData.plus,
|
||||
label: increaseLabel,
|
||||
onPressed: onIncrease,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 둥근 테두리의 줄이기/늘리기 버튼 — 누르는 영역은 44px 이다
|
||||
class RoundStepButton extends StatelessWidget {
|
||||
const RoundStepButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final AppIconData icon;
|
||||
|
||||
/// 화면 읽기 프로그램용 이름
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final enabled = onPressed != null;
|
||||
return Semantics(
|
||||
button: true,
|
||||
enabled: enabled,
|
||||
label: label,
|
||||
excludeSemantics: true,
|
||||
onTap: onPressed,
|
||||
child: InkResponse(
|
||||
onTap: onPressed,
|
||||
radius: 22,
|
||||
child: SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: AppColors.chipBorder),
|
||||
),
|
||||
child: Center(
|
||||
child: AppIcon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: enabled ? AppColors.text : AppColors.chipBorder,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 반려동물 고르기 결과 — [pet] 이 null 이면 '함께 안 가요'
|
||||
typedef PetChoice = ({PetCondition? pet});
|
||||
|
||||
/// 반려동물 고르기. 고르지 않고 닫으면 null
|
||||
Future<PetChoice?> showPetSheet(BuildContext context, {PetCondition? current}) {
|
||||
return showModalBottomSheet<PetChoice>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => _PetSheet(current: current),
|
||||
);
|
||||
}
|
||||
|
||||
class _PetSheet extends StatefulWidget {
|
||||
const _PetSheet({required this.current});
|
||||
|
||||
final PetCondition? current;
|
||||
|
||||
@override
|
||||
State<_PetSheet> createState() => _PetSheetState();
|
||||
}
|
||||
|
||||
class _PetSheetState extends State<_PetSheet> {
|
||||
PetKind? _kind;
|
||||
PetSize? _size;
|
||||
int _count = 1;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final pet = widget.current;
|
||||
_kind = pet?.kind;
|
||||
_size = pet?.size;
|
||||
_count = pet?.count ?? 1;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final kind = _kind;
|
||||
final kinds = <(PetKind?, String)>[
|
||||
(null, l10n.aiPetKindNone),
|
||||
(PetKind.dog, l10n.aiPetKindDog),
|
||||
(PetKind.cat, l10n.aiPetKindCat),
|
||||
(PetKind.other, l10n.aiPetKindOther),
|
||||
];
|
||||
final sizes = <(PetSize?, String)>[
|
||||
(PetSize.small, l10n.petSizeSmall),
|
||||
(PetSize.medium, l10n.petSizeMedium),
|
||||
(PetSize.large, l10n.petSizeLarge),
|
||||
(null, l10n.aiPetSizeUnknown),
|
||||
];
|
||||
return SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(kPagePadding, 0, kPagePadding, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.aiPetSheetTitle, style: textTheme.titleMedium),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final (value, label) in kinds)
|
||||
ChoiceChip(
|
||||
label: Text(label),
|
||||
selected: kind == value,
|
||||
onSelected: (_) => setState(() {
|
||||
_kind = value;
|
||||
if (value != PetKind.dog) _size = null;
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (kind == PetKind.dog) ...[
|
||||
const SizedBox(height: 20),
|
||||
Text(l10n.aiPetSize, style: textTheme.bodyMedium),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final (value, label) in sizes)
|
||||
ChoiceChip(
|
||||
label: Text(label),
|
||||
selected: _size == value,
|
||||
onSelected: (_) => setState(() => _size = value),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (kind != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_Stepper(
|
||||
label: l10n.aiPetCount,
|
||||
value: '$_count',
|
||||
decreaseLabel: l10n.aiPetCountDecrease,
|
||||
increaseLabel: l10n.aiPetCountIncrease,
|
||||
onDecrease: _count > 1 ? () => setState(() => _count--) : null,
|
||||
onIncrease: _count < kPetCountMax
|
||||
? () => setState(() => _count++)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop((
|
||||
pet: kind == null
|
||||
? null
|
||||
: PetCondition(kind: kind, size: _size, count: _count),
|
||||
)),
|
||||
child: Text(l10n.aiApply),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,391 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:wandergo/core/icons/app_icons.dart';
|
||||
import 'package:wandergo/core/l10n/l10n.dart';
|
||||
import 'package:wandergo/core/theme/app_colors.dart';
|
||||
import 'package:wandergo/core/theme/app_theme.dart';
|
||||
import 'package:wandergo/core/utils/format.dart';
|
||||
import 'package:wandergo/core/utils/keep_all.dart';
|
||||
import 'package:wandergo/features/ai_course/data/models/course_conditions.dart';
|
||||
import 'package:wandergo/features/ai_course/data/models/course_generation.dart';
|
||||
import 'package:wandergo/features/ai_course/domain/condition_rules.dart';
|
||||
import 'package:wandergo/features/event/data/models/event_models.dart';
|
||||
import 'package:wandergo/features/event/presentation/widgets/event_visuals.dart';
|
||||
import 'package:wandergo/features/place/data/models/place_models.dart';
|
||||
import 'package:wandergo/features/place/presentation/widgets/place_visuals.dart';
|
||||
|
||||
/// 장소 분류 이름 — 행사는 행사 종류(공연 · 축제 · 전시 · 행사), 장소는 장소 분류
|
||||
String stopCategoryLabel(CourseStop stop, AppLocalizations l10n) {
|
||||
if (stop.kind == StopKind.event) {
|
||||
return (EventKind.parse(stop.eventKind) ?? EventKind.event).label(l10n);
|
||||
}
|
||||
return PlaceCategory.parse(stop.category)?.label(l10n) ??
|
||||
l10n.placeCategoryOther;
|
||||
}
|
||||
|
||||
/// 반려 동반 글자 — "반려견 동반 · 소형견" · "일부 구역 동반". 확인하지 못했으면 null
|
||||
String? stopPetLabel(CourseStop stop, AppLocalizations l10n) {
|
||||
final label = petLabel(l10n, stop.facts.petPolicy);
|
||||
if (label == null) return null;
|
||||
final sizes = stop.facts.petSizes ?? const <PetSize>[];
|
||||
if (sizes.isEmpty) return label;
|
||||
return '$label · ${sizes.map((s) => s.label(l10n)).join('·')}';
|
||||
}
|
||||
|
||||
/// 사실 줄 — "영업 11:00–19:00 · 10.2 확인 · 출처: 한국관광공사"
|
||||
String stopFactsText(
|
||||
CourseStop stop,
|
||||
AppLocalizations l10n, {
|
||||
required String localeName,
|
||||
}) {
|
||||
final facts = stop.facts;
|
||||
final hours = facts.hoursOnDate;
|
||||
final period = facts.eventPeriod;
|
||||
final verifiedAt = facts.verifiedAt;
|
||||
return [
|
||||
if (period != null)
|
||||
l10n.aiEventPeriod(
|
||||
formatDateRange(period.start, period.end, localeName: localeName),
|
||||
)
|
||||
else if (hours != null && hours.isNotEmpty)
|
||||
l10n.aiHours(
|
||||
hours.map((h) => formatTimeRange(h.start, h.end)).join(', '),
|
||||
),
|
||||
if (verifiedAt != null)
|
||||
l10n.placeVerifiedOn(
|
||||
formatDateRange(
|
||||
toYmd(verifiedAt.toUtc()),
|
||||
toYmd(verifiedAt.toUtc()),
|
||||
localeName: localeName,
|
||||
),
|
||||
),
|
||||
if (facts.provider.isNotEmpty) l10n.sourceLabel(facts.provider),
|
||||
].join(' · ');
|
||||
}
|
||||
|
||||
String warningText(StopWarning warning, AppLocalizations l10n) =>
|
||||
switch (warning) {
|
||||
StopWarning.hoursUnknown => l10n.aiWarnHoursUnknown,
|
||||
StopWarning.eventTimeUnknown => l10n.aiWarnEventTimeUnknown,
|
||||
StopWarning.petUnknown => l10n.aiWarnPetUnknown,
|
||||
StopWarning.eventLastDay => l10n.aiWarnEventLastDay,
|
||||
};
|
||||
|
||||
/// 이동 — "걸어서 6분 · 450m" · "대중교통 약 12분 · 3.1km" · "차로 약 8분 · 4.2km"
|
||||
String legText(CourseLeg leg, AppLocalizations l10n) {
|
||||
final distance = formatDistance(leg.meters);
|
||||
return switch (leg.mode) {
|
||||
Mobility.walk => l10n.aiLegWalk(leg.minutes, distance),
|
||||
Mobility.transit => l10n.aiLegTransit(leg.minutes, distance),
|
||||
Mobility.car => l10n.aiLegCar(leg.minutes, distance),
|
||||
};
|
||||
}
|
||||
|
||||
/// 장소 사진 — 사진이 없으면 분류별 자리 표시. 공공누리 Type3 은 자르지 않는다
|
||||
class CourseStopImage extends StatelessWidget {
|
||||
const CourseStopImage({
|
||||
super.key,
|
||||
required this.stop,
|
||||
required this.width,
|
||||
required this.height,
|
||||
});
|
||||
|
||||
final CourseStop stop;
|
||||
final double width;
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final photo = stop.photo;
|
||||
if (stop.kind == StopKind.event) {
|
||||
return EventImage(
|
||||
url: photo?.thumbUrl,
|
||||
kind: EventKind.parse(stop.eventKind),
|
||||
license: photo?.license,
|
||||
width: width,
|
||||
height: height,
|
||||
);
|
||||
}
|
||||
return PlaceImage(
|
||||
url: photo?.thumbUrl,
|
||||
category: PlaceCategory.parse(stop.category),
|
||||
license: photo?.license,
|
||||
width: width,
|
||||
height: height,
|
||||
iconSize: 24,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 코스 순서의 한 칸 — 번호 원과 이어지는 선(타임라인) · 시각 · 이름 · 분류 · 사진 · 이유 · 경고 · 사실 · 다음 이동
|
||||
class CourseStopTile extends StatelessWidget {
|
||||
const CourseStopTile({
|
||||
super.key,
|
||||
required this.number,
|
||||
required this.stop,
|
||||
required this.isLast,
|
||||
required this.showPet,
|
||||
required this.onOpen,
|
||||
});
|
||||
|
||||
final int number;
|
||||
final CourseStop stop;
|
||||
final bool isLast;
|
||||
|
||||
/// 반려동물과 함께 가는 코스면 동반 정보를 보인다
|
||||
final bool showPet;
|
||||
final VoidCallback onOpen;
|
||||
|
||||
/// 번호 원 지름
|
||||
static const double _dot = 28;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final meta = textTheme.bodyMedium?.copyWith(height: 18 / 13);
|
||||
final small = textTheme.bodySmall?.copyWith(height: 17 / 12);
|
||||
final facts = stopFactsText(stop, l10n, localeName: l10n.localeName);
|
||||
final leg = stop.legToNext;
|
||||
final warnings = [
|
||||
for (final warning in stop.warnings)
|
||||
if (warning != StopWarning.petUnknown || showPet) warning,
|
||||
];
|
||||
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 번호 원과 다음 장소로 이어지는 선
|
||||
SizedBox(
|
||||
width: _dot,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: _dot,
|
||||
height: _dot,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.brand,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
'$number',
|
||||
style: textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!isLast)
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: 2,
|
||||
margin: const EdgeInsets.only(top: 4),
|
||||
color: AppColors.chipBorderSelected,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_Heading(stop: stop, showPet: showPet, onOpen: onOpen),
|
||||
if (stop.reason.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
const WidgetSpan(
|
||||
alignment: PlaceholderAlignment.middle,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(right: 4),
|
||||
child: AppIcon(
|
||||
AppIconData.sparkleSolid,
|
||||
size: 13,
|
||||
color: AppColors.brand,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextSpan(text: keepAll(stop.reason)),
|
||||
],
|
||||
),
|
||||
style: meta?.copyWith(
|
||||
height: 20 / 13,
|
||||
color: AppColors.label,
|
||||
),
|
||||
),
|
||||
],
|
||||
for (final warning in warnings)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 1.5),
|
||||
child: AppIcon(
|
||||
AppIconData.info,
|
||||
size: 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
keepAll(warningText(warning, l10n)),
|
||||
style: small?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (facts.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
keepAll(facts),
|
||||
style: small?.copyWith(color: AppColors.textHint),
|
||||
),
|
||||
),
|
||||
if (leg != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 14, bottom: 18),
|
||||
child: Row(
|
||||
children: [
|
||||
switch (leg.mode) {
|
||||
Mobility.walk => const AppIcon(
|
||||
AppIconData.walk,
|
||||
size: 16,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
Mobility.transit => const Icon(
|
||||
Icons.directions_bus_outlined,
|
||||
size: 16,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
Mobility.car => const Icon(
|
||||
Icons.directions_car_outlined,
|
||||
size: 16,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
},
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(legText(leg, l10n), style: meta),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (!isLast)
|
||||
const SizedBox(height: 18),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 시각 · 이름 · 분류 줄과 오른쪽 사진 — 누르면 장소 · 행사 상세로 간다
|
||||
class _Heading extends StatelessWidget {
|
||||
const _Heading({
|
||||
required this.stop,
|
||||
required this.showPet,
|
||||
required this.onOpen,
|
||||
});
|
||||
|
||||
final CourseStop stop;
|
||||
final bool showPet;
|
||||
final VoidCallback onOpen;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final meta = textTheme.bodyMedium?.copyWith(height: 18 / 13);
|
||||
final setting = switch (stop.facts.setting) {
|
||||
StopSetting.indoor => l10n.placeSettingIndoor,
|
||||
StopSetting.outdoor => l10n.placeSettingOutdoor,
|
||||
StopSetting.unknown => null,
|
||||
};
|
||||
final pet = showPet ? stopPetLabel(stop, l10n) : null;
|
||||
final period = stop.facts.eventPeriod;
|
||||
// 행사는 "축제 10.31–11.2"처럼 종류와 기간을 굵은 날짜색으로 앞에 둔다
|
||||
final InlineSpan lead = stop.kind == StopKind.event
|
||||
? TextSpan(
|
||||
text: [
|
||||
stopCategoryLabel(stop, l10n),
|
||||
if (period != null)
|
||||
formatDateRange(
|
||||
period.start,
|
||||
period.end,
|
||||
localeName: l10n.localeName,
|
||||
),
|
||||
].join(' '),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.eventDate,
|
||||
),
|
||||
)
|
||||
: TextSpan(text: stopCategoryLabel(stop, l10n));
|
||||
|
||||
return InkWell(
|
||||
onTap: onOpen,
|
||||
borderRadius: BorderRadius.circular(kRadius),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.aiStopTime(
|
||||
formatKstTime(stop.arrive),
|
||||
formatKstTime(stop.leave),
|
||||
stop.minutes,
|
||||
),
|
||||
style: meta,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
keepAll(stop.name),
|
||||
style: textTheme.titleSmall?.copyWith(
|
||||
fontSize: 16,
|
||||
height: 22 / 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
lead,
|
||||
if (setting != null) TextSpan(text: ' · $setting'),
|
||||
if (pet != null) ...[
|
||||
const TextSpan(text: ' · '),
|
||||
petSpan(pet),
|
||||
],
|
||||
],
|
||||
),
|
||||
style: meta?.copyWith(color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
CourseStopImage(stop: stop, width: 64, height: 64),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:wandergo/core/api/error_lexicon.dart';
|
||||
import 'package:wandergo/core/l10n/l10n.dart';
|
||||
import 'package:wandergo/core/theme/app_theme.dart';
|
||||
import 'package:wandergo/core/utils/keep_all.dart';
|
||||
import 'package:wandergo/shared/widgets/character_image.dart';
|
||||
|
||||
/// 하루 횟수를 다 썼다(AI_001) — 그림 · 사전 문구 · 다시 채워지는 때(시안 공통 상태 3).
|
||||
/// 디자인의 '로그인하기' · '직접 코스 짜기'는 그 기능이 생기면 붙인다
|
||||
Future<void> showQuotaExhaustedSheet(BuildContext context) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
final l10n = context.l10n;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(kPagePadding, 0, kPagePadding, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CharacterImage(width: 110, height: 80),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
keepAll(ErrorLexicon.getMessage('AI_001')),
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.titleSmall?.copyWith(height: 23 / 15),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
keepAll(l10n.aiQuotaExhaustedHint),
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
fontSize: 14,
|
||||
height: 21 / 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.aiConfirm),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:wandergo/core/l10n/l10n.dart';
|
||||
import 'package:wandergo/core/theme/app_theme.dart';
|
||||
import 'package:wandergo/core/utils/keep_all.dart';
|
||||
|
||||
/// '다시 만들기' 확인 — 같은 조건으로 다른 곳을 골라 새로 만든다. 하루 횟수가 차감되므로 한 번 묻는다
|
||||
Future<bool> showRegenerateSheet(BuildContext context, {int? remaining}) async {
|
||||
final confirmed = await showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
final l10n = context.l10n;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(kPagePadding, 0, kPagePadding, 12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(l10n.aiRegenerateTitle, style: textTheme.titleMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
keepAll(
|
||||
remaining == null
|
||||
? l10n.aiRegenerateBodyNoCount
|
||||
: l10n.aiRegenerateBody(remaining),
|
||||
),
|
||||
style: textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(l10n.aiRegenerate),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(l10n.aiCancel),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
return confirmed ?? false;
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import 'package:wandergo/core/router/app_routes.dart';
|
||||
import 'package:wandergo/core/theme/app_colors.dart';
|
||||
import 'package:wandergo/core/theme/app_theme.dart';
|
||||
import 'package:wandergo/core/utils/keep_all.dart';
|
||||
import 'package:wandergo/features/ai_course/presentation/providers/ai_course_providers.dart';
|
||||
import 'package:wandergo/features/ai_course/presentation/widgets/ai_home_section.dart';
|
||||
import 'package:wandergo/features/event/presentation/providers/event_providers.dart';
|
||||
import 'package:wandergo/features/event/presentation/widgets/event_card.dart';
|
||||
import 'package:wandergo/features/home/presentation/widgets/category_grid.dart';
|
||||
@@ -21,8 +23,8 @@ import 'package:wandergo/shared/widgets/language_button.dart';
|
||||
import 'package:wandergo/shared/widgets/load_error.dart';
|
||||
import 'package:wandergo/shared/widgets/section_header.dart';
|
||||
|
||||
/// 3 홈 — 기준 위치 · 제목 · 검색창 · 카테고리 · 이번 주말 공연·행사 · 가까운 반려동물 동반 장소 · 출처.
|
||||
/// 배너·AI·추천 코스·지역 바로가기·로그인 버튼은 해당 기능을 만들 때 붙인다.
|
||||
/// 3 홈 — 기준 위치 · 제목 · 검색창 · 카테고리 · AI에게 코스 맡기기 · 이번 주말 공연·행사 · 가까운 반려동물 동반 장소 · 출처.
|
||||
/// 배너·추천 코스·지역 바로가기·로그인 버튼은 해당 기능을 만들 때 붙인다.
|
||||
class HomePage extends ConsumerWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@@ -30,7 +32,8 @@ class HomePage extends ConsumerWidget {
|
||||
// 현재 위치를 쓰는 경우 위치도 다시 받는다
|
||||
ref
|
||||
..invalidate(basePointProvider)
|
||||
..invalidate(homePetPlacesProvider);
|
||||
..invalidate(homePetPlacesProvider)
|
||||
..invalidate(aiQuotaProvider);
|
||||
await Future.wait([
|
||||
ref.read(homeEventsProvider.future),
|
||||
ref.read(homePetPlacesProvider.future),
|
||||
@@ -82,6 +85,8 @@ class HomePage extends ConsumerWidget {
|
||||
const HomeSearchBar(),
|
||||
const SizedBox(height: 28),
|
||||
const CategoryGrid(),
|
||||
const SizedBox(height: 28),
|
||||
const AiHomeSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -33,11 +33,15 @@ Future<BaseLocationChoice?> showRegionSheet(
|
||||
BuildContext context, {
|
||||
BaseLocationChoice? selected,
|
||||
bool includeCurrent = true,
|
||||
String? title,
|
||||
}) {
|
||||
return showModalBottomSheet<BaseLocationChoice>(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
_RegionSheet(selected: selected, includeCurrent: includeCurrent),
|
||||
builder: (context) => _RegionSheet(
|
||||
selected: selected,
|
||||
includeCurrent: includeCurrent,
|
||||
title: title,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,11 +66,18 @@ Future<void> applyBaseLocation(
|
||||
}
|
||||
|
||||
class _RegionSheet extends StatelessWidget {
|
||||
const _RegionSheet({required this.selected, required this.includeCurrent});
|
||||
const _RegionSheet({
|
||||
required this.selected,
|
||||
required this.includeCurrent,
|
||||
this.title,
|
||||
});
|
||||
|
||||
final BaseLocationChoice? selected;
|
||||
final bool includeCurrent;
|
||||
|
||||
/// 없으면 "어디를 기준으로 볼까요?"
|
||||
final String? title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
@@ -107,7 +118,7 @@ class _RegionSheet extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.regionSheetTitle,
|
||||
title ?? l10n.regionSheetTitle,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
Reference in New Issue
Block a user