623 lines
19 KiB
Dart
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();
|
|
}
|