Files
fanuzu/app/lib/features/inside/inside_data.dart
T

415 lines
14 KiB
Dart

/// INSIDE 탭이 그리는 값의 형태.
///
/// 근거는 Figma `03_Wireframe` 의 INSIDE 화면 10종이다. 목록 화면이 정렬 기준마다
/// 따로 그려져 있어(`Inside_최신순` · `Inside_인기순` · `Inside_POP순`) 정렬을
/// 화면이 아니라 질의 조건으로 둔다 — 같은 리스트에 정렬만 다르다.
///
/// 데이터 원장은 `questions` 컬렉션이며 답변은 `answers` 하위 컬렉션이다
/// (Firestore 설계서 §5). 현상금은 POP 원장을 지나므로 화면은 금액만 읽는다.
library;
import '../../core/data/backend.dart';
import '../../core/i18n/app_locale.dart';
/// 목록 정렬 기준. Figma 가 화면을 셋으로 나눠 둔 그 축이다.
enum InsideSort {
/// `Inside_최신순` — createdAt 내림차순.
latest,
/// `Inside_인기순` — 답변 수 내림차순.
popular,
/// `Inside_POP순` — 걸린 현상금 내림차순.
bounty,
}
extension InsideSortLabel on InsideSort {
String get label => switch (this) {
InsideSort.latest => appL10n.sortLatest,
InsideSort.popular => appL10n.sortPopular,
InsideSort.bounty => appL10n.sortPop,
};
/// Firestore 정렬 필드. 화면이 필드 이름을 알 필요는 없지만, 저장소 구현이
/// 세 곳으로 갈리는 것보다 여기 한 줄로 두는 편이 어긋날 여지가 적다.
String get field => switch (this) {
InsideSort.latest => 'createdAt',
InsideSort.popular => 'answerCount',
InsideSort.bounty => 'bountyPop',
};
}
/// 질문 하나. 목록 카드와 상세가 같은 형을 쓴다 — 상세가 더 읽을 뿐이다.
class InsideQuestion {
const InsideQuestion({
required this.id,
required this.title,
required this.body,
required this.authorName,
required this.createdAt,
this.bountyPop = 0,
this.answerCount = 0,
this.viewCount = 0,
this.likeCount = 0,
this.imageUrls = const [],
this.isMine = false,
this.isSolved = false,
this.refund = InsideRefund.none,
});
final String id;
final String title;
final String body;
final String authorName;
final DateTime createdAt;
/// 걸린 현상금. 0 이면 카드에 칩을 그리지 않는다.
final int bountyPop;
final int answerCount;
final int viewCount;
final int likeCount;
/// 첨부 이미지. 목록 카드는 첫 장을 70×70 썸네일로, 상세는 전부 그린다.
/// `assets/` 로 시작하면 번들 자산(표본), 아니면 네트워크 주소다.
final List<String> imageUrls;
/// 본인 글. Figma 가 상세를 「본인」·「타인」으로 나눠 둔 그 구분이며,
/// 수정·삭제와 신고·차단이 갈린다.
final bool isMine;
/// 답변이 채택되어 현상금이 지급된 상태.
final bool isSolved;
/// 채택 기한(정책 원장 B-39 · 7일)이 지난 뒤의 환불 단계.
final InsideRefund refund;
/// 채택을 받을 수 있는 상태 — 채택도 만료도 아니다.
bool get isOpen => !isSolved && refund == InsideRefund.none;
InsideQuestion solvedCopy() => InsideQuestion(
id: id,
title: title,
body: body,
authorName: authorName,
createdAt: createdAt,
bountyPop: bountyPop,
answerCount: answerCount,
viewCount: viewCount,
likeCount: likeCount,
imageUrls: imageUrls,
isMine: isMine,
isSolved: true,
);
}
/// 만료 환불 단계 — 서버 `status` 의 `refunding` · `expired` · `refund_failed`.
enum InsideRefund {
none,
pending,
done,
/// 자동 환불이 멈춰 운영 확인 중. 사용자에게는 원인을 드러내지 않는다.
checking;
String get notice => switch (this) {
none => '',
pending => appL10n.insideRefundPending,
done => appL10n.insideRefundDone,
checking => appL10n.insideRefundChecking,
};
static InsideRefund fromStatus(String status) => switch (status) {
'refunding' => pending,
'expired' => done,
'refund_failed' => checking,
_ => none,
};
}
/// 답변(댓글) 하나. 시안 상세의 댓글 · 대댓글이다(`answers` 하위 컬렉션).
class InsideAnswer {
const InsideAnswer({
required this.authorName,
required this.body,
required this.createdAt,
this.id = '',
this.isMine = false,
this.isAccepted = false,
this.replies = const [],
});
/// `answers` 문서 ID. 채택 호출이 이 값을 보낸다.
final String id;
final String authorName;
final String body;
final DateTime createdAt;
/// 본인 댓글. 시안은 분홍 바탕(#ff7ddd 10%)이다.
final bool isMine;
/// 채택된 답변(`accepted`). 서버(`fn-answer-accept`)만 켠다.
final bool isAccepted;
/// 대댓글. 한 단계만 있다 — 대댓글에는 답글 버튼이 없다.
final List<InsideAnswer> replies;
}
class InsideSnapshot {
const InsideSnapshot({
required this.questions,
this.sort = InsideSort.latest,
this.canWrite = true,
});
final List<InsideQuestion> questions;
final InsideSort sort;
/// 작성 플로팅 버튼 노출 조건. 명세 「해당 팬덤에 가입된 유저에게만 노출」.
final bool canWrite;
}
abstract interface class InsideRepository {
/// 첫 장. 명세가 「최초 5개, 스크롤 시 5개씩」이라 기본값을 5로 둔다.
Future<InsideSnapshot> load({InsideSort sort = InsideSort.latest});
/// 다음 장. 더 없으면 빈 목록을 준다.
Future<List<InsideQuestion>> more({required InsideSort sort, int limit = 5});
/// 현상금을 걸 수 있는 보유 POP — 일반 + 기여. 예치는 겸용이다(B-39 · B-06, 일반 우선).
Future<int> balance();
/// 현상금 하한 — 서버와 같은 `config/policy.qnaMinBountyPop`(기본 100 · 운영값).
Future<int> minBounty();
/// 질문 등록 + 현상금 예치(`fn-question-create`). 새 질문 ID 를 준다. 실패는 던진다.
Future<String> create({
required String title,
required String body,
required int bountyPop,
required String clientSeq,
});
/// 답변 목록 — 명세 2.61-3 「최신순 소팅」.
Future<List<InsideAnswer>> answers(String questionId);
/// 답변 등록. 재화가 오가지 않아 규칙이 클라이언트 쓰기를 허용한다.
Future<void> answer(String questionId, String body);
/// 채택(`fn-answer-accept`). 현상금이 답변자에게 간다. 번복되지 않는다.
Future<void> accept(String questionId, String answerId);
/// 내가 쓴 질문 — MY > 활동 > 인사이드. 최신순.
Future<List<InsideQuestion>> mine();
}
/// 현상금 Q&A 실패를 화면 문구로 — 서버 카탈로그 코드(`E-QNA-*` 등)로 가른다.
String insideErrorMessage(Object error) => switch (error) {
BackendException(code: 'E-POP-01') => appL10n.insideErrPopShort,
BackendException(code: 'E-QNA-01') => appL10n.insideErrSelfAccept,
BackendException(code: 'E-QNA-03') => appL10n.insideErrAlreadyAccepted,
BackendException(code: 'E-PERM-01') => appL10n.insideErrNotOwner,
BackendException(code: 'E-AUTH-03') => appL10n.authLoginRequired,
BackendException(:final message) => message,
_ => appL10n.commonCheckConnection,
};
/// 서버가 붙기 전에 쓰는 표본. 문구는 Figma INSIDE 시안(199:43083 등)의 표본 그대로이며 정책 수치가 아니다.
///
/// 작성 시각은 지금을 기준으로 잡는다 — 시안의 「10분 전」·「1시간 전」이 그대로 나오게.
class SampleInsideRepository implements InsideRepository {
const SampleInsideRepository();
/// 표본 질문(HOME 「인기 K-INSIDE」 표본도 같은 것을 쓴다).
static List<InsideQuestion> get samples {
final now = DateTime.now();
return [
InsideQuestion(
id: 'q-0',
title: '화양연화 세계관 해석해주실 분?',
body: '이번 뮤비랑 연결되는 부분이 헷갈리네요. 타임라인 정리해주실 천재 아미 구합니다!',
authorName: '아미보이',
createdAt: now.subtract(const Duration(minutes: 10)),
bountyPop: 500,
viewCount: 3,
),
InsideQuestion(
id: 'q-1',
title: '공방 신청 꿀팁 공유합니다.',
body: '공방 신청할 때 폼 가장 빨리 제출하는 손크로 팁 알려드립! 궁금하신 분 질문 남겨주세요',
authorName: '다이브짱',
createdAt: now.subtract(const Duration(hours: 1)),
bountyPop: 200,
likeCount: 8,
answerCount: 24,
viewCount: 82,
imageUrls: const ['assets/figma/inside-sample-3.png'],
isSolved: true,
),
InsideQuestion(
id: 'q-2',
title: 'BTS 이 포즈 뭔가요?',
body: '아미 분들 이 포즈 뭔지 알려주실 수 있나요?',
authorName: '네누',
createdAt: now.subtract(const Duration(hours: 3)),
bountyPop: 100,
likeCount: 48,
answerCount: 24,
viewCount: 82,
imageUrls: const ['assets/figma/inside-sample-2.png'],
),
InsideQuestion(
id: 'q-3',
title: 'BTS 광화문 봉사 활동',
body: '광화문 봉사 활동 어떻게 신청하셨나요?\n다음 봉사 활동이 있으면 참가하고 싶어요!',
authorName: '아우라',
createdAt: now.subtract(const Duration(days: 30)),
bountyPop: 800,
likeCount: 3,
answerCount: 2,
viewCount: 32,
isSolved: true,
),
InsideQuestion(
id: 'q-4',
title: 'BTS 컴백 알려주세요!',
body: 'BTS 한국 컴백 시기가 궁금합니다!\n아시는 분들 댓글 부탁드려요ㅠㅠ',
authorName: 'BTS_Hong',
createdAt: now.subtract(const Duration(days: 40)),
bountyPop: 100,
imageUrls: const [
'assets/figma/inside-sample-4.png',
'assets/figma/inside-sample-5.png',
],
isMine: true,
),
];
}
List<InsideQuestion> _sorted(InsideSort sort) {
final list = samples;
switch (sort) {
case InsideSort.latest:
list.sort((a, b) => b.createdAt.compareTo(a.createdAt));
case InsideSort.popular:
// 기능 명세 2008 — 인기순은 좋아요 > 댓글 > 조회수.
list.sort(
(a, b) => b.likeCount != a.likeCount
? b.likeCount.compareTo(a.likeCount)
: b.answerCount != a.answerCount
? b.answerCount.compareTo(a.answerCount)
: b.viewCount.compareTo(a.viewCount),
);
case InsideSort.bounty:
list.sort((a, b) => b.bountyPop.compareTo(a.bountyPop));
}
return list;
}
@override
Future<InsideSnapshot> load({InsideSort sort = InsideSort.latest}) async =>
InsideSnapshot(questions: _sorted(sort).take(5).toList(), sort: sort);
/// 표본 모드에서 단 답변. 서버가 없으니 앱이 떠 있는 동안만 남는다.
static final Map<String, List<InsideAnswer>> _answers = {};
@override
Future<int> balance() async => 12_400;
@override
Future<List<InsideQuestion>> mine() async =>
_sorted(InsideSort.latest).where((q) => q.isMine).toList();
@override
Future<int> minBounty() async => 100;
/// 표본은 원장이 없다 — 화면 동선만 태운다(캠페인 표본의 `create` 와 같다).
@override
Future<String> create({
required String title,
required String body,
required int bountyPop,
required String clientSeq,
}) async => 'q-new';
@override
Future<List<InsideAnswer>> answers(String questionId) async =>
List.of(_answers[questionId] ?? const []);
@override
Future<void> answer(String questionId, String body) async {
(_answers[questionId] ??= []).insert(
0,
InsideAnswer(
id: 'a-${DateTime.now().microsecondsSinceEpoch}',
authorName: '나',
body: body,
createdAt: DateTime.now(),
isMine: true,
),
);
}
@override
Future<void> accept(String questionId, String answerId) async {
final list = _answers[questionId] ?? const <InsideAnswer>[];
for (final (i, a) in list.indexed) {
if (a.id != answerId) continue;
if (a.isMine) {
throw BackendException('E-QNA-01', appL10n.insideErrSelfAccept);
}
_answers[questionId]![i] = InsideAnswer(
id: a.id,
authorName: a.authorName,
body: a.body,
createdAt: a.createdAt,
isAccepted: true,
);
}
}
/// 표본은 5건뿐이라 둘째 장이 비어 나온다. 화면의 「더 없음」 경로를 그대로 태운다.
@override
Future<List<InsideQuestion>> more({
required InsideSort sort,
int limit = 5,
}) async => _sorted(sort).skip(5).take(limit).toList();
}
/// 시안 「인사이드 상세(타인)」(201:49678)의 댓글 표본. 답변 목록 읽기 경로가 아직 없어
/// 화면 대조와 시연에서만 쓴다.
final List<InsideAnswer> sampleInsideAnswers = [
InsideAnswer(
authorName: 'pro_따봉',
body: '세상이 이런 꿀팁이 다 있나요,,,\n덕분에 좋은 정보 알고 갑니다 ㅎㅎㅎ',
createdAt: DateTime(2026, 4, 27, 9),
replies: [
InsideAnswer(
authorName: '뚜루뚭',
body: '이 내용 말고도 다른 내용 더 있어요!',
createdAt: DateTime(2026, 4, 27, 9),
),
InsideAnswer(
authorName: '호떡이',
body:
'위의 내용 번역해드릴게요!\nIs there really no such great tip in the world...\n'
'Thanks for the good information hahaha',
createdAt: DateTime(2026, 4, 27, 9),
isMine: true,
),
],
),
InsideAnswer(
authorName: '참참참',
body: '내가 찾던 내용인데 이제 찾았네 ㅠㅠ',
createdAt: DateTime(2026, 4, 27, 9),
replies: [
InsideAnswer(
authorName: '뚜루뚭',
body: '이 내용 말고도 다른 내용 더 있어요!',
createdAt: DateTime(2026, 4, 27, 9),
),
],
),
];