성운 상세 실시간 기여 랭킹에 실제 기여자 표시
This commit is contained in:
@@ -428,7 +428,9 @@ class _Podium extends StatelessWidget {
|
|||||||
_avatar(i + 3, 80),
|
_avatar(i + 3, 80),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
rows[i].isMine ? context.l10n.rankMeSuffix(rows[i].name) : rows[i].name,
|
rows[i].isMine
|
||||||
|
? context.l10n.rankMeSuffix(rows[i].name)
|
||||||
|
: rows[i].name,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
softWrap: false,
|
softWrap: false,
|
||||||
overflow: TextOverflow.visible,
|
overflow: TextOverflow.visible,
|
||||||
|
|||||||
@@ -11,8 +11,13 @@ import '../../core/i18n/app_locale.dart';
|
|||||||
|
|
||||||
/// 수량·상한·중복 여부는 서버가 결정한다. 단말은 참여 의사와 결과만 전달한다.
|
/// 수량·상한·중복 여부는 서버가 결정한다. 단말은 참여 의사와 결과만 전달한다.
|
||||||
class HeroParticipationSheet extends StatefulWidget {
|
class HeroParticipationSheet extends StatefulWidget {
|
||||||
const HeroParticipationSheet({super.key, required this.fandomId,
|
const HeroParticipationSheet({
|
||||||
required this.watch, required this.repository, this.accelerometer});
|
super.key,
|
||||||
|
required this.fandomId,
|
||||||
|
required this.watch,
|
||||||
|
required this.repository,
|
||||||
|
this.accelerometer,
|
||||||
|
});
|
||||||
final String fandomId;
|
final String fandomId;
|
||||||
final PlanetWatch watch;
|
final PlanetWatch watch;
|
||||||
final PlanetRepository repository;
|
final PlanetRepository repository;
|
||||||
@@ -39,10 +44,17 @@ class _HeroParticipationSheetState extends State<HeroParticipationSheet>
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_sensor = (widget.accelerometer ?? accelerometerEventStream()).listen((event) {
|
_sensor = (widget.accelerometer ?? accelerometerEventStream()).listen((
|
||||||
final magnitude = math.sqrt(event.x * event.x + event.y * event.y + event.z * event.z);
|
event,
|
||||||
|
) {
|
||||||
|
final magnitude = math.sqrt(
|
||||||
|
event.x * event.x + event.y * event.y + event.z * event.z,
|
||||||
|
);
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
if (!magnitude.isFinite || magnitude < 18 || now.difference(_lastShake).inMilliseconds < 220) return;
|
if (!magnitude.isFinite ||
|
||||||
|
magnitude < 18 ||
|
||||||
|
now.difference(_lastShake).inMilliseconds < 220)
|
||||||
|
return;
|
||||||
_lastShake = now;
|
_lastShake = now;
|
||||||
if (!_failed) _claim();
|
if (!_failed) _claim();
|
||||||
}, onError: (_) {}); // 센서가 없어도 동일한 서버 경로의 참여 버튼을 제공한다.
|
}, onError: (_) {}); // 센서가 없어도 동일한 서버 경로의 참여 버튼을 제공한다.
|
||||||
@@ -62,21 +74,39 @@ class _HeroParticipationSheetState extends State<HeroParticipationSheet>
|
|||||||
|
|
||||||
String? get _next {
|
String? get _next {
|
||||||
final state = _state;
|
final state = _state;
|
||||||
if (_accessDenied || state?.isHeroTime != true || state?.heroEndsAt?.isAfter(DateTime.now()) != true) return null;
|
if (_accessDenied ||
|
||||||
final ids = (state?.heroEventIds ?? {}).where((id) => !_received.containsKey(id) && !_unavailable.contains(id)).toList()..sort();
|
state?.isHeroTime != true ||
|
||||||
|
state?.heroEndsAt?.isAfter(DateTime.now()) != true)
|
||||||
|
return null;
|
||||||
|
final ids =
|
||||||
|
(state?.heroEventIds ?? {})
|
||||||
|
.where(
|
||||||
|
(id) => !_received.containsKey(id) && !_unavailable.contains(id),
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
..sort();
|
||||||
return ids.firstOrNull;
|
return ids.firstOrNull;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _claim() async {
|
Future<void> _claim() async {
|
||||||
if (_sending || !_foreground || ModalRoute.of(context)?.isCurrent != true) return;
|
if (_sending || !_foreground || ModalRoute.of(context)?.isCurrent != true)
|
||||||
|
return;
|
||||||
// 응답 유실 재확인은 이벤트 종료 뒤에도 같은 멱등키로 요청한다.
|
// 응답 유실 재확인은 이벤트 종료 뒤에도 같은 멱등키로 요청한다.
|
||||||
final id = _pendingId ?? _next;
|
final id = _pendingId ?? _next;
|
||||||
if (id == null) return;
|
if (id == null) return;
|
||||||
setState(() { _sending = true; _failed = false; _notice = null; _pendingId = id; });
|
setState(() {
|
||||||
|
_sending = true;
|
||||||
|
_failed = false;
|
||||||
|
_notice = null;
|
||||||
|
_pendingId = id;
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
final receipt = await widget.repository.claimHeroDrop(id);
|
final receipt = await widget.repository.claimHeroDrop(id);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() { _received[id] = receipt; _pendingId = null; });
|
setState(() {
|
||||||
|
_received[id] = receipt;
|
||||||
|
_pendingId = null;
|
||||||
|
});
|
||||||
if (_foreground) HapticFeedback.lightImpact();
|
if (_foreground) HapticFeedback.lightImpact();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -109,28 +139,54 @@ class _HeroParticipationSheetState extends State<HeroParticipationSheet>
|
|||||||
_state = state;
|
_state = state;
|
||||||
final available = _next != null;
|
final available = _next != null;
|
||||||
final total = _received.values.fold<int>(0, (sum, r) => sum + r.amount);
|
final total = _received.values.fold<int>(0, (sum, r) => sum + r.amount);
|
||||||
return SafeArea(child: Padding(
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
child: Column(
|
||||||
Text('HERO TIME · ZAP+', style: Theme.of(context).textTheme.titleLarge),
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'HERO TIME · ZAP+',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(context.l10n.heroGiftHowTo),
|
Text(context.l10n.heroGiftHowTo),
|
||||||
Text(context.l10n.heroGiftPopNote),
|
Text(context.l10n.heroGiftPopNote),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (_received.isNotEmpty)
|
if (_received.isNotEmpty)
|
||||||
Semantics(liveRegion: true, child: Text(context.l10n.heroGiftReceived('$total'), style: Theme.of(context).textTheme.titleMedium)),
|
Semantics(
|
||||||
if (_notice case final notice?) Semantics(liveRegion: true, child: Text(notice)),
|
liveRegion: true,
|
||||||
|
child: Text(
|
||||||
|
context.l10n.heroGiftReceived('$total'),
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_notice case final notice?)
|
||||||
|
Semantics(liveRegion: true, child: Text(notice)),
|
||||||
if (_failed) Text(context.l10n.heroGiftUnknown),
|
if (_failed) Text(context.l10n.heroGiftUnknown),
|
||||||
if (!available && !_sending && !_failed && _notice == null)
|
if (!available && !_sending && !_failed && _notice == null)
|
||||||
Text(_received.isEmpty ? context.l10n.heroGiftNone : context.l10n.heroGiftConfirmed),
|
Text(
|
||||||
|
_received.isEmpty
|
||||||
|
? context.l10n.heroGiftNone
|
||||||
|
: context.l10n.heroGiftConfirmed,
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _sending || (!available && !_failed) ? null : _claim,
|
onPressed: _sending || (!available && !_failed) ? null : _claim,
|
||||||
icon: const Icon(Icons.auto_awesome),
|
icon: const Icon(Icons.auto_awesome),
|
||||||
label: Text(_sending ? context.l10n.heroGiftChecking : _failed ? context.l10n.heroGiftRetry : context.l10n.heroGiftTap),
|
label: Text(
|
||||||
|
_sending
|
||||||
|
? context.l10n.heroGiftChecking
|
||||||
|
: _failed
|
||||||
|
? context.l10n.heroGiftRetry
|
||||||
|
: context.l10n.heroGiftTap,
|
||||||
),
|
),
|
||||||
]),
|
),
|
||||||
));
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,11 +51,7 @@ class ModerationTarget {
|
|||||||
|
|
||||||
/// 신고하기 화면.
|
/// 신고하기 화면.
|
||||||
class ReportScreen extends StatefulWidget {
|
class ReportScreen extends StatefulWidget {
|
||||||
const ReportScreen({
|
const ReportScreen({super.key, required this.target, this.reasons});
|
||||||
super.key,
|
|
||||||
required this.target,
|
|
||||||
this.reasons,
|
|
||||||
});
|
|
||||||
|
|
||||||
final ModerationTarget target;
|
final ModerationTarget target;
|
||||||
final List<String>? reasons;
|
final List<String>? reasons;
|
||||||
@@ -67,8 +63,11 @@ class ReportScreen extends StatefulWidget {
|
|||||||
class _ReportScreenState extends State<ReportScreen> {
|
class _ReportScreenState extends State<ReportScreen> {
|
||||||
String? _reason;
|
String? _reason;
|
||||||
|
|
||||||
static TextStyle _t(double size, Color color, {FontWeight w = FontWeight.w600}) =>
|
static TextStyle _t(
|
||||||
TextStyle(
|
double size,
|
||||||
|
Color color, {
|
||||||
|
FontWeight w = FontWeight.w600,
|
||||||
|
}) => TextStyle(
|
||||||
fontFamily: AppFonts.body,
|
fontFamily: AppFonts.body,
|
||||||
fontSize: size,
|
fontSize: size,
|
||||||
fontWeight: w,
|
fontWeight: w,
|
||||||
@@ -105,18 +104,29 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_info(context.l10n.reportAuthor, widget.target.authorName, const Color(0xFFEE0C2C)),
|
_info(
|
||||||
|
context.l10n.reportAuthor,
|
||||||
|
widget.target.authorName,
|
||||||
|
const Color(0xFFEE0C2C),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Divider(height: 1, thickness: 1, color: Color(0xFF313131)),
|
const Divider(height: 1, thickness: 1, color: Color(0xFF313131)),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_info(context.l10n.commonTitle, widget.target.title, const Color(0xFFD1D1D2)),
|
_info(
|
||||||
|
context.l10n.commonTitle,
|
||||||
|
widget.target.title,
|
||||||
|
const Color(0xFFD1D1D2),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(
|
Container(
|
||||||
color: const Color(0xFF232325),
|
color: const Color(0xFF232325),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
child: Text(context.l10n.reportReasonLabel, style: _t(12, const Color(0xFF909091))),
|
child: Text(
|
||||||
|
context.l10n.reportReasonLabel,
|
||||||
|
style: _t(12, const Color(0xFF909091)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
// 디폴트 미선택 · 단수 선택.
|
// 디폴트 미선택 · 단수 선택.
|
||||||
for (final r in widget.reasons ?? defaultReportReasons)
|
for (final r in widget.reasons ?? defaultReportReasons)
|
||||||
@@ -145,20 +155,26 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||||||
children: [
|
children: [
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 36,
|
width: 36,
|
||||||
child: Text(label, textAlign: TextAlign.center, style: _t(14, const Color(0xFF59595B))),
|
child: Text(
|
||||||
|
label,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: _t(14, const Color(0xFF59595B)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 20),
|
const SizedBox(width: 20),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(value, maxLines: 1, overflow: TextOverflow.ellipsis, style: _t(14, color)),
|
child: Text(
|
||||||
|
value,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: _t(14, color),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
Future<void> _submit() async {
|
Future<void> _submit() async {
|
||||||
final ok = await confirmOverlay(
|
final ok = await confirmOverlay(context, context.l10n.reportConfirm);
|
||||||
context,
|
|
||||||
context.l10n.reportConfirm,
|
|
||||||
);
|
|
||||||
if (!ok || !mounted) return;
|
if (!ok || !mounted) return;
|
||||||
Navigator.pop(context, _reason);
|
Navigator.pop(context, _reason);
|
||||||
}
|
}
|
||||||
@@ -226,7 +242,11 @@ Future<bool> confirmOverlay(BuildContext context, String message) async {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
button(c, c.l10n.commonCancel, false),
|
button(c, c.l10n.commonCancel, false),
|
||||||
Container(width: 0.5, height: 42, color: const Color(0xFF636463)),
|
Container(
|
||||||
|
width: 0.5,
|
||||||
|
height: 42,
|
||||||
|
color: const Color(0xFF636463),
|
||||||
|
),
|
||||||
button(c, c.l10n.commonConfirm, true),
|
button(c, c.l10n.commonConfirm, true),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
/// - 팬덤 라운지 리스트 — *최신순 소팅 · 최초 5개, 스크롤 시 5개씩 호출*
|
/// - 팬덤 라운지 리스트 — *최신순 소팅 · 최초 5개, 스크롤 시 5개씩 호출*
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import '../campaign/campaign_data.dart' show ContributorRank;
|
||||||
import '../my/my_data.dart' show PickedPhoto, PhotoRejected, checkProfilePhoto;
|
import '../my/my_data.dart' show PickedPhoto, PhotoRejected, checkProfilePhoto;
|
||||||
import '../../core/i18n/app_locale.dart';
|
import '../../core/i18n/app_locale.dart';
|
||||||
|
|
||||||
@@ -69,7 +70,8 @@ List<(T, int)> rankWithTies<T>(
|
|||||||
num Function(T) value,
|
num Function(T) value,
|
||||||
String Function(T) id,
|
String Function(T) id,
|
||||||
) {
|
) {
|
||||||
final sorted = [...items]..sort((a, b) {
|
final sorted = [...items]
|
||||||
|
..sort((a, b) {
|
||||||
final byValue = value(b).compareTo(value(a));
|
final byValue = value(b).compareTo(value(a));
|
||||||
return byValue != 0 ? byValue : id(a).compareTo(id(b));
|
return byValue != 0 ? byValue : id(a).compareTo(id(b));
|
||||||
});
|
});
|
||||||
@@ -304,12 +306,26 @@ class HeroDropReceipt {
|
|||||||
final int amount;
|
final int amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum PlanetHistoryKind { evolution, hibernation, wakeUp, zap, questReward, companyAdBonus, funding }
|
enum PlanetHistoryKind {
|
||||||
|
evolution,
|
||||||
|
hibernation,
|
||||||
|
wakeUp,
|
||||||
|
zap,
|
||||||
|
questReward,
|
||||||
|
companyAdBonus,
|
||||||
|
funding,
|
||||||
|
}
|
||||||
|
|
||||||
class PlanetHistoryEntry {
|
class PlanetHistoryEntry {
|
||||||
const PlanetHistoryEntry({required this.id, required this.kind,
|
const PlanetHistoryEntry({
|
||||||
required this.occurredAt, this.fromStage, this.toStage,
|
required this.id,
|
||||||
this.participationConfirmed = false, this.amount});
|
required this.kind,
|
||||||
|
required this.occurredAt,
|
||||||
|
this.fromStage,
|
||||||
|
this.toStage,
|
||||||
|
this.participationConfirmed = false,
|
||||||
|
this.amount,
|
||||||
|
});
|
||||||
final String id;
|
final String id;
|
||||||
final PlanetHistoryKind kind;
|
final PlanetHistoryKind kind;
|
||||||
final DateTime occurredAt;
|
final DateTime occurredAt;
|
||||||
@@ -340,7 +356,10 @@ abstract interface class PlanetRepository {
|
|||||||
Future<List<LoungePost>> morePosts({required int offset, int limit = 5});
|
Future<List<LoungePost>> morePosts({required int offset, int limit = 5});
|
||||||
|
|
||||||
/// 글 쓰기. 사진은 먼저 Storage 에 올리고 URL 을 글에 붙인다(B-44).
|
/// 글 쓰기. 사진은 먼저 Storage 에 올리고 URL 을 글에 붙인다(B-44).
|
||||||
Future<LoungePost> createPost(String body, {List<PickedPhoto> photos = const []});
|
Future<LoungePost> createPost(
|
||||||
|
String body, {
|
||||||
|
List<PickedPhoto> photos = const [],
|
||||||
|
});
|
||||||
|
|
||||||
/// 내 글 본문 수정. 사진은 바꾸지 않는다.
|
/// 내 글 본문 수정. 사진은 바꾸지 않는다.
|
||||||
Future<LoungePost> updatePost(LoungePost post, String body);
|
Future<LoungePost> updatePost(LoungePost post, String body);
|
||||||
@@ -377,7 +396,13 @@ abstract interface class PlanetRepository {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/// 지표별 **국가** 랭킹 — Figma `546:14713`(랭킹 필터 「국가별」).
|
/// 지표별 **국가** 랭킹 — Figma `546:14713`(랭킹 필터 「국가별」).
|
||||||
Future<FpiRanking> countryRanking({required FpiMetric metric, int limit = 30});
|
Future<FpiRanking> countryRanking({
|
||||||
|
required FpiMetric metric,
|
||||||
|
int limit = 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 성운 「실시간 기여 랭킹」 — 멤버 누적 기여 POP(`members.contributedPop`) 상위.
|
||||||
|
Future<List<ContributorRank>> contributors({int limit = 3});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 행성 ID 로 저장소를 만드는 함수. 갤럭시 화면이 어느 행성으로 들어가든
|
/// 행성 ID 로 저장소를 만드는 함수. 갤럭시 화면이 어느 행성으로 들어가든
|
||||||
@@ -398,14 +423,21 @@ class SamplePlanetRepository implements PlanetRepository {
|
|||||||
@override
|
@override
|
||||||
Future<void> join() async {}
|
Future<void> join() async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ContributorRank>> contributors({int limit = 3}) async => const [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> leave() async {}
|
Future<void> leave() async {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<PlanetHistoryPage> history({String? cursor, bool personal = false}) async => const PlanetHistoryPage(items: []);
|
Future<PlanetHistoryPage> history({
|
||||||
|
String? cursor,
|
||||||
|
bool personal = false,
|
||||||
|
}) async => const PlanetHistoryPage(items: []);
|
||||||
@override
|
@override
|
||||||
Future<HeroDropReceipt> claimHeroDrop(String dropId) =>
|
Future<HeroDropReceipt> claimHeroDrop(String dropId) =>
|
||||||
Future.error(StateError('실제 지급 서버가 연결되지 않았습니다.'));
|
Future.error(StateError('실제 지급 서버가 연결되지 않았습니다.'));
|
||||||
|
|
||||||
/// [fandomId] 는 갤럭시에서 누른 행성이다. 주지 않으면 내 행성으로 본다.
|
/// [fandomId] 는 갤럭시에서 누른 행성이다. 주지 않으면 내 행성으로 본다.
|
||||||
const SamplePlanetRepository({String? fandomId})
|
const SamplePlanetRepository({String? fandomId})
|
||||||
: fandomId = fandomId ?? _myFandomId;
|
: fandomId = fandomId ?? _myFandomId;
|
||||||
@@ -428,8 +460,10 @@ class SamplePlanetRepository implements PlanetRepository {
|
|||||||
/// ponytail: 표본에 없는 ID(빈 문자열 포함)면 첫 행으로 떨어진다. 목 모드에서
|
/// ponytail: 표본에 없는 ID(빈 문자열 포함)면 첫 행으로 떨어진다. 목 모드에서
|
||||||
/// ID 를 주는 곳은 [SampleGalaxyRepository] 의 6행성뿐이라 닿지 않는 길이고,
|
/// ID 를 주는 곳은 [SampleGalaxyRepository] 의 6행성뿐이라 닿지 않는 길이고,
|
||||||
/// 여기서 던지면 발주사 확인용 화면이 빈다. 표본 행성이 늘어나면 두 표를 함께 늘린다.
|
/// 여기서 던지면 발주사 확인용 화면이 빈다. 표본 행성이 늘어나면 두 표를 함께 늘린다.
|
||||||
(String, String, List<int>) get _row =>
|
(String, String, List<int>) get _row => _planets.firstWhere(
|
||||||
_planets.firstWhere((p) => p.$1 == fandomId, orElse: () => _planets.first);
|
(p) => p.$1 == fandomId,
|
||||||
|
orElse: () => _planets.first,
|
||||||
|
);
|
||||||
|
|
||||||
/// 행성별 5축 값. 순서는 [FpiMetric.values] 와 같다 — 별첨 B 확정 순서인
|
/// 행성별 5축 값. 순서는 [FpiMetric.values] 와 같다 — 별첨 B 확정 순서인
|
||||||
/// `bpm · health · referral · contribution · community`.
|
/// `bpm · health · referral · contribution · community`.
|
||||||
@@ -532,9 +566,12 @@ class SamplePlanetRepository implements PlanetRepository {
|
|||||||
// 전체 순위 = 행성 랭킹에서 **이 행성**의 순위. 랭킹 화면과 같은 계산이다.
|
// 전체 순위 = 행성 랭킹에서 **이 행성**의 순위. 랭킹 화면과 같은 계산이다.
|
||||||
rank: nebula
|
rank: nebula
|
||||||
? 0
|
? 0
|
||||||
: _rank(_planets, metric, myId: row.$1, limit: _planets.length)
|
: _rank(
|
||||||
.mine!
|
_planets,
|
||||||
.rank,
|
metric,
|
||||||
|
myId: row.$1,
|
||||||
|
limit: _planets.length,
|
||||||
|
).mine!.rank,
|
||||||
trend: nebula ? 0 : axes[metric.index] ~/ 60 - metric.index * 40,
|
trend: nebula ? 0 : axes[metric.index] ~/ 60 - metric.index * 40,
|
||||||
// 내 기여도는 **내 행성에만 있다.** 남의 행성에서 내 등수를 지어내면
|
// 내 기여도는 **내 행성에만 있다.** 남의 행성에서 내 등수를 지어내면
|
||||||
// 화면이 그것을 사실로 띄운다(대조표 3-5 — 없으면 null).
|
// 화면이 그것을 사실로 띄운다(대조표 3-5 — 없으면 null).
|
||||||
@@ -546,8 +583,10 @@ class SamplePlanetRepository implements PlanetRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<LoungePost>> morePosts({required int offset, int limit = 5}) async =>
|
Future<List<LoungePost>> morePosts({
|
||||||
_posts(offset, limit);
|
required int offset,
|
||||||
|
int limit = 5,
|
||||||
|
}) async => _posts(offset, limit);
|
||||||
|
|
||||||
/// 표본 댓글. 서버가 없어도 쓰고 지운 댓글이 상세를 다시 열 때 남아 있게 한다.
|
/// 표본 댓글. 서버가 없어도 쓰고 지운 댓글이 상세를 다시 열 때 남아 있게 한다.
|
||||||
static final _comments = <String, List<LoungeComment>>{};
|
static final _comments = <String, List<LoungeComment>>{};
|
||||||
@@ -613,8 +652,10 @@ class SamplePlanetRepository implements PlanetRepository {
|
|||||||
Future<void> blockAuthor(LoungePost post) async {}
|
Future<void> blockAuthor(LoungePost post) async {}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<FpiRanking> planetRanking({required FpiMetric metric, int limit = 30}) async =>
|
Future<FpiRanking> planetRanking({
|
||||||
_rank(_planets, metric, myId: _myFandomId, limit: limit);
|
required FpiMetric metric,
|
||||||
|
int limit = 30,
|
||||||
|
}) async => _rank(_planets, metric, myId: _myFandomId, limit: limit);
|
||||||
|
|
||||||
/// 표본 사용자는 **내 행성 소속**이다. 남의 행성 랭킹에 「나」를 끼우면
|
/// 표본 사용자는 **내 행성 소속**이다. 남의 행성 랭킹에 「나」를 끼우면
|
||||||
/// 「나의 랭킹 영역」이 가입하지도 않은 행성에서 뜬다. 대상 행성은 인터페이스가
|
/// 「나의 랭킹 영역」이 가입하지도 않은 행성에서 뜬다. 대상 행성은 인터페이스가
|
||||||
@@ -636,7 +677,10 @@ class SamplePlanetRepository implements PlanetRepository {
|
|||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<FpiRanking> countryRanking({required FpiMetric metric, int limit = 30}) async =>
|
Future<FpiRanking> countryRanking({
|
||||||
|
required FpiMetric metric,
|
||||||
|
int limit = 30,
|
||||||
|
}) async =>
|
||||||
// 「나의 랭킹 영역」은 국가별 필터에서도 뜬다 — 내 국가 행이다(546:14713).
|
// 「나의 랭킹 영역」은 국가별 필터에서도 뜬다 — 내 국가 행이다(546:14713).
|
||||||
_rank(_countries, metric, myId: 'KR', limit: limit);
|
_rank(_countries, metric, myId: 'KR', limit: limit);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import 'planet_history_screen.dart';
|
|||||||
import 'hero_participation_sheet.dart';
|
import 'hero_participation_sheet.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../campaign/campaign_data.dart' show ContributorRank;
|
||||||
|
import '../campaign/widgets/campaign_rank.dart' show RankPodium;
|
||||||
|
|
||||||
import '../../core/data/backend.dart' show BackendException;
|
import '../../core/data/backend.dart' show BackendException;
|
||||||
import '../../core/data/planet_visual_state.dart';
|
import '../../core/data/planet_visual_state.dart';
|
||||||
|
|
||||||
@@ -66,11 +69,15 @@ class _PlanetDetailScreenState extends State<PlanetDetailScreen> {
|
|||||||
try {
|
try {
|
||||||
await widget.repository.join();
|
await widget.repository.join();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() { _detail = widget.repository.load(nebula: widget.nebula); });
|
setState(() {
|
||||||
|
_detail = widget.repository.load(nebula: widget.nebula);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} on BackendException catch (e) {
|
} on BackendException catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(e.message)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,11 +96,15 @@ class _PlanetDetailScreenState extends State<PlanetDetailScreen> {
|
|||||||
try {
|
try {
|
||||||
await widget.repository.leave();
|
await widget.repository.leave();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() { _detail = widget.repository.load(nebula: widget.nebula); });
|
setState(() {
|
||||||
|
_detail = widget.repository.load(nebula: widget.nebula);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} on BackendException catch (e) {
|
} on BackendException catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(e.message)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -108,7 +119,9 @@ class _PlanetDetailScreenState extends State<PlanetDetailScreen> {
|
|||||||
}
|
}
|
||||||
final d = snap.data;
|
final d = snap.data;
|
||||||
if (d == null) {
|
if (d == null) {
|
||||||
return Scaffold(body: Center(child: Text(context.l10n.commonLoadFailed)));
|
return Scaffold(
|
||||||
|
body: Center(child: Text(context.l10n.commonLoadFailed)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// 시안(373:441117)은 히어로 543 아래 「FPI | 팬덤 라운지」 탭이 오고 화면 전체가
|
// 시안(373:441117)은 히어로 543 아래 「FPI | 팬덤 라운지」 탭이 오고 화면 전체가
|
||||||
// 한 번에 스크롤된다. 스크롤하면 히어로가 접혀 앱 바(행성 축소판 + 이름)와 탭만
|
// 한 번에 스크롤된다. 스크롤하면 히어로가 접혀 앱 바(행성 축소판 + 이름)와 탭만
|
||||||
@@ -205,8 +218,14 @@ class _PlanetDetailScreenState extends State<PlanetDetailScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}, destructive: false),
|
}, destructive: false),
|
||||||
ActionSheetItem(context.l10n.reportTitle, () => _moderate(context, 'report', name)),
|
ActionSheetItem(
|
||||||
ActionSheetItem(context.l10n.commonBlockAction, () => _moderate(context, 'block', name)),
|
context.l10n.reportTitle,
|
||||||
|
() => _moderate(context, 'report', name),
|
||||||
|
),
|
||||||
|
ActionSheetItem(
|
||||||
|
context.l10n.commonBlockAction,
|
||||||
|
() => _moderate(context, 'block', name),
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Future<void> _moderate(
|
Future<void> _moderate(
|
||||||
@@ -219,11 +238,15 @@ class _PlanetDetailScreenState extends State<PlanetDetailScreen> {
|
|||||||
.push<String>(
|
.push<String>(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => ReportScreen(
|
builder: (_) => ReportScreen(
|
||||||
target: ModerationTarget(authorName: name, title: context.l10n.planetNamed(name)),
|
target: ModerationTarget(
|
||||||
|
authorName: name,
|
||||||
|
title: context.l10n.planetNamed(name),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (reason != null && context.mounted) _toast(context, context.l10n.reportReceived);
|
if (reason != null && context.mounted)
|
||||||
|
_toast(context, context.l10n.reportReceived);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final ok = await confirmBlock(
|
final ok = await confirmBlock(
|
||||||
@@ -408,7 +431,7 @@ class _FpiTab extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
sliver: SliverList.list(
|
sliver: SliverList.list(
|
||||||
children: [
|
children: [
|
||||||
_NebulaFpi(pop: detail.growthPop),
|
_NebulaFpi(pop: detail.growthPop, repository: repository),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -502,9 +525,10 @@ class _FpiTab extends StatelessWidget {
|
|||||||
/// 목표는 원장 B-10 의 1,000 POP(사본 500,000 은 과거 수치). 분자는 서버 `growthPop`.
|
/// 목표는 원장 B-10 의 1,000 POP(사본 500,000 은 과거 수치). 분자는 서버 `growthPop`.
|
||||||
/// 기여자 목록은 상세 데이터에 아직 없어 빈 상태로 둔다.
|
/// 기여자 목록은 상세 데이터에 아직 없어 빈 상태로 둔다.
|
||||||
class _NebulaFpi extends StatelessWidget {
|
class _NebulaFpi extends StatelessWidget {
|
||||||
const _NebulaFpi({required this.pop});
|
const _NebulaFpi({required this.pop, required this.repository});
|
||||||
|
|
||||||
final int pop;
|
final int pop;
|
||||||
|
final PlanetRepository repository;
|
||||||
|
|
||||||
static TextStyle _b(double size, Color color, {double? height}) => TextStyle(
|
static TextStyle _b(double size, Color color, {double? height}) => TextStyle(
|
||||||
fontFamily: AppFonts.body,
|
fontFamily: AppFonts.body,
|
||||||
@@ -531,7 +555,10 @@ class _NebulaFpi extends StatelessWidget {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text(context.l10n.planetContribution, style: _b(18, AppColors.onCosmic)),
|
Text(
|
||||||
|
context.l10n.planetContribution,
|
||||||
|
style: _b(18, AppColors.onCosmic),
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
@@ -589,7 +616,25 @@ class _NebulaFpi extends StatelessWidget {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(context.l10n.planetLiveRanking, style: _b(18, AppColors.onCosmic)),
|
Text(context.l10n.planetLiveRanking, style: _b(18, AppColors.onCosmic)),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Column(
|
FutureBuilder<List<ContributorRank>>(
|
||||||
|
future: repository.contributors(),
|
||||||
|
builder: (context, snap) {
|
||||||
|
final top = snap.data ?? const <ContributorRank>[];
|
||||||
|
if (top.isNotEmpty) {
|
||||||
|
// 캠페인 상세와 같은 단상(2 · 1 · 3) — 시안 `373:442708` 랭킹 자리.
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 20),
|
||||||
|
child: RankPodium(top: top),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return _empty(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _empty(BuildContext context) => Column(
|
||||||
children: [
|
children: [
|
||||||
SizedBox.square(
|
SizedBox.square(
|
||||||
dimension: 24,
|
dimension: 24,
|
||||||
@@ -636,11 +681,8 @@ class _NebulaFpi extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// 지표 카드 — 시안 `Frame 1984081152`(#19191c r16 · 안쪽 12 · 높이 117).
|
/// 지표 카드 — 시안 `Frame 1984081152`(#19191c r16 · 안쪽 12 · 높이 117).
|
||||||
///
|
///
|
||||||
@@ -723,7 +765,9 @@ class _ScoreCard extends StatelessWidget {
|
|||||||
// 순위가 없으면(집계 모집단 밖 · 0) 「전체 0위」 대신 「순위 집계 전」.
|
// 순위가 없으면(집계 모집단 밖 · 0) 「전체 0위」 대신 「순위 집계 전」.
|
||||||
Text(
|
Text(
|
||||||
score.rank > 0
|
score.rank > 0
|
||||||
? context.l10n.planetOverallRank('${score.rank}')
|
? context.l10n.planetOverallRank(
|
||||||
|
'${score.rank}',
|
||||||
|
)
|
||||||
: context.l10n.planetRankNone,
|
: context.l10n.planetRankNone,
|
||||||
style: _t(12, color),
|
style: _t(12, color),
|
||||||
),
|
),
|
||||||
@@ -779,14 +823,19 @@ class _ScoreCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(context.l10n.planetMyContribution, style: _t(10, AppColors.onCosmic, height: 1)),
|
Text(
|
||||||
|
context.l10n.planetMyContribution,
|
||||||
|
style: _t(10, AppColors.onCosmic, height: 1),
|
||||||
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Text(
|
Text(
|
||||||
// 서버 산출값이 없으면 숫자를 짓지 않는다 — 「상위 100%」는
|
// 서버 산출값이 없으면 숫자를 짓지 않는다 — 「상위 100%」는
|
||||||
// 최하위라는 사실 주장이 되어 버린다(대조표 3-5).
|
// 최하위라는 사실 주장이 되어 버린다(대조표 3-5).
|
||||||
score.myTopPercent == null
|
score.myTopPercent == null
|
||||||
? '—'
|
? '—'
|
||||||
: context.l10n.planetTopPercent('${score.myTopPercent}'),
|
: context.l10n.planetTopPercent(
|
||||||
|
'${score.myTopPercent}',
|
||||||
|
),
|
||||||
style: _t(10, AppColors.onCosmic, height: 1),
|
style: _t(10, AppColors.onCosmic, height: 1),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -914,10 +963,7 @@ class _LoungeTabState extends State<_LoungeTab> {
|
|||||||
void _replace(LoungePost old, LoungePost? post) => setState(() {
|
void _replace(LoungePost old, LoungePost? post) => setState(() {
|
||||||
_posts = [
|
_posts = [
|
||||||
for (final p in _posts)
|
for (final p in _posts)
|
||||||
if (p.id != old.id)
|
if (p.id != old.id) p else if (post != null) post,
|
||||||
p
|
|
||||||
else if (post != null)
|
|
||||||
post,
|
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -933,7 +979,10 @@ class _LoungeTabState extends State<_LoungeTab> {
|
|||||||
/// 내 글이면 수정 · 삭제를 띄운다.
|
/// 내 글이면 수정 · 삭제를 띄운다.
|
||||||
void _postMore(LoungePost post) => showActionSheet(context, [
|
void _postMore(LoungePost post) => showActionSheet(context, [
|
||||||
if (post.isMine) ...[
|
if (post.isMine) ...[
|
||||||
ActionSheetItem(context.l10n.commonEditAction, destructive: false, () async {
|
ActionSheetItem(
|
||||||
|
context.l10n.commonEditAction,
|
||||||
|
destructive: false,
|
||||||
|
() async {
|
||||||
final updated = await Navigator.of(context, rootNavigator: true)
|
final updated = await Navigator.of(context, rootNavigator: true)
|
||||||
.push<LoungePost>(
|
.push<LoungePost>(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
@@ -945,9 +994,11 @@ class _LoungeTabState extends State<_LoungeTab> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (updated != null && mounted) _replace(post, updated);
|
if (updated != null && mounted) _replace(post, updated);
|
||||||
}),
|
},
|
||||||
|
),
|
||||||
ActionSheetItem(context.l10n.commonDeleteAction, () async {
|
ActionSheetItem(context.l10n.commonDeleteAction, () async {
|
||||||
if (!await confirmOverlay(context, context.l10n.postDeleteConfirm)) return;
|
if (!await confirmOverlay(context, context.l10n.postDeleteConfirm))
|
||||||
|
return;
|
||||||
await _run(() async {
|
await _run(() async {
|
||||||
await widget.repository.deletePost(post);
|
await widget.repository.deletePost(post);
|
||||||
_replace(post, null);
|
_replace(post, null);
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ class _PlanetHistoryScreenState extends State<PlanetHistoryScreen> {
|
|||||||
_failed = false;
|
_failed = false;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
final page = await widget.repository.history(cursor: _cursor, personal: _personal);
|
final page = await widget.repository.history(
|
||||||
|
cursor: _cursor,
|
||||||
|
personal: _personal,
|
||||||
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
final ids = _items.map((item) => item.id).toSet();
|
final ids = _items.map((item) => item.id).toSet();
|
||||||
@@ -60,15 +63,22 @@ class _PlanetHistoryScreenState extends State<PlanetHistoryScreen> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
SegmentedButton<bool>(
|
SegmentedButton<bool>(
|
||||||
segments: [
|
segments: [
|
||||||
ButtonSegment(value: false, label: Text(context.l10n.homeFandomHistory)),
|
ButtonSegment(
|
||||||
|
value: false,
|
||||||
|
label: Text(context.l10n.homeFandomHistory),
|
||||||
|
),
|
||||||
ButtonSegment(value: true, label: Text(context.l10n.historyMine)),
|
ButtonSegment(value: true, label: Text(context.l10n.historyMine)),
|
||||||
],
|
],
|
||||||
selected: {_personal},
|
selected: {_personal},
|
||||||
onSelectionChanged: _loading ? null : (selection) {
|
onSelectionChanged: _loading
|
||||||
|
? null
|
||||||
|
: (selection) {
|
||||||
if (selection.single == _personal) return;
|
if (selection.single == _personal) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_personal = selection.single;
|
_personal = selection.single;
|
||||||
_items.clear(); _cursor = null; _loaded = false;
|
_items.clear();
|
||||||
|
_cursor = null;
|
||||||
|
_loaded = false;
|
||||||
});
|
});
|
||||||
_load();
|
_load();
|
||||||
},
|
},
|
||||||
@@ -87,8 +97,7 @@ class _PlanetHistoryScreenState extends State<PlanetHistoryScreen> {
|
|||||||
PlanetHistoryKind.funding => Icons.favorite_border,
|
PlanetHistoryKind.funding => Icons.favorite_border,
|
||||||
}),
|
}),
|
||||||
title: Text(switch (item.kind) {
|
title: Text(switch (item.kind) {
|
||||||
PlanetHistoryKind.evolution =>
|
PlanetHistoryKind.evolution => context.l10n.historyEvolution(
|
||||||
context.l10n.historyEvolution(
|
|
||||||
'${item.fromStage}',
|
'${item.fromStage}',
|
||||||
'${item.toStage}',
|
'${item.toStage}',
|
||||||
),
|
),
|
||||||
@@ -104,7 +113,8 @@ class _PlanetHistoryScreenState extends State<PlanetHistoryScreen> {
|
|||||||
MaterialLocalizations.of(
|
MaterialLocalizations.of(
|
||||||
context,
|
context,
|
||||||
).formatMediumDate(item.occurredAt.toLocal()),
|
).formatMediumDate(item.occurredAt.toLocal()),
|
||||||
if (item.amount != null) context.l10n.historyGrowthPop('${item.amount}'),
|
if (item.amount != null)
|
||||||
|
context.l10n.historyGrowthPop('${item.amount}'),
|
||||||
if (item.participationConfirmed) context.l10n.historyJoined,
|
if (item.participationConfirmed) context.l10n.historyJoined,
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
),
|
),
|
||||||
@@ -114,9 +124,11 @@ class _PlanetHistoryScreenState extends State<PlanetHistoryScreen> {
|
|||||||
Text(context.l10n.historyLoadFailed),
|
Text(context.l10n.historyLoadFailed),
|
||||||
TextButton(onPressed: _load, child: Text(context.l10n.commonRetry)),
|
TextButton(onPressed: _load, child: Text(context.l10n.commonRetry)),
|
||||||
] else if (_loaded && _items.isEmpty && _cursor == null)
|
] else if (_loaded && _items.isEmpty && _cursor == null)
|
||||||
Text(_personal
|
Text(
|
||||||
|
_personal
|
||||||
? context.l10n.historyMineEmpty
|
? context.l10n.historyMineEmpty
|
||||||
: context.l10n.historyFandomEmpty),
|
: context.l10n.historyFandomEmpty,
|
||||||
|
),
|
||||||
if (!_loading && !_failed && _cursor != null)
|
if (!_loading && !_failed && _cursor != null)
|
||||||
TextButton(onPressed: _load, child: Text(context.l10n.historyMore)),
|
TextButton(onPressed: _load, child: Text(context.l10n.historyMore)),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:cloud_firestore/cloud_firestore.dart';
|
|||||||
import 'package:firebase_storage/firebase_storage.dart';
|
import 'package:firebase_storage/firebase_storage.dart';
|
||||||
|
|
||||||
import '../../core/data/backend.dart';
|
import '../../core/data/backend.dart';
|
||||||
|
import '../campaign/campaign_data.dart' show ContributorRank;
|
||||||
import '../my/my_data.dart' show PickedPhoto, PhotoRejected;
|
import '../my/my_data.dart' show PickedPhoto, PhotoRejected;
|
||||||
import '../my/my_repository_firestore.dart';
|
import '../my/my_repository_firestore.dart';
|
||||||
import 'moderation.dart' show reportReasonCodes;
|
import 'moderation.dart' show reportReasonCodes;
|
||||||
@@ -32,10 +33,14 @@ class FirestorePlanetRepository implements PlanetRepository {
|
|||||||
final String fandomId;
|
final String fandomId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<PlanetHistoryPage> history({String? cursor, bool personal = false}) async {
|
Future<PlanetHistoryPage> history({
|
||||||
|
String? cursor,
|
||||||
|
bool personal = false,
|
||||||
|
}) async {
|
||||||
_backend.requireUid;
|
_backend.requireUid;
|
||||||
final result = await _backend.call('fnPlanetHistory', {
|
final result = await _backend.call('fnPlanetHistory', {
|
||||||
'fandomId': fandomId, if (cursor != null) 'cursor': cursor,
|
'fandomId': fandomId,
|
||||||
|
if (cursor != null) 'cursor': cursor,
|
||||||
if (personal) 'scope': 'personal',
|
if (personal) 'scope': 'personal',
|
||||||
});
|
});
|
||||||
final rows = result['items'];
|
final rows = result['items'];
|
||||||
@@ -56,23 +61,46 @@ class FirestorePlanetRepository implements PlanetRepository {
|
|||||||
'funding' => PlanetHistoryKind.funding,
|
'funding' => PlanetHistoryKind.funding,
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
final date = row['occurredAt'] is String ? DateTime.tryParse(row['occurredAt']) : null;
|
final date = row['occurredAt'] is String
|
||||||
|
? DateTime.tryParse(row['occurredAt'])
|
||||||
|
: null;
|
||||||
final id = row['id'];
|
final id = row['id'];
|
||||||
final from = row['fromStage'];
|
final from = row['fromStage'];
|
||||||
final to = row['toStage'];
|
final to = row['toStage'];
|
||||||
final amount = row['amount'];
|
final amount = row['amount'];
|
||||||
final activity = [PlanetHistoryKind.zap, PlanetHistoryKind.questReward, PlanetHistoryKind.companyAdBonus, PlanetHistoryKind.funding].contains(kind);
|
final activity = [
|
||||||
if (activity != personal || (activity && (amount is! int || amount <= 0)) || kind == null || date == null || id is! String || id.isEmpty ||
|
PlanetHistoryKind.zap,
|
||||||
|
PlanetHistoryKind.questReward,
|
||||||
|
PlanetHistoryKind.companyAdBonus,
|
||||||
|
PlanetHistoryKind.funding,
|
||||||
|
].contains(kind);
|
||||||
|
if (activity != personal ||
|
||||||
|
(activity && (amount is! int || amount <= 0)) ||
|
||||||
|
kind == null ||
|
||||||
|
date == null ||
|
||||||
|
id is! String ||
|
||||||
|
id.isEmpty ||
|
||||||
(kind == PlanetHistoryKind.evolution &&
|
(kind == PlanetHistoryKind.evolution &&
|
||||||
(from is! int || to is! int || from < 0 || to > 6 || to <= from))) {
|
(from is! int ||
|
||||||
|
to is! int ||
|
||||||
|
from < 0 ||
|
||||||
|
to > 6 ||
|
||||||
|
to <= from))) {
|
||||||
throw StateError('역사 기록을 확인할 수 없습니다.');
|
throw StateError('역사 기록을 확인할 수 없습니다.');
|
||||||
}
|
}
|
||||||
items.add(PlanetHistoryEntry(id: id, kind: kind, occurredAt: date,
|
items.add(
|
||||||
|
PlanetHistoryEntry(
|
||||||
|
id: id,
|
||||||
|
kind: kind,
|
||||||
|
occurredAt: date,
|
||||||
amount: activity ? amount as int : null,
|
amount: activity ? amount as int : null,
|
||||||
fromStage: kind == PlanetHistoryKind.evolution ? from as int : null,
|
fromStage: kind == PlanetHistoryKind.evolution ? from as int : null,
|
||||||
toStage: kind == PlanetHistoryKind.evolution ? to as int : null,
|
toStage: kind == PlanetHistoryKind.evolution ? to as int : null,
|
||||||
participationConfirmed: kind == PlanetHistoryKind.evolution && row['participationConfirmed'] == true,
|
participationConfirmed:
|
||||||
));
|
kind == PlanetHistoryKind.evolution &&
|
||||||
|
row['participationConfirmed'] == true,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return PlanetHistoryPage(items: items, nextCursor: next as String?);
|
return PlanetHistoryPage(items: items, nextCursor: next as String?);
|
||||||
}
|
}
|
||||||
@@ -81,7 +109,8 @@ class FirestorePlanetRepository implements PlanetRepository {
|
|||||||
Future<HeroDropReceipt> claimHeroDrop(String dropId) async {
|
Future<HeroDropReceipt> claimHeroDrop(String dropId) async {
|
||||||
_backend.requireUid;
|
_backend.requireUid;
|
||||||
final result = await _backend.call('fnHeroDropClaim', {
|
final result = await _backend.call('fnHeroDropClaim', {
|
||||||
'fandomId': fandomId, 'dropId': dropId,
|
'fandomId': fandomId,
|
||||||
|
'dropId': dropId,
|
||||||
});
|
});
|
||||||
final amount = result['claimedPop'];
|
final amount = result['claimedPop'];
|
||||||
final txId = result['txId'];
|
final txId = result['txId'];
|
||||||
@@ -104,7 +133,8 @@ class FirestorePlanetRepository implements PlanetRepository {
|
|||||||
Future<void> join() => _backend.call('fnFandomJoin', {'fandomId': fandomId});
|
Future<void> join() => _backend.call('fnFandomJoin', {'fandomId': fandomId});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> leave() => _backend.call('fnFandomLeave', {'fandomId': fandomId});
|
Future<void> leave() =>
|
||||||
|
_backend.call('fnFandomLeave', {'fandomId': fandomId});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<PlanetDetail> load({required bool nebula}) async {
|
Future<PlanetDetail> load({required bool nebula}) async {
|
||||||
@@ -137,9 +167,8 @@ class FirestorePlanetRepository implements PlanetRepository {
|
|||||||
// `topPercentByAxis` 는 서버 5 분 집계(`fnRankingCountryAggregate`)가 적는다.
|
// `topPercentByAxis` 는 서버 5 분 집계(`fnRankingCountryAggregate`)가 적는다.
|
||||||
// **값이 없으면 null** — 그 축 기여가 0 이거나 모집단(B-17) 밖이다. 100%(최하위)로
|
// **값이 없으면 null** — 그 축 기여가 0 이거나 모집단(B-17) 밖이다. 100%(최하위)로
|
||||||
// 채우면 없는 값이 사실처럼 보인다.
|
// 채우면 없는 값이 사실처럼 보인다.
|
||||||
int? myTopOf(FpiMetric metric) => myTop[_axisKey(metric)] is num
|
int? myTopOf(FpiMetric metric) =>
|
||||||
? myTop.intOr(_axisKey(metric))
|
myTop[_axisKey(metric)] is num ? myTop.intOr(_axisKey(metric)) : null;
|
||||||
: null;
|
|
||||||
// 스냅샷의 축 맵은 `fpiAxes` 다. 초기 시드가 `axes` 로 쓴 문서가 남아 있어
|
// 스냅샷의 축 맵은 `fpiAxes` 다. 초기 시드가 `axes` 로 쓴 문서가 남아 있어
|
||||||
// 읽는 쪽에서 두 이름을 모두 받는다 — 시드를 다시 넣으면 앞 이름만 남는다.
|
// 읽는 쪽에서 두 이름을 모두 받는다 — 시드를 다시 넣으면 앞 이름만 남는다.
|
||||||
final snapshot = snapshots.isEmpty
|
final snapshot = snapshots.isEmpty
|
||||||
@@ -182,8 +211,9 @@ class FirestorePlanetRepository implements PlanetRepository {
|
|||||||
: axes.intOr(_axisKey(metric)) - daily.intOr(_axisKey(metric)),
|
: axes.intOr(_axisKey(metric)) - daily.intOr(_axisKey(metric)),
|
||||||
// 없으면 null — 화면이 숫자 대신 「—」를 띄운다. 행성 값이 0 이면
|
// 없으면 null — 화면이 숫자 대신 「—」를 띄운다. 행성 값이 0 이면
|
||||||
// 기여를 나눌 모수가 없으니 남아 있는 값(시드 등)도 쓰지 않는다.
|
// 기여를 나눌 모수가 없으니 남아 있는 값(시드 등)도 쓰지 않는다.
|
||||||
myTopPercent:
|
myTopPercent: axes.intOr(_axisKey(metric)) > 0
|
||||||
axes.intOr(_axisKey(metric)) > 0 ? myTopOf(metric) : null,
|
? myTopOf(metric)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
posts: posts,
|
posts: posts,
|
||||||
@@ -276,8 +306,10 @@ class FirestorePlanetRepository implements PlanetRepository {
|
|||||||
final axis = _axisKey(metric);
|
final axis = _axisKey(metric);
|
||||||
final uid = _backend.requireUid;
|
final uid = _backend.requireUid;
|
||||||
final members = _backend.db.collection('fandoms/$fandomId/members');
|
final members = _backend.db.collection('fandoms/$fandomId/members');
|
||||||
final snap =
|
final snap = await members
|
||||||
await members.orderBy('fpiAxes.$axis', descending: true).limit(limit).get();
|
.orderBy('fpiAxes.$axis', descending: true)
|
||||||
|
.limit(limit)
|
||||||
|
.get();
|
||||||
|
|
||||||
// 순위는 1·1·3 공동 순위(C-19) — 실값 동률만 같은 순위, 동점 내부는 uid 오름차순.
|
// 순위는 1·1·3 공동 순위(C-19) — 실값 동률만 같은 순위, 동점 내부는 uid 오름차순.
|
||||||
final rows = <FpiRankRow>[
|
final rows = <FpiRankRow>[
|
||||||
@@ -286,12 +318,19 @@ class FirestorePlanetRepository implements PlanetRepository {
|
|||||||
(d) => _raw(d.data(), axis),
|
(d) => _raw(d.data(), axis),
|
||||||
(d) => d.id,
|
(d) => d.id,
|
||||||
))
|
))
|
||||||
_userRow(doc.data(), id: doc.id, axis: axis, fallbackRank: rank, uid: uid),
|
_userRow(
|
||||||
|
doc.data(),
|
||||||
|
id: doc.id,
|
||||||
|
axis: axis,
|
||||||
|
fallbackRank: rank,
|
||||||
|
uid: uid,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
// 한 줄도 직전 순위를 갖고 있지 않으면 동향은 전부 0 이다. 그 사실을 배지로
|
// 한 줄도 직전 순위를 갖고 있지 않으면 동향은 전부 0 이다. 그 사실을 배지로
|
||||||
// 알린다 — 「▲▼ 가 없다」와 「아직 집계 전이다」는 다른 이야기다.
|
// 알린다 — 「▲▼ 가 없다」와 「아직 집계 전이다」는 다른 이야기다.
|
||||||
final note = snap.docs.any((d) => _map(d.data(), 'rankPrevByAxis')[axis] is num)
|
final note =
|
||||||
|
snap.docs.any((d) => _map(d.data(), 'rankPrevByAxis')[axis] is num)
|
||||||
? null
|
? null
|
||||||
: '사용자 순위 동향은 아직 집계 전입니다';
|
: '사용자 순위 동향은 아직 집계 전입니다';
|
||||||
|
|
||||||
@@ -318,8 +357,14 @@ class FirestorePlanetRepository implements PlanetRepository {
|
|||||||
return FpiRanking(
|
return FpiRanking(
|
||||||
rows: rows,
|
rows: rows,
|
||||||
mockNote: note,
|
mockNote: note,
|
||||||
mine: _userRow(me, id: uid, axis: axis, fallbackRank: fallback, uid: uid,
|
mine: _userRow(
|
||||||
name: appL10n.commonMe),
|
me,
|
||||||
|
id: uid,
|
||||||
|
axis: axis,
|
||||||
|
fallbackRank: fallback,
|
||||||
|
uid: uid,
|
||||||
|
name: appL10n.commonMe,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,6 +391,30 @@ class FirestorePlanetRepository implements PlanetRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ContributorRank>> contributors({int limit = 3}) async {
|
||||||
|
final uid = _backend.requireUid;
|
||||||
|
final snap = await _backend.db
|
||||||
|
.collection('fandoms/$fandomId/members')
|
||||||
|
.orderBy('contributedPop', descending: true)
|
||||||
|
.limit(limit)
|
||||||
|
.get();
|
||||||
|
return [
|
||||||
|
for (final (doc, rank) in rankWithTies(
|
||||||
|
snap.docs.where((d) => d.data().intOr('contributedPop') > 0).toList(),
|
||||||
|
(d) => d.data().intOr('contributedPop'),
|
||||||
|
(d) => d.id,
|
||||||
|
))
|
||||||
|
ContributorRank(
|
||||||
|
rank: rank,
|
||||||
|
// UID·이메일을 이름 자리에 내보내지 않는다(G-07).
|
||||||
|
name: publicName(doc.data()['nickname'], null),
|
||||||
|
pop: doc.data().intOr('contributedPop'),
|
||||||
|
isMe: doc.id == uid,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<FpiRanking> countryRanking({
|
Future<FpiRanking> countryRanking({
|
||||||
required FpiMetric metric,
|
required FpiMetric metric,
|
||||||
@@ -353,18 +422,19 @@ class FirestorePlanetRepository implements PlanetRepository {
|
|||||||
}) async {
|
}) async {
|
||||||
// 설계서 §10 「랭킹 사전 집계 — 랭킹 화면은 이 문서 1건만 읽는다」.
|
// 설계서 §10 「랭킹 사전 집계 — 랭킹 화면은 이 문서 1건만 읽는다」.
|
||||||
// 국가는 사용자 프로필 `country`, 없으면 `ZZ`(미지정) 버킷이다.
|
// 국가는 사용자 프로필 `country`, 없으면 `ZZ`(미지정) 버킷이다.
|
||||||
final doc = await _backend.db.doc('rankings/country-${_axisKey(metric)}').get();
|
final doc = await _backend.db
|
||||||
|
.doc('rankings/country-${_axisKey(metric)}')
|
||||||
|
.get();
|
||||||
final rows = (doc.data() ?? const <String, dynamic>{}).mapsOr('rows');
|
final rows = (doc.data() ?? const <String, dynamic>{}).mapsOr('rows');
|
||||||
if (rows.isEmpty) {
|
if (rows.isEmpty) {
|
||||||
return const FpiRanking(
|
return const FpiRanking(rows: [], mockNote: '국가별 집계가 아직 없습니다');
|
||||||
rows: [],
|
|
||||||
mockNote: '국가별 집계가 아직 없습니다',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
// 내 국가는 멤버 문서에 비정규화된 `countryCode` 다(집계가 세는 필드와 같다).
|
// 내 국가는 멤버 문서에 비정규화된 `countryCode` 다(집계가 세는 필드와 같다).
|
||||||
// 「나의 랭킹 영역」(546:14713)은 국가별 필터에서도 떠야 하므로 내 국가 행을
|
// 「나의 랭킹 영역」(546:14713)은 국가별 필터에서도 떠야 하므로 내 국가 행을
|
||||||
// 찾아 올린다.
|
// 찾아 올린다.
|
||||||
final me = (await _backend.db.doc('fandoms/$fandomId/members/${_backend.requireUid}')
|
final me =
|
||||||
|
(await _backend.db
|
||||||
|
.doc('fandoms/$fandomId/members/${_backend.requireUid}')
|
||||||
.get())
|
.get())
|
||||||
.data();
|
.data();
|
||||||
final myCode = (me ?? const <String, dynamic>{}).strOr('countryCode', 'ZZ');
|
final myCode = (me ?? const <String, dynamic>{}).strOr('countryCode', 'ZZ');
|
||||||
@@ -372,7 +442,8 @@ class FirestorePlanetRepository implements PlanetRepository {
|
|||||||
final list = <FpiRankRow>[];
|
final list = <FpiRankRow>[];
|
||||||
for (final (i, row) in rows.take(limit).indexed) {
|
for (final (i, row) in rows.take(limit).indexed) {
|
||||||
final code = row.strOr('code', 'ZZ');
|
final code = row.strOr('code', 'ZZ');
|
||||||
list.add(FpiRankRow(
|
list.add(
|
||||||
|
FpiRankRow(
|
||||||
id: code,
|
id: code,
|
||||||
rank: row.intOr('rank', i + 1),
|
rank: row.intOr('rank', i + 1),
|
||||||
// 표시명은 코드에서 만든다 — 서버는 코드만 안다([countryName]).
|
// 표시명은 코드에서 만든다 — 서버는 코드만 안다([countryName]).
|
||||||
@@ -380,7 +451,8 @@ class FirestorePlanetRepository implements PlanetRepository {
|
|||||||
value: row.intOr('value'),
|
value: row.intOr('value'),
|
||||||
change: row.intOr('change'),
|
change: row.intOr('change'),
|
||||||
isMine: code == myCode,
|
isMine: code == myCode,
|
||||||
));
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return FpiRanking(
|
return FpiRanking(
|
||||||
rows: list,
|
rows: list,
|
||||||
@@ -432,13 +504,16 @@ class FirestorePlanetRepository implements PlanetRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static Map<String, dynamic> _map(Map<String, dynamic> data, String key) =>
|
static Map<String, dynamic> _map(Map<String, dynamic> data, String key) =>
|
||||||
Map<String, dynamic>.from((data[key] as Map?) ?? const <String, dynamic>{});
|
Map<String, dynamic>.from(
|
||||||
|
(data[key] as Map?) ?? const <String, dynamic>{},
|
||||||
|
);
|
||||||
|
|
||||||
/// 내가 차단한 UID. 저장소 하나가 행성 상세 하나와 수명이 같아 한 번만 읽는다.
|
/// 내가 차단한 UID. 저장소 하나가 행성 상세 하나와 수명이 같아 한 번만 읽는다.
|
||||||
Set<String>? _blocked;
|
Set<String>? _blocked;
|
||||||
|
|
||||||
Future<Set<String>> _blockedUids() async => _blocked ??= {
|
Future<Set<String>> _blockedUids() async => _blocked ??= {
|
||||||
for (final d in (await _backend.db
|
for (final d
|
||||||
|
in (await _backend.db
|
||||||
.collection('users/${_backend.requireUid}/blocks')
|
.collection('users/${_backend.requireUid}/blocks')
|
||||||
.get())
|
.get())
|
||||||
.docs)
|
.docs)
|
||||||
|
|||||||
@@ -217,7 +217,9 @@ class PlanetDetailHero extends StatelessWidget {
|
|||||||
Text('|', style: _body(14, info)),
|
Text('|', style: _body(14, info)),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
context.l10n.planetPerHead(perHead.toStringAsFixed(2)),
|
context.l10n.planetPerHead(
|
||||||
|
perHead.toStringAsFixed(2),
|
||||||
|
),
|
||||||
style: _body(14, info),
|
style: _body(14, info),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -239,7 +241,9 @@ class PlanetDetailHero extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
joined ? context.l10n.planetJoined : context.l10n.planetJoin,
|
joined
|
||||||
|
? context.l10n.planetJoined
|
||||||
|
: context.l10n.planetJoin,
|
||||||
style: _body(
|
style: _body(
|
||||||
14,
|
14,
|
||||||
const Color(0xFFD5D7D9),
|
const Color(0xFFD5D7D9),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:fancake/features/planet/fpi_ranking_screen.dart';
|
|||||||
import 'package:fancake/features/planet/planet_data.dart';
|
import 'package:fancake/features/planet/planet_data.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:fancake/features/campaign/campaign_data.dart' show ContributorRank;
|
||||||
|
|
||||||
import 'package:fancake/core/theme/app_theme.dart';
|
import 'package:fancake/core/theme/app_theme.dart';
|
||||||
import 'package:fancake/features/planet/lounge_post_screens.dart';
|
import 'package:fancake/features/planet/lounge_post_screens.dart';
|
||||||
@@ -92,6 +93,15 @@ class _NoTopPercentRepository extends SamplePlanetRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 가입 전 상태로 시작하고, [join] 이 불리면 가입중으로 바뀌는 표본.
|
/// 가입 전 상태로 시작하고, [join] 이 불리면 가입중으로 바뀌는 표본.
|
||||||
|
class _Contributed extends SamplePlanetRepository {
|
||||||
|
const _Contributed();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<ContributorRank>> contributors({int limit = 3}) async => const [
|
||||||
|
ContributorRank(rank: 1, name: '별빛수집가', pop: 990),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
class _Joinable extends SamplePlanetRepository {
|
class _Joinable extends SamplePlanetRepository {
|
||||||
_Joinable();
|
_Joinable();
|
||||||
int joins = 0;
|
int joins = 0;
|
||||||
@@ -381,6 +391,17 @@ void main() {
|
|||||||
));
|
));
|
||||||
expect(find.text('New_BTS'), findsOneWidget);
|
expect(find.text('New_BTS'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('기여자가 있으면 실시간 기여 랭킹에 단상이 그려진다(10/5 실기기)', (tester) async {
|
||||||
|
await _pump(tester, const PlanetDetailScreen(
|
||||||
|
nebula: true,
|
||||||
|
repository: _Contributed(),
|
||||||
|
eclipse: NoEclipseRepository(),
|
||||||
|
));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('별빛수집가', skipOffstage: false), findsOneWidget);
|
||||||
|
expect(find.text('아직 기여자가 없어요', skipOffstage: false), findsNothing);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('상대 시각 표기', (tester) async {
|
testWidgets('상대 시각 표기', (tester) async {
|
||||||
|
|||||||
Reference in New Issue
Block a user