/// 캠페인이 그리는 값의 형태 — 화면 대조 원장 F5. /// /// 근거는 Figma 명세 구간이다. /// - 목록 `523:18347`·`523:18388`·`523:18399` · 검색 `523:18417`~`523:18429` /// - 상세 `523:18487`~`523:18589` · 기여 바텀시트 `527:21100` /// - 랭킹 `536:14345`~`536:14382` · 댓글 `527:21192`·`527:21198` library; import '../../core/i18n/app_locale.dart'; import '../my/my_data.dart'; /// 목록 정렬. 명세 — *디폴트 최신순 선택 · 단일 선택*. enum CampaignSort { latest, popular, contributing; String get label => switch (this) { latest => appL10n.sortLatest, popular => appL10n.sortPopular, contributing => appL10n.campaignSortContributing, }; } /// 캠페인 진행 상태. 정책 설계서 §P-09 의 상태 전이를 따른다. enum CampaignStatus { /// 모금 중. funding, /// 목표 달성 후 집행. success, /// 목표 미달 — 100% 자동 환불 대상. failed, /// 결과 보고까지 끝남. reported, /// 공개 전 — 초안 · 운영 검수 대기. 개설자에게만 보인다. review, /// 운영 반려 — 등록비 반환(B-58). rejected, /// 개설자 철회(B-58). 공개 전 철회만 등록비를 돌려받는다. withdrawn, /// 운영 취소(회사귀책, B-58) — 등록비 · 후원 전액 반환. cancelled; /// 공개 모금 흐름 밖의 상태 안내. 모금 · 결과 상태는 null. String? get notice => switch (this) { review => appL10n.campaignStatusReview, rejected => appL10n.campaignStatusRejected, withdrawn => appL10n.campaignStatusWithdrawn, cancelled => appL10n.campaignStatusCancelled, _ => null, }; } /// 목록 카드 하나. /// /// 명세 `523:18399` — *대표 이미지 · 작성자 · 작성일시 · 달성률(%) · 제목 ·* /// *내용 · 좋아요 · 댓글 · 조회수 · 공유 수* class CampaignCardData { const CampaignCardData({ required this.id, required this.title, required this.body, required this.authorName, this.summary = '', this.fandomId = '', this.fandomName = '', required this.createdAt, required this.status, required this.goalPop, required this.raisedPop, required this.supporters, required this.likes, required this.comments, required this.views, required this.shares, this.mine = false, this.contributedPop = 0, this.creatorUid, }); final String id; /// 개설자 UID — 캠페인 신고 · 개설자 차단의 대상. 없으면 차단할 수 없다. final String? creatorUid; final String title; /// 한 줄 소개 — 서버 문서의 `summary`. 공유 문구가 이 값을 쓴다. final String summary; final String body; final String authorName; /// 소속 팬덤 — 서버 문서의 `fandomId`. final String fandomId; /// 행성 이름. /// /// **캠페인 문서의 필드가 아니다.** `firestore.rules` 의 `campaigns` 생성 규칙이 /// `keys().hasOnly([...])` 로 스키마를 닫아 두었고 그 목록에 `fandomName` 이 /// 없다. 비정규화 필드를 넣으면 캠페인 생성 자체가 거부된다. 그래서 이 값은 /// `fandoms/{fandomId}.name` 을 읽어 채우는 **파생값**이고, 해석은 캠페인 서버 /// 연결 회차가 맡는다. 비어 있으면 공유 문구에서 행성 줄과 팬덤 해시태그가 함께 /// 빠진다(정책 원장 B-78 — 고정 3종이 아니다). final String fandomName; final DateTime createdAt; final CampaignStatus status; final int goalPop; final int raisedPop; final int supporters; final int likes; final int comments; final int views; final int shares; /// 내가 만든 캠페인인가. 명세 — *기여하기 버튼은 타인 캠페인에서만 표시*. final bool mine; /// 내가 이미 기여한 POP. 「기여중」 필터의 기준이다. final int contributedPop; /// 달성률(%). 목표가 0이면 0. int get achievedPercent => goalPop == 0 ? 0 : ((raisedPop / goalPop) * 100).round(); /// 인기순 정렬 키. 명세 — *좋아요 > 댓글 > 조회수 > 공유수*. /// 앞의 값이 같을 때만 뒤를 본다는 뜻이라 순서대로 비교한다. int comparePopularity(CampaignCardData other) { for (final pick in [ (c) => c.likes, (c) => c.comments, (c) => c.views, (c) => c.shares, ]) { final d = pick(other).compareTo(pick(this)); if (d != 0) return d; } return 0; } } /// 기여 랭킹 한 줄. class ContributorRank { const ContributorRank({ required this.rank, required this.name, required this.pop, this.change = 0, this.isMe = false, }); final int rank; final String name; final int pop; /// 순위 변동값. 양수면 올라갔다. final int change; final bool isMe; } /// 랭킹 집계 구간. 명세 `536:14382` — *실시간 · 일간 · 주간*. enum RankWindow { live, daily, weekly; String get label => switch (this) { live => appL10n.rankScopeLive, daily => appL10n.rankWindowDaily, weekly => appL10n.rankWindowWeekly, }; } /// 응원 게시판 댓글 한 장의 크기. 이만큼 오면 「더 보기」가 붙는다. const campaignCommentPageSize = 20; /// 응원 게시판 댓글 — 서버 `campaigns/{id}/comments/{cid}` . class CampaignComment { const CampaignComment({ required this.id, required this.authorName, required this.body, required this.createdAt, this.authorUid, this.isMine = false, this.likes = 0, this.liked = false, }); final String id; /// 공개 닉네임(없으면 「팬」). 공급자 실명은 쓰지 않는다(M-7). final String authorName; final String body; final DateTime createdAt; /// 차단 대상. 없으면 차단할 수 없다. final String? authorUid; final bool isMine; /// 좋아요 수(서버 트리거가 센 `likeCount`)와 내가 눌렀는가. final int likes; final bool liked; } /// 상세 화면이 필요로 하는 것. class CampaignDetail { const CampaignDetail({ required this.card, required this.mediaLabels, required this.endsAt, required this.topContributors, this.liked = false, this.report, }); final CampaignCardData card; /// 결과보고. 게시 전이면 null. final CampaignReport? report; /// 내가 이미 좋아요를 눌렀는가. 서버에 저장된 상태에서 시작해야 두 번째 탭이 취소가 된다. final bool liked; /// 사진·영상. 자산이 아직 없어 라벨로 둔다. 개수가 인디케이터 수를 정한다. final List mediaLabels; final DateTime endsAt; final List topContributors; /// 남은 시간. 지났으면 [Duration.zero]. Duration remaining({DateTime? now}) { final gap = endsAt.difference(now ?? DateTime.now()); return gap.isNegative ? Duration.zero : gap; } } /// 결과보고 — 요구사항 FR-CMP-06 「집행 사진 · 영수증 · 예산 사용 내역 공개」. /// 서버 문서 `campaigns/{id}/reports/main`. 올리는 쪽은 운영이다(원장 B-35). class CampaignReport { const CampaignReport({ required this.body, required this.photoUrls, required this.receiptUrls, required this.budget, required this.createdAt, }); final String body; final List photoUrls; final List receiptUrls; final List<({String label, int amount})> budget; final DateTime createdAt; int get budgetTotal => budget.fold(0, (sum, l) => sum + l.amount); } /// 사용자 캠페인 등록비 — 원장 B-57(🟡 운영값). 서버 `fn-campaign-create` 가 실제로 뺀다. const int campaignRegistrationFeePop = 100; /// Campaign Product Catalog 상품 하나 — 원장 B-59(상품 · 필요 기여 POP) · C-14(권장 모금 기간). class CampaignProduct { const CampaignProduct(this.name, this.goalPop, this.recommendedDays); final String name; /// 필요 기여 POP. 목표액은 이 값 그대로다(B-32 — 사용자 임의 입력 없음). final int goalPop; /// 권장 모금 기간(일). 날짜 선택에 자동으로 들어간다(C-14). final int recommendedDays; } /// MOCK: Catalog 가 앱에 아직 연결되지 않아 사본(`287:16991`) 칩 문구와 표본 수치를 쓴다. /// 인스타 광고 300,000 만 사본 값이고 나머지 POP · 일수는 S03 제안표 확정 전 자리표시다. const sampleCampaignCatalog = [ CampaignProduct('인스타 광고', 300000, 14), CampaignProduct('지하철 CM보드', 800000, 30), CampaignProduct('커피차 서포트', 500000, 14), CampaignProduct('커스텀 인이어', 200000, 21), ]; /// 새 캠페인이 만드는 값. class CampaignDraft { const CampaignDraft({ required this.title, required this.body, required this.goalPop, required this.days, }); final String title; final String body; final int goalPop; final int days; } abstract interface class CampaignRepository { /// 명세 — *기본 10개 노출 · 아래 스크롤 시 10개 업데이트*. Future> load({ CampaignSort sort = CampaignSort.latest, int limit = 10, }); Future> more({required int offset, int limit = 10}); Future detail(String campaignId); Future> ranking( String campaignId, { RankWindow window = RankWindow.live, }); Future> recentQueries(); Future clearQueries(); /// 최근 검색어 한 개 지우기 — 시안 `272:241681` 검색어 칩의 ✕. Future removeQuery(String query); Future> search(String query); /// 기여. **잔액은 서버가 깎는다** — `fn-campaign-fund` 가 원장을 지난다. /// 실패하면 던진다 — 화면은 성공으로 그리지 않는다. Future contribute(String campaignId, int amountPop); /// 후원에 쓸 수 있는 잔액 — 기여 POP(정책 원장 B-33). Future balance(); /// 등록비에 쓸 수 있는 잔액 — 일반 + 기여 POP. 등록비는 겸용이다(B-57 · B-06). Future feeBalance(); /// 좋아요 저장. 실패하면 던진다. Future like(String campaignId, bool on); Future create(CampaignDraft draft); /// 내가 만든 캠페인 — 공개 전 · 철회 · 취소 상태까지(MY/내 캠페인). Future> mine(); /// 개설자 철회(`fn-campaign-withdraw` · B-58). 실패하면 던진다. Future withdraw(String campaignId); /// 응원 게시판 댓글 한 장 — 최신순(명세 `527:21192`). [more] 가 false 면 첫 /// 장부터 다시 읽는다. 차단한 작성자의 댓글은 뺀다. Future> comments( String campaignId, { bool more = false, }); /// 댓글 저장. 실패하면 던진다. Future addComment(String campaignId, String body); Future deleteComment(String campaignId, String commentId); /// 신고 — [reason] 은 신고 화면의 사유 문구(`reportReasonCodes` 의 키). Future reportComment( String campaignId, CampaignComment comment, String reason, ); /// 작성자 차단 — 이후 그 작성자의 댓글은 목록에서 빠진다. Future blockAuthor(CampaignComment comment); /// 댓글 좋아요 저장 — 1인 1회. 실패하면 던진다. Future likeComment(String campaignId, String commentId, bool on); /// 캠페인 신고 — [reason] 은 신고 화면의 사유 문구. 같은 사람의 재신고는 던진다. Future reportCampaign(String campaignId, String reason); /// 개설자 차단 — 이후 그 개설자의 캠페인 · 댓글이 목록에서 빠진다. Future blockCreator(CampaignCardData card); /// 댓글 작성 자격 — 공개 닉네임이 있는가(비었거나 이메일 형태면 없음). 규칙도 /// 응원 게시판 댓글에는 「팬」 대체값을 받지 않는다. Future hasPublicNickname(); /// 닉네임을 정하러 갈 MY 저장소(닉네임 수정 화면이 쓴다). MyRepository get my; } /// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.** class SampleCampaignRepository implements CampaignRepository { SampleCampaignRepository(); static final _base = DateTime(2026, 9, 1, 12); final List _queries = ['강남역', 'LED', '커피차']; static final List _cards = [ CampaignCardData( id: 'c1', summary: '도심 한가운데에서 함께 축하해요.', fandomId: 'bts', fandomName: 'BTS', title: '강남역 초대형 LED 광고', body: 'BTS 데뷔 기념 팬 연합의 초대형 프로젝트입니다. 도심을 BTS의 색으로 물들여주세요.', authorName: '다이브짱', createdAt: _base.subtract(const Duration(hours: 3)), status: CampaignStatus.funding, goalPop: 100000, raisedPop: 92000, supporters: 48, likes: 820, comments: 24, views: 12400, shares: 96, contributedPop: 500, ), CampaignCardData( id: 'c2', summary: '무대 뒤에서 고생하는 분들께 시원한 한 잔.', fandomId: 'bts', fandomName: 'BTS', title: '뮤직뱅크 커피차 보내기', body: '무더위에 고생하는 스태프와 아티스트를 위해 커피차를 준비합니다.', authorName: '아미보이', createdAt: _base.subtract(const Duration(days: 2)), status: CampaignStatus.funding, goalPop: 50000, raisedPop: 12000, supporters: 21, likes: 340, comments: 12, views: 5200, shares: 18, ), CampaignCardData( id: 'c3', summary: '데뷔일을 2호선 전 역에서.', fandomId: 'newjeans', fandomName: 'NewJeans', title: '지하철 생일 광고', body: '데뷔일에 맞춰 2호선 전 역사에 생일 광고를 겁니다.', authorName: '네누', createdAt: _base.subtract(const Duration(days: 12)), status: CampaignStatus.success, goalPop: 30000, raisedPop: 30000, supporters: 64, likes: 1240, comments: 51, views: 21000, shares: 210, mine: true, ), ]; @override Future> load({ CampaignSort sort = CampaignSort.latest, int limit = 10, }) async { final list = [..._cards]; switch (sort) { case CampaignSort.latest: list.sort((a, b) => b.createdAt.compareTo(a.createdAt)); case CampaignSort.popular: list.sort((a, b) => a.comparePopularity(b)); case CampaignSort.contributing: list.retainWhere((c) => c.contributedPop > 0); } return list.take(limit).toList(); } @override Future> more({ required int offset, int limit = 10, }) async => _cards.skip(offset).take(limit).toList(); @override Future detail(String campaignId) async { final card = _cards.firstWhere( (c) => c.id == campaignId, orElse: () => _cards.first, ); return CampaignDetail( card: card, mediaLabels: const ['대표 이미지', '조감도', '위치'], endsAt: _base.add(const Duration(days: 3)), topContributors: const [ ContributorRank(rank: 1, name: '버니즈', pop: 12000, change: 2), ContributorRank(rank: 2, name: '캐럿', pop: 9800, change: -1), ContributorRank(rank: 3, name: '나', pop: 500, change: 4, isMe: true), ], ); } @override Future> ranking( String campaignId, { RankWindow window = RankWindow.live, }) async => const [ ContributorRank(rank: 1, name: '버니즈', pop: 12000, change: 2), ContributorRank(rank: 2, name: '캐럿', pop: 9800, change: -1), ContributorRank(rank: 3, name: '나', pop: 500, change: 4, isMe: true), ContributorRank(rank: 4, name: '무민', pop: 400), ContributorRank(rank: 5, name: '보라', pop: 320), ]; @override Future> recentQueries() async => [..._queries]; @override Future clearQueries() async => _queries.clear(); @override Future removeQuery(String query) async => _queries.remove(query); @override Future> search(String query) async { // 검색어를 최신순 · 중복 제거로 앞에 쌓는다(명세 `523:18429`). _queries ..remove(query) ..insert(0, query); return _cards .where((c) => c.title.contains(query) || c.body.contains(query)) .toList(); } @override Future contribute(String campaignId, int amountPop) async => amountPop; @override Future balance() async => 12540; @override Future feeBalance() => balance(); @override Future like(String campaignId, bool on) async {} @override Future create(CampaignDraft draft) async => 'c-new'; @override Future> mine() async => (await load(limit: 100)).where((c) => c.mine).toList(); @override Future withdraw(String campaignId) async {} late final List _comments = [ CampaignComment( id: 'm1', authorName: '아미보이', authorUid: 'u-army', body: '꼭 성공했으면 좋겠어요!', createdAt: _base.subtract(const Duration(minutes: 20)), ), CampaignComment( id: 'm2', authorName: '다이브짱', authorUid: 'u-dive', body: '조감도 너무 예쁘네요', createdAt: _base.subtract(const Duration(hours: 5)), ), ]; @override Future> comments( String campaignId, { bool more = false, }) async => more ? const [] : [..._comments]; @override Future addComment(String campaignId, String body) async { final c = CampaignComment( id: 'm${_comments.length + 1}', authorName: appL10n.commonMe, body: body, createdAt: DateTime.now(), isMine: true, ); _comments.insert(0, c); return c; } @override Future deleteComment(String campaignId, String commentId) async => _comments.removeWhere((c) => c.id == commentId); @override Future reportComment( String campaignId, CampaignComment comment, String reason, ) async {} @override Future blockAuthor(CampaignComment comment) async => _comments.removeWhere((c) => c.authorUid == comment.authorUid); @override Future likeComment( String campaignId, String commentId, bool on, ) async {} @override Future reportCampaign(String campaignId, String reason) async {} @override Future blockCreator(CampaignCardData card) async {} @override Future hasPublicNickname() async => true; @override late final MyRepository my = SampleMyRepository(); }