/// 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 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 replies; } class InsideSnapshot { const InsideSnapshot({ required this.questions, this.sort = InsideSort.latest, this.canWrite = true, }); final List questions; final InsideSort sort; /// 작성 플로팅 버튼 노출 조건. 명세 「해당 팬덤에 가입된 유저에게만 노출」. final bool canWrite; } abstract interface class InsideRepository { /// 첫 장. 명세가 「최초 5개, 스크롤 시 5개씩」이라 기본값을 5로 둔다. Future load({InsideSort sort = InsideSort.latest}); /// 다음 장. 더 없으면 빈 목록을 준다. Future> more({required InsideSort sort, int limit = 5}); /// 현상금을 걸 수 있는 보유 POP — 일반 + 기여. 예치는 겸용이다(B-39 · B-06, 일반 우선). Future balance(); /// 현상금 하한 — 서버와 같은 `config/policy.qnaMinBountyPop`(기본 100 · 운영값). Future minBounty(); /// 질문 등록 + 현상금 예치(`fn-question-create`). 새 질문 ID 를 준다. 실패는 던진다. Future create({ required String title, required String body, required int bountyPop, required String clientSeq, }); /// 답변 목록 — 명세 2.61-3 「최신순 소팅」. Future> answers(String questionId); /// 답변 등록. 재화가 오가지 않아 규칙이 클라이언트 쓰기를 허용한다. Future answer(String questionId, String body); /// 채택(`fn-answer-accept`). 현상금이 답변자에게 간다. 번복되지 않는다. Future accept(String questionId, String answerId); /// 내가 쓴 질문 — MY > 활동 > 인사이드. 최신순. Future> 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 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 _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 load({InsideSort sort = InsideSort.latest}) async => InsideSnapshot(questions: _sorted(sort).take(5).toList(), sort: sort); /// 표본 모드에서 단 답변. 서버가 없으니 앱이 떠 있는 동안만 남는다. static final Map> _answers = {}; @override Future balance() async => 12_400; @override Future> mine() async => _sorted(InsideSort.latest).where((q) => q.isMine).toList(); @override Future minBounty() async => 100; /// 표본은 원장이 없다 — 화면 동선만 태운다(캠페인 표본의 `create` 와 같다). @override Future create({ required String title, required String body, required int bountyPop, required String clientSeq, }) async => 'q-new'; @override Future> answers(String questionId) async => List.of(_answers[questionId] ?? const []); @override Future 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 accept(String questionId, String answerId) async { final list = _answers[questionId] ?? const []; 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> more({ required InsideSort sort, int limit = 5, }) async => _sorted(sort).skip(5).take(limit).toList(); } /// 시안 「인사이드 상세(타인)」(201:49678)의 댓글 표본. 답변 목록 읽기 경로가 아직 없어 /// 화면 대조와 시연에서만 쓴다. final List 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), ), ], ), ];