Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class ChallengePreviewRepository {

/// 챌린지 이름 사전 검사
/// AI가 이 이름/주제로 사진 검증(CLIP 검사)을 자동으로 수행할 수 있는지 여부를 안내용으로 반환
/// (백엔드 확인 결과, true/false 모두 정상 응답이며 생성 로직 자체를 막을 필요는 없음)
/// (autoVerifiable == false면 사진 필수 챌린지 생성을 막고, 이름 재입력을 유도함)
Future<ChallengePreviewResponse> checkPreview(String title) async {
debugPrint('🔍 [Preview API] 요청 title: "$title"');

Expand Down
80 changes: 48 additions & 32 deletions lib/features/challenge/create/screens/challenge_create_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ class ChallengeCreateScreen extends ConsumerStatefulWidget {
class _ChallengeCreateScreenState extends ConsumerState<ChallengeCreateScreen> {
final List<ChallengeTagModel> _selectedTagModels = []; // 선택된 태그를 모델 리스트로 관리
int selectedType = 0; // 현재 선택된 방식을 저장 (0: 미선택, 1: 사진 필수, 2: 체크 자유)
int selectedVisibility = 0; // 1: 비공개, 2: 공개, 3: 친구 공개
int selectedVisibility = 2; // 1: 비공개, 2: 공개(기본값), 3: 친구 공개

// AI 사진 검증 사전 안내용 상태
bool? _autoVerifiable; // null: 미검사, true/false: 검사 결과
Expand All @@ -63,7 +63,7 @@ class _ChallengeCreateScreenState extends ConsumerState<ChallengeCreateScreen> {
final DateTime _today = DateTime.now();

// 선택된 값들을 저장할 상태 변수
String? _selectedDuration; // 인증 기간
String? _selectedDuration = '30일'; // 인증 기간 (기본값: 30일)
String? _selectedFrequency; // 인증 빈도

bool _isSubmitting = false;
Expand All @@ -76,14 +76,23 @@ class _ChallengeCreateScreenState extends ConsumerState<ChallengeCreateScreen> {

// 모든 조건이 충족되었는지 확인
bool get _isFormValid {
// 사진 필수 선택 + AI 이름 검사가 진행 중이면, 아직 최종 결과를 못 봤으니 버튼 잠금
final bool blockedByPreviewCheck = selectedType == 1 && _isCheckingPreview;

// 사진 필수 선택 + 이름 검사 결과가 "판별 어려움(false)"이면 생성 자체를 막음
final bool blockedByUnsupportedName =
selectedType == 1 && _autoVerifiable == false;

return _nameController.text.trim().isNotEmpty && // 이름 입력
_selectedDay != null && // 시작일 선택
_selectedDuration != null && // 기간 선택
_selectedFrequency != null && // 빈도 선택됨
_selectedTagModels.isNotEmpty && // 태그
_descriptionController.text.trim().isNotEmpty && // 설명 입력
selectedType != 0 && // 인증 방식 선택
selectedVisibility != 0; // 공개범위 선택
selectedVisibility != 0 && // 공개범위 선택
!blockedByPreviewCheck && // AI 검사 진행 중이면 버튼 잠금
!blockedByUnsupportedName; // AI 검사 결과가 false면 버튼 잠금
}

// 상태 초기화
Expand All @@ -92,6 +101,10 @@ class _ChallengeCreateScreenState extends ConsumerState<ChallengeCreateScreen> {
super.initState();

_focusedDay = _today;
_selectedDay = _today;
_dateHintText =
"${_today.year}-${_today.month.toString().padLeft(2, '0')}-${_today.day.toString().padLeft(2, '0')}";

_scrollController.addListener(_onScroll); // 스크롤 리스너 추가

// 텍스트 입력 시마다 버튼 활성화 여부 판단 + (사진 필수 선택 시) 이름 재검사
Expand Down Expand Up @@ -122,12 +135,14 @@ class _ChallengeCreateScreenState extends ConsumerState<ChallengeCreateScreen> {
// 사진 필수가 선택된 상태에서만 재검사 (디바운스)
if (selectedType == 1) {
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 500), () {
_debounceTimer = Timer(const Duration(milliseconds: 700), () {
_checkAutoVerifiable();
});
}
}

int _previewRequestId = 0; // 요청 순번 추적용 (같은 제목이라도 최신 요청만 반영하기 위함)

// 사진 첨부 필수를 선택했을 때, 현재 입력된 이름으로 AI 판별 난이도 사전 안내 조회
Future<void> _checkAutoVerifiable() async {
final title = _nameController.text.trim();
Expand All @@ -136,6 +151,8 @@ class _ChallengeCreateScreenState extends ConsumerState<ChallengeCreateScreen> {
return;
}

final int requestId = ++_previewRequestId; // 이 호출만의 고유 순번 부여

setState(() => _isCheckingPreview = true);
try {
final result = await ref
Expand All @@ -146,7 +163,12 @@ class _ChallengeCreateScreenState extends ConsumerState<ChallengeCreateScreen> {
'🔍 [AI Notice] title: "$title" → autoVerifiable: ${result.autoVerifiable}, category: ${result.category}',
);

// ✅ ChallengePreviewResponse -> bool로 꺼내서 저장
// 내가 보낸 이후에 더 최신 요청이 나갔다면, 내 응답은 무시
if (requestId != _previewRequestId) {
debugPrint('🔍 [AI Notice] "$title" (id=$requestId) 응답은 낡은 요청이라 무시');
return;
}

if (mounted) setState(() => _autoVerifiable = result.autoVerifiable);
} catch (e) {
debugPrint('🔍 [AI Notice] 검사 실패: $e');
Expand Down Expand Up @@ -372,7 +394,24 @@ class _ChallengeCreateScreenState extends ConsumerState<ChallengeCreateScreen> {
const ChallengeLabel(label: '챌린지 이름'),
ChallengeInputBox(
controller: _nameController, // 연결
hintText: '챌린지 이름을 입력하세요',
hintText: '어떤 챌린지를 시작해볼까요?',
),

const SizedBox(height: 16),

ChallengeTypeSelector(
selectedType: selectedType,
autoVerifiable: _autoVerifiable,
isCheckingPreview: _isCheckingPreview,
onChanged: (type) {
setState(() => selectedType = type);

// 사진 첨부 필수를 눌렀을 때
if (type == 1) {
_debounceTimer?.cancel(); // 대기 중이던 디바운스 요청 취소
_checkAutoVerifiable(); // 즉시 1번만 호출
}
},
),

const SizedBox(height: 16),
Expand All @@ -393,7 +432,7 @@ class _ChallengeCreateScreenState extends ConsumerState<ChallengeCreateScreen> {

const ChallengeLabel(label: '인증 기간'),
ChallengeInputBox(
hintText: _selectedDuration ?? '인증 기간을 선택하세요',
hintText: _selectedDuration ?? '얼마나 해내볼까요?',
textColor: _selectedDuration == null
? appColors.gray3
: appColors.blackToWhite,
Expand All @@ -405,7 +444,7 @@ class _ChallengeCreateScreenState extends ConsumerState<ChallengeCreateScreen> {

const ChallengeLabel(label: '인증 빈도'),
ChallengeInputBox(
hintText: _selectedFrequency ?? '인증 빈도를 선택하세요',
hintText: _selectedFrequency ?? '얼마나 자주 인증할까요?',
textColor: _selectedFrequency == null
? appColors.gray3
: appColors.blackToWhite,
Expand All @@ -417,7 +456,7 @@ class _ChallengeCreateScreenState extends ConsumerState<ChallengeCreateScreen> {

const ChallengeLabel(label: '챌린지 태그'),
ChallengeInputBox(
hintText: '태그를 선택하세요',
hintText: '어떤 주제와 어울릴까요?',
iconPath: 'assets/images/icons/big_down_arrow.svg',
onTap: showChallengeTagBottomSheet,
tag: _selectedTagModels.isEmpty ? null : buildSelectedTags(),
Expand All @@ -434,29 +473,6 @@ class _ChallengeCreateScreenState extends ConsumerState<ChallengeCreateScreen> {

const SizedBox(height: 16),

ChallengeTypeSelector(
selectedType: selectedType,
autoVerifiable: _autoVerifiable,
onChanged: (type) {
setState(() => selectedType = type);

// 사진 첨부 필수를 눌렀을 때만 하단으로 스크롤 이동 + AI 이름 검사
if (type == 1) {
_checkAutoVerifiable();

WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
});
}
},
),

const SizedBox(height: 16),

// 챌린지 공개 범위
ChallengeVisibilitySelector(
selectedVisibility: selectedVisibility,
Expand Down
84 changes: 77 additions & 7 deletions lib/features/challenge/create/widgets/ai_notice_box.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,17 @@ import 'package:flutter_svg/flutter_svg.dart';

// 사진 첨부 필수를 누를 경우 안내 박스
class AiNoticeBox extends StatelessWidget {
const AiNoticeBox({super.key, this.autoVerifiable});
const AiNoticeBox({
super.key,
this.autoVerifiable,
this.isCheckingPreview = false,
});

// null: 아직 검사 전(또는 검사 실패), true/false: 검사 결과
final bool? autoVerifiable;

final bool isCheckingPreview; // true면 현재 AI 이름 검사가 진행 중 (스피너 표시)

@override
Widget build(BuildContext context) {
final appColors = Theme.of(context).extension<AppColorsExtension>()!;
Expand Down Expand Up @@ -41,13 +47,77 @@ class AiNoticeBox extends StatelessWidget {
'정확한 인증을 위해 AI 검증 단계를 거치게 됩니다.\n환경에 따라 인식이 지연되거나 재촬영이 필요할 수 있습니다.',
style: AppTypography.c1.copyWith(color: appColors.gray1),
),
if (autoVerifiable == false) ...[
// 검사 중일 때: 스피너 + 안내 문구
if (isCheckingPreview) ...[
const SizedBox(height: 6),
Row(
children: [
SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: appColors.primaryAble,
),
),
const SizedBox(width: 6),
Text(
'챌린지 이름을 확인하고 있어요...',
style: AppTypography.c1.copyWith(
color: appColors.gray1,
),
),
],
),
] else if (autoVerifiable == false) ...[
// 검사 중이 아닐 때만 결과 문구 표시 (실패 → 재입력 유도)
const SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: SvgPicture.asset(
'assets/images/icons/warning.svg',
width: 14,
height: 14,
),
),
const SizedBox(width: 4),
Expanded(
child: Text(
'현재 챌린지 이름으로는 AI 사진 인증이 어려워요.\n챌린지 이름을 다시 입력해주세요.',
style: AppTypography.c1.copyWith(
color: appColors.notification,
),
),
),
],
),
] else if (autoVerifiable == true) ...[
// 검사 성공 시 안내 문구
const SizedBox(height: 4),
Text(
'현재 입력한 챌린지 이름은 AI가 자동으로 판별하기 어려운 주제예요. 인증 시 지연되거나 재촬영이 필요할 가능성이 높아요.',
style: AppTypography.c1.copyWith(
color: appColors.primaryAble,
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: SvgPicture.asset(
'assets/images/icons/success_check.svg',
width: 14,
height: 14,
),
),
const SizedBox(width: 4),
Expanded(
child: Text(
'현재 입력한 챌린지 이름은 AI가 자동으로 판별하기 쉬운 주제예요. 인증이 원활하게 진행될 거예요.',
style: AppTypography.c1.copyWith(
color: appColors.primaryAble,
),
),
),
],
),
],
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ class ChallengeTypeSelector extends StatelessWidget {
final int selectedType;
final ValueChanged<int> onChanged;
final bool? autoVerifiable;
final bool isCheckingPreview;

const ChallengeTypeSelector({
super.key,
required this.selectedType,
required this.onChanged,
this.autoVerifiable,
this.isCheckingPreview = false,
});

@override
Expand Down Expand Up @@ -42,7 +44,10 @@ class ChallengeTypeSelector extends StatelessWidget {
// 선택된 타입이 1(사진 필수)일 때만 AI 안내 박스 표시
if (selectedType == 1) ...[
const SizedBox(height: 12),
AiNoticeBox(autoVerifiable: autoVerifiable),
AiNoticeBox(
autoVerifiable: autoVerifiable,
isCheckingPreview: isCheckingPreview,
),
],
],
);
Expand Down
8 changes: 4 additions & 4 deletions lib/features/statistics/screens/statistics_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ class StatisticsScreen extends ConsumerWidget {
activity: data.activity,
),
),
const SizedBox(height: 10),
const SizedBox(height: 20),
// 나의 해냄 분포
distributionAsync.when(
loading: () => const SizedBox(
Expand Down Expand Up @@ -111,7 +111,7 @@ class StatisticsScreen extends ConsumerWidget {
totalCount: data.totalCount,
),
),
const SizedBox(height: 10),
const SizedBox(height: 20),
// 나의 해냄 추이
monthlyWeeklyAsync.when(
loading: () => const SizedBox(
Expand Down Expand Up @@ -144,10 +144,10 @@ class StatisticsScreen extends ConsumerWidget {
data: (data) =>
LineGraph(monthlyData: data.monthly, weeklyData: data.weekly),
),
const SizedBox(height: 10),
const SizedBox(height: 20),
// AI 코칭 카드 (나의 해냄 포인트 / 이것만 해내면 완벽해요 / 다음 단계 해내기)
const AiCoachingSection(),
const SizedBox(height: 10),
const SizedBox(height: 20),
],
),
),
Expand Down
Loading