Files
fanuzu/app/lib/features/campaign/campaign_data.dart
T

623 lines
19 KiB
Dart

/// 캠페인이 그리는 값의 형태 — 화면 대조 원장 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 <int Function(CampaignCardData)>[
(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<String> mediaLabels;
final DateTime endsAt;
final List<ContributorRank> 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<String> photoUrls;
final List<String> 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<List<CampaignCardData>> load({
CampaignSort sort = CampaignSort.latest,
int limit = 10,
});
Future<List<CampaignCardData>> more({required int offset, int limit = 10});
Future<CampaignDetail> detail(String campaignId);
Future<List<ContributorRank>> ranking(
String campaignId, {
RankWindow window = RankWindow.live,
});
Future<List<String>> recentQueries();
Future<void> clearQueries();
/// 최근 검색어 한 개 지우기 — 시안 `272:241681` 검색어 칩의 ✕.
Future<void> removeQuery(String query);
Future<List<CampaignCardData>> search(String query);
/// 기여. **잔액은 서버가 깎는다** — `fn-campaign-fund` 가 원장을 지난다.
/// 실패하면 던진다 — 화면은 성공으로 그리지 않는다.
Future<int> contribute(String campaignId, int amountPop);
/// 후원에 쓸 수 있는 잔액 — 기여 POP(정책 원장 B-33).
Future<int> balance();
/// 등록비에 쓸 수 있는 잔액 — 일반 + 기여 POP. 등록비는 겸용이다(B-57 · B-06).
Future<int> feeBalance();
/// 좋아요 저장. 실패하면 던진다.
Future<void> like(String campaignId, bool on);
Future<String> create(CampaignDraft draft);
/// 내가 만든 캠페인 — 공개 전 · 철회 · 취소 상태까지(MY/내 캠페인).
Future<List<CampaignCardData>> mine();
/// 개설자 철회(`fn-campaign-withdraw` · B-58). 실패하면 던진다.
Future<void> withdraw(String campaignId);
/// 응원 게시판 댓글 한 장 — 최신순(명세 `527:21192`). [more] 가 false 면 첫
/// 장부터 다시 읽는다. 차단한 작성자의 댓글은 뺀다.
Future<List<CampaignComment>> comments(
String campaignId, {
bool more = false,
});
/// 댓글 저장. 실패하면 던진다.
Future<CampaignComment> addComment(String campaignId, String body);
Future<void> deleteComment(String campaignId, String commentId);
/// 신고 — [reason] 은 신고 화면의 사유 문구(`reportReasonCodes` 의 키).
Future<void> reportComment(
String campaignId,
CampaignComment comment,
String reason,
);
/// 작성자 차단 — 이후 그 작성자의 댓글은 목록에서 빠진다.
Future<void> blockAuthor(CampaignComment comment);
/// 댓글 좋아요 저장 — 1인 1회. 실패하면 던진다.
Future<void> likeComment(String campaignId, String commentId, bool on);
/// 캠페인 신고 — [reason] 은 신고 화면의 사유 문구. 같은 사람의 재신고는 던진다.
Future<void> reportCampaign(String campaignId, String reason);
/// 개설자 차단 — 이후 그 개설자의 캠페인 · 댓글이 목록에서 빠진다.
Future<void> blockCreator(CampaignCardData card);
/// 댓글 작성 자격 — 공개 닉네임이 있는가(비었거나 이메일 형태면 없음). 규칙도
/// 응원 게시판 댓글에는 「팬」 대체값을 받지 않는다.
Future<bool> hasPublicNickname();
/// 닉네임을 정하러 갈 MY 저장소(닉네임 수정 화면이 쓴다).
MyRepository get my;
}
/// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.**
class SampleCampaignRepository implements CampaignRepository {
SampleCampaignRepository();
static final _base = DateTime(2026, 9, 1, 12);
final List<String> _queries = ['강남역', 'LED', '커피차'];
static final List<CampaignCardData> _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<List<CampaignCardData>> 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<List<CampaignCardData>> more({
required int offset,
int limit = 10,
}) async => _cards.skip(offset).take(limit).toList();
@override
Future<CampaignDetail> 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<List<ContributorRank>> 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<List<String>> recentQueries() async => [..._queries];
@override
Future<void> clearQueries() async => _queries.clear();
@override
Future<void> removeQuery(String query) async => _queries.remove(query);
@override
Future<List<CampaignCardData>> 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<int> contribute(String campaignId, int amountPop) async => amountPop;
@override
Future<int> balance() async => 12540;
@override
Future<int> feeBalance() => balance();
@override
Future<void> like(String campaignId, bool on) async {}
@override
Future<String> create(CampaignDraft draft) async => 'c-new';
@override
Future<List<CampaignCardData>> mine() async =>
(await load(limit: 100)).where((c) => c.mine).toList();
@override
Future<void> withdraw(String campaignId) async {}
late final List<CampaignComment> _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<List<CampaignComment>> comments(
String campaignId, {
bool more = false,
}) async => more ? const [] : [..._comments];
@override
Future<CampaignComment> 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<void> deleteComment(String campaignId, String commentId) async =>
_comments.removeWhere((c) => c.id == commentId);
@override
Future<void> reportComment(
String campaignId,
CampaignComment comment,
String reason,
) async {}
@override
Future<void> blockAuthor(CampaignComment comment) async =>
_comments.removeWhere((c) => c.authorUid == comment.authorUid);
@override
Future<void> likeComment(
String campaignId,
String commentId,
bool on,
) async {}
@override
Future<void> reportCampaign(String campaignId, String reason) async {}
@override
Future<void> blockCreator(CampaignCardData card) async {}
@override
Future<bool> hasPublicNickname() async => true;
@override
late final MyRepository my = SampleMyRepository();
}