415 lines
14 KiB
Dart
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),
|
|
),
|
|
],
|
|
),
|
|
];
|