HOME · GALAXY · 행성·성운 상세 · 팬덤 라운지 · 이클립스 화면
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
/// 이클립스 방어·침식·라이브 소셜 프루프가 화면에 닿는 형태.
|
||||
///
|
||||
/// **방어는 팬덤 공동 Shake 참여이며 POP 을 소모하지 않는다.**
|
||||
/// 근거 — 정책 원장 **B-55** 비고 · 정책설계서 v1.1 255~264행 ·
|
||||
/// Firestore 설계서 v1.1 §9-#5: *방어는 Shake 참여로 이루어지며 1 회당 POP 소모를
|
||||
/// 두지 않습니다. 하루 2 회, 세부 수치는 `config` 운영값*.
|
||||
///
|
||||
/// 별첨 1 ① 시나리오 4 의 「번개 버튼 연타·오토 도네이션」은 별첨 B 가 상위 기준이므로
|
||||
/// 따르지 않는다(공통 기준 2항). GALAXY 의 ZAP 버튼은 이클립스 방어가 아니라
|
||||
/// **행성 성장 기여**다(B-23) — [GalaxyRepository] 가 맡는다.
|
||||
///
|
||||
/// v1.5 §5 확정: 수동 방어는 Shake only · POP 무소모, HOME ZAP은 성장 기여다.
|
||||
///
|
||||
/// 수치는 **전부 서버 산출값**이다. 화면은 침식률을 계산하지 않는다 — 단말마다
|
||||
/// 다른 값을 그리면 「방어했는데 침식률이 안 준다」가 된다.
|
||||
library;
|
||||
|
||||
/// 활성 이클립스 한 건의 현재 상태.
|
||||
enum EclipseResult { success, failure, cancelled }
|
||||
|
||||
class EclipseState {
|
||||
const EclipseState({
|
||||
required this.fandomId,
|
||||
required this.eclipseId,
|
||||
required this.fandomName,
|
||||
required this.erosionRate,
|
||||
required this.defense,
|
||||
required this.targetDefense,
|
||||
required this.defenseCount,
|
||||
required this.defended,
|
||||
this.qualifyShakes = 60,
|
||||
this.endsAt,
|
||||
this.result,
|
||||
this.mockNote,
|
||||
});
|
||||
|
||||
final String fandomId;
|
||||
final String eclipseId;
|
||||
|
||||
/// 배너 문구의 「{행성}이 침식되고 있습니다」 자리.
|
||||
final String fandomName;
|
||||
|
||||
/// 서버가 산출한 침식률 0~100. `fn-eclipse-aggregate` 의 `erosionRate`.
|
||||
final int erosionRate;
|
||||
|
||||
/// 서버가 60회 이상으로 인정한 고유 참여자 수. 샤드 합으로 즉시 반영한다.
|
||||
final int defense;
|
||||
|
||||
/// DAU 평균과 목표율로 서버가 확정한 목표 인원. `eclipseRounds.target`(C-26).
|
||||
final int targetDefense;
|
||||
|
||||
/// 고유 참여자 1명으로 인정하는 개인 유효 Shake 수. 회차 문서의 운영값(C-25 기본 60).
|
||||
final int qualifyShakes;
|
||||
|
||||
/// 방어에 참여한 횟수 합.
|
||||
final int defenseCount;
|
||||
|
||||
/// 목표를 채워 방어에 성공했는가.
|
||||
final bool defended;
|
||||
|
||||
/// 서버 확정 판정. 단말 타이머 만료는 failure가 아니다.
|
||||
final EclipseResult? result;
|
||||
bool get canDefend => result == null && !defended && endsAt != null && !isExpired(DateTime.now());
|
||||
|
||||
/// 방어 마감. 없으면 남은 시간을 띄우지 않는다.
|
||||
final DateTime? endsAt;
|
||||
|
||||
/// 실데이터가 아닌 경우의 사유. 화면이 그대로 배지로 띄운다(대조표 3-5).
|
||||
final String? mockNote;
|
||||
|
||||
bool get isMock => mockNote != null;
|
||||
|
||||
/// 방어 진행률 0~1. 목표가 0 이면 진행률을 말할 수 없다.
|
||||
double get progress =>
|
||||
targetDefense <= 0 ? 0 : (defense / targetDefense).clamp(0.0, 1.0);
|
||||
|
||||
/// 마감이 지났는가. **지난 이클립스는 화면에 띄우지 않는다**(검수 D3) —
|
||||
/// 서버가 `ended` 로 닫기 전이라도 화면이 먼저 접는다.
|
||||
bool isExpired(DateTime now) {
|
||||
final at = endsAt;
|
||||
return at != null && !at.isAfter(now);
|
||||
}
|
||||
|
||||
/// 남은 시간. 마감이 없거나 지났으면 null.
|
||||
Duration? remaining(DateTime now) {
|
||||
final at = endsAt;
|
||||
if (at == null) return null;
|
||||
final left = at.difference(now);
|
||||
return left.isNegative ? null : left;
|
||||
}
|
||||
}
|
||||
|
||||
/// 방어 한 번의 결과. **서버가 확정한 값만 담는다.**
|
||||
class DefendResult {
|
||||
const DefendResult({
|
||||
required this.accepted,
|
||||
required this.clamped,
|
||||
required this.myTotal,
|
||||
required this.duplicate,
|
||||
});
|
||||
|
||||
/// 이번 참여로 집계에 더해진 방어량. 상한에 걸리면 보낸 값보다 작다.
|
||||
final int accepted;
|
||||
|
||||
/// 상한 때문에 깎인 양. 화면이 「참여 상한에 닿았습니다」를 띄우는 근거다.
|
||||
final int clamped;
|
||||
|
||||
/// 이 이벤트에서 내가 지금까지 쌓은 방어량.
|
||||
final int myTotal;
|
||||
|
||||
/// 재시도로 판정되어 아무것도 더하지 않았는가.
|
||||
final bool duplicate;
|
||||
}
|
||||
|
||||
/// 라이브 소셜 프루프 한 줄 — `fandoms/{id}/liveEvents` 한 문서.
|
||||
///
|
||||
/// 별첨 B **「Global Ticker」** 확정에 따라 이 컬렉션은 이클립스 전용이 아니다 —
|
||||
/// *Eclipse·Artist Hot Time·Hero Gift·Campaign·공지를 하나의 Global Ticker 로 통합하고
|
||||
/// 우선순위·클릭이동처를 운영설정으로 관리*. 그래서 [kind] 로 갈라 두었다.
|
||||
class LiveEvent {
|
||||
const LiveEvent({
|
||||
required this.id,
|
||||
required this.nickname,
|
||||
required this.at,
|
||||
this.kind = LiveEventKind.eclipseDefense,
|
||||
this.defense = 0,
|
||||
this.priority = 50,
|
||||
this.linkTo = '',
|
||||
});
|
||||
|
||||
/// 문서 ID. 같은 줄을 두 번 흘리지 않도록 위젯이 키로 쓴다.
|
||||
final String id;
|
||||
|
||||
final String nickname;
|
||||
|
||||
/// 이클립스 방어 참여량. 다른 종류에서는 0 이다.
|
||||
final int defense;
|
||||
|
||||
final DateTime at;
|
||||
final LiveEventKind kind;
|
||||
|
||||
/// 낮을수록 먼저 뜬다. 종류별 값은 운영설정에서 온다.
|
||||
final int priority;
|
||||
|
||||
/// 눌렀을 때 갈 곳. 운영설정 항목이라 화면이 만들지 않는다.
|
||||
final String linkTo;
|
||||
}
|
||||
|
||||
/// 티커 이벤트 종류. 서버의 `kind` 문자열과 1:1 이다.
|
||||
enum LiveEventKind {
|
||||
eclipseDefense('eclipse_defense'),
|
||||
artistHotTime('artist_hot_time'),
|
||||
heroGift('hero_gift'),
|
||||
campaign('campaign'),
|
||||
notice('notice');
|
||||
|
||||
const LiveEventKind(this.wire);
|
||||
|
||||
/// Firestore 에 적힌 값. 이 문자열이 서버와의 계약면이다.
|
||||
final String wire;
|
||||
|
||||
/// 모르는 값은 공지로 읽는다 — 새 종류가 늘어도 티커가 멈추지 않는다.
|
||||
static LiveEventKind of(String value) =>
|
||||
values.where((k) => k.wire == value).firstOrNull ?? LiveEventKind.notice;
|
||||
}
|
||||
|
||||
/// 이클립스가 화면에 닿는 유일한 경로.
|
||||
///
|
||||
/// 읽기는 Firestore 구독, **쓰기(방어)는 Cloud Functions** 다 — 별첨 1 ②
|
||||
/// 「클라이언트 단독 지급 0건」. 방어는 POP 을 쓰지 않지만 집계를 움직이므로
|
||||
/// 클라이언트가 직접 쓰지 못하게 같은 경계를 지킨다.
|
||||
abstract interface class EclipseRepository {
|
||||
/// 활성 이클립스. [fandomId] 를 주지 않으면 내 소속 팬덤을 본다.
|
||||
/// 기본은 활성 회차만, includeResults는 방어 화면에서 마감 후 판정까지 받는다.
|
||||
Stream<EclipseState?> watch({String? fandomId, bool includeResults = false});
|
||||
|
||||
/// 최근 티커 이벤트. 최신순.
|
||||
Stream<List<LiveEvent>> liveEvents({String? fandomId, int limit = 20});
|
||||
|
||||
/// 공동 Shake 참여. [shakes] 는 단말이 감지한 흔들기 수이며
|
||||
/// **서버가 상한과 속도로 다시 판정한다**(별첨 1 ② 서버검증 원칙).
|
||||
Future<DefendResult> defend({
|
||||
required String fandomId,
|
||||
required String eclipseId,
|
||||
required String clientSeq,
|
||||
required int shakes,
|
||||
});
|
||||
}
|
||||
|
||||
/// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.**
|
||||
///
|
||||
/// 활성 이클립스를 하나 띄워 두는 이유는 별첨 2 의 확인 항목 때문이다. 목 모드에서
|
||||
/// 배너가 아예 뜨지 않으면 「이클립스 화면이 없다」로 읽힌다. 대신 `mockNote` 를
|
||||
/// 실어 화면이 표본임을 배지로 밝힌다.
|
||||
class SampleEclipseRepository implements EclipseRepository {
|
||||
SampleEclipseRepository();
|
||||
|
||||
static const _note = '표본 이클립스 — 서버 집계 미연동';
|
||||
|
||||
/// 표본에서도 참여가 쌓이는 것이 보여야 한다. 방어 호출이 이 값을 올린다.
|
||||
int _defense = 5_800;
|
||||
|
||||
EclipseState _snapshot({String? fandomId}) => EclipseState(
|
||||
fandomId: fandomId ?? 'bts',
|
||||
eclipseId: 'sample-eclipse',
|
||||
fandomName: 'BTS',
|
||||
erosionRate: 42,
|
||||
defense: _defense,
|
||||
targetDefense: 10_000,
|
||||
defenseCount: 1_340,
|
||||
defended: false,
|
||||
endsAt: DateTime.now().add(const Duration(minutes: 18)),
|
||||
mockNote: _note,
|
||||
);
|
||||
|
||||
@override
|
||||
Stream<EclipseState?> watch({String? fandomId, bool includeResults = false}) =>
|
||||
Stream.value(_snapshot(fandomId: fandomId));
|
||||
|
||||
@override
|
||||
Stream<List<LiveEvent>> liveEvents({String? fandomId, int limit = 20}) {
|
||||
final now = DateTime.now();
|
||||
return Stream.value([
|
||||
LiveEvent(id: 'e1', nickname: '아미보이', defense: 12, at: now),
|
||||
LiveEvent(
|
||||
id: 'e2',
|
||||
nickname: '버니즈',
|
||||
defense: 8,
|
||||
at: now.subtract(const Duration(seconds: 12)),
|
||||
),
|
||||
].take(limit).toList(growable: false));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DefendResult> defend({
|
||||
required String fandomId,
|
||||
required String eclipseId,
|
||||
required String clientSeq,
|
||||
required int shakes,
|
||||
}) async {
|
||||
_defense += shakes;
|
||||
return DefendResult(
|
||||
accepted: shakes,
|
||||
clamped: 0,
|
||||
myTotal: shakes,
|
||||
duplicate: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'dart:ui' show ImageFilter;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sensors_plus/sensors_plus.dart';
|
||||
|
||||
import '../../core/data/backend.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/app_icons.dart';
|
||||
import '../../core/ui/live_stream.dart';
|
||||
import '../home/home_screen.dart' show formatPop;
|
||||
import 'eclipse_data.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 「흔들어 방어」 — 팬덤 공동 Shake 참여 화면.
|
||||
///
|
||||
/// 근거 — 정책 원장 **B-55** *하루 2 회 **공동 Shake 방어 이벤트*** ·
|
||||
/// Firestore 설계서 v1.1 §9-#5 *방어는 Shake 참여로 이루어지며 **1 회당 POP 소모를
|
||||
/// 두지 않는다***. 그래서 이 화면에는 잔액도 차감 표기도 없다.
|
||||
///
|
||||
/// **흔들기 판정을 화면이 최종적으로 하지 않는다.** 단말이 센 횟수는 서버가 상한과
|
||||
/// 속도로 다시 판정하며(별첨 1 ② 서버검증 원칙), 화면은 서버가 받아들인 값
|
||||
/// ([DefendResult.accepted])만 자기 기여로 표시한다. 단말 값을 그대로 믿고 그리면
|
||||
/// 서버가 깎은 뒤에 숫자가 줄어드는 것으로 보인다.
|
||||
///
|
||||
/// `quest/verify.ts` 의 `gradeShake` 는 재사용하지 않았다. 그쪽은 **구간별 박자
|
||||
/// 정확도**(perfect·good·miss)를 매기는 Music Shake 판정이고, 이클립스 방어는
|
||||
/// 박자가 없는 자유 흔들기 누적이라 판정 모델이 다르다.
|
||||
class EclipseDefendScreen extends StatefulWidget {
|
||||
const EclipseDefendScreen({
|
||||
super.key,
|
||||
required this.repository,
|
||||
required this.fandomId,
|
||||
});
|
||||
|
||||
final EclipseRepository repository;
|
||||
|
||||
/// 어느 팬덤의 이클립스인가. 화면은 구독으로 현재 상태를 따라간다.
|
||||
final String fandomId;
|
||||
|
||||
@override
|
||||
State<EclipseDefendScreen> createState() => _EclipseDefendScreenState();
|
||||
}
|
||||
|
||||
class _EclipseDefendScreenState extends State<EclipseDefendScreen>
|
||||
with WidgetsBindingObserver {
|
||||
bool _foreground = true;
|
||||
String? _roundId;
|
||||
StreamSubscription<AccelerometerEvent>? _accel;
|
||||
|
||||
/// 아직 서버에 보내지 않은 흔들기 수.
|
||||
int _pending = 0;
|
||||
|
||||
/// 서버가 받아들인 내 누적 참여량.
|
||||
int _mine = 0;
|
||||
|
||||
/// 상한에 걸려 깎인 적이 있는가. 한 번 알리고 다시 알리지 않는다.
|
||||
bool _cappedNotified = false;
|
||||
|
||||
Timer? _burst;
|
||||
bool _sending = false;
|
||||
({String fandomId, String eclipseId, String seq, int shakes})? _batch;
|
||||
|
||||
/// 흔들기로 볼 가속도 크기(m/s²). 중력(9.8)을 넘는 순간만 센다.
|
||||
static const _shakeThreshold = 18.0;
|
||||
|
||||
/// 한 번의 흔들기로 세는 최소 간격. 이보다 촘촘한 값은 같은 동작의 진동이다.
|
||||
static const _shakeDebounce = Duration(milliseconds: 220);
|
||||
|
||||
/// 묶어 보내는 시간창. 흔들 때마다 부르면 호출이 초당 여러 번 나간다.
|
||||
static const _burstWindow = Duration(milliseconds: 700);
|
||||
|
||||
DateTime _lastShake = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_accel = accelerometerEventStream().listen(
|
||||
_onAccel,
|
||||
onError: (_) {
|
||||
// 센서가 없는 단말(에뮬레이터·일부 태블릿)이다. 화면은 그대로 두고
|
||||
// 아래의 「직접 누르기」로 참여할 수 있게 한다.
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_burst?.cancel();
|
||||
_burst = null;
|
||||
_accel?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
_foreground = state == AppLifecycleState.resumed;
|
||||
if (!_foreground) {
|
||||
_burst?.cancel();
|
||||
_burst = null;
|
||||
_pending = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void _onAccel(AccelerometerEvent event) {
|
||||
final magnitude = math.sqrt(
|
||||
event.x * event.x + event.y * event.y + event.z * event.z,
|
||||
);
|
||||
if (!magnitude.isFinite || magnitude < _shakeThreshold) return;
|
||||
final now = DateTime.now();
|
||||
if (now.difference(_lastShake) < _shakeDebounce) return;
|
||||
_lastShake = now;
|
||||
_shake();
|
||||
}
|
||||
|
||||
void _shake() {
|
||||
if (!_foreground ||
|
||||
ModalRoute.of(context)?.isCurrent == false ||
|
||||
_current?.canDefend != true ||
|
||||
(_batch != null && !_sending))
|
||||
return;
|
||||
setState(() => _pending += 1);
|
||||
_scheduleFlush();
|
||||
}
|
||||
|
||||
void _scheduleFlush() {
|
||||
_burst ??= Timer(_burstWindow, () {
|
||||
_burst = null;
|
||||
_flush();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _flush({bool retry = false}) async {
|
||||
if (_sending || !_foreground || ModalRoute.of(context)?.isCurrent == false)
|
||||
return;
|
||||
if (_batch != null && !retry) return;
|
||||
if (_batch == null) {
|
||||
final state = _current;
|
||||
if (_pending == 0 || state == null || !state.canDefend) return;
|
||||
_batch = (
|
||||
fandomId: state.fandomId,
|
||||
eclipseId: state.eclipseId,
|
||||
seq: 'shake${DateTime.now().microsecondsSinceEpoch}',
|
||||
shakes: _pending,
|
||||
);
|
||||
_pending = 0;
|
||||
}
|
||||
final batch = _batch!;
|
||||
setState(() => _sending = true);
|
||||
try {
|
||||
final result = await widget.repository.defend(
|
||||
fandomId: batch.fandomId,
|
||||
eclipseId: batch.eclipseId,
|
||||
clientSeq: batch.seq,
|
||||
shakes: batch.shakes,
|
||||
);
|
||||
if (!mounted || _batch != batch) return;
|
||||
_batch = null;
|
||||
if (_current?.eclipseId == batch.eclipseId) {
|
||||
setState(() => _mine = math.max(_mine, result.myTotal));
|
||||
if (result.clamped > 0 && !_cappedNotified) {
|
||||
_cappedNotified = true;
|
||||
_notify(context.l10n.eclipseCapped);
|
||||
}
|
||||
}
|
||||
} on BackendException catch (error) {
|
||||
if (!mounted || _batch != batch) return;
|
||||
if (['E-ECL-01', 'E-AUTH-03', 'E-PERM-01'].contains(error.code))
|
||||
_batch = null;
|
||||
_notify(_failure(error));
|
||||
} catch (_) {
|
||||
if (!mounted || _batch != batch) return;
|
||||
_notify(context.l10n.eclipseResultUnknown);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _sending = false);
|
||||
if (_batch == null &&
|
||||
_pending > 0 &&
|
||||
_foreground &&
|
||||
_current?.canDefend == true)
|
||||
_scheduleFlush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _retryButton() => TextButton(
|
||||
onPressed: _sending ? null : () => _flush(retry: true),
|
||||
child: Text(
|
||||
_sending ? context.l10n.eclipseChecking : context.l10n.eclipseRetrySame,
|
||||
),
|
||||
);
|
||||
|
||||
/// 서버 오류 코드를 문구로 옮긴다. 코드로 분기하고 메시지로 분기하지 않는다.
|
||||
String _failure(BackendException error) => switch (error.code) {
|
||||
'E-ECL-01' => context.l10n.eclipseEnded,
|
||||
'E-QST-02' => context.l10n.eclipseTooFast,
|
||||
'E-AUTH-03' => context.l10n.authLoginRequired,
|
||||
_ => context.l10n.eclipseResultUnknown,
|
||||
};
|
||||
|
||||
void _notify(String message) {
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
}
|
||||
|
||||
/// 마지막으로 받은 이클립스 상태. 방어 호출이 대상 ID 를 여기서 얻는다.
|
||||
EclipseState? _current;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
body: SafeArea(
|
||||
child: LiveStreamBuilder<EclipseState?>(
|
||||
key: ValueKey('defend-${widget.fandomId}'),
|
||||
create: () => widget.repository.watch(
|
||||
fandomId: widget.fandomId,
|
||||
includeResults: true,
|
||||
),
|
||||
builder: (context, state) {
|
||||
_current = state;
|
||||
if (state != null && _roundId != state.eclipseId) {
|
||||
_roundId = state.eclipseId;
|
||||
_mine = 0;
|
||||
_batch = null;
|
||||
_pending = 0;
|
||||
_cappedNotified = false;
|
||||
_burst?.cancel();
|
||||
_burst = null;
|
||||
}
|
||||
if (state?.canDefend != true) {
|
||||
_pending = 0;
|
||||
_burst?.cancel();
|
||||
_burst = null;
|
||||
}
|
||||
if (state == null) {
|
||||
return Column(
|
||||
children: [
|
||||
const _CloseBar(),
|
||||
Expanded(child: Center(child: Text(context.l10n.eclipseNone))),
|
||||
],
|
||||
);
|
||||
}
|
||||
final result = state.result;
|
||||
if (state.defended || result == EclipseResult.success) {
|
||||
// 사본 `홈/이클립스 성공`(`373:537406`).
|
||||
return _Outcome(
|
||||
title: context.l10n.eclipseSuccessTitle,
|
||||
success: true,
|
||||
extra: [
|
||||
if (_mine >= state.qualifyShakes)
|
||||
_Note(context.l10n.eclipseQualified),
|
||||
if (_batch != null) _retryButton(),
|
||||
],
|
||||
);
|
||||
}
|
||||
if (result == EclipseResult.failure ||
|
||||
result == EclipseResult.cancelled ||
|
||||
state.isExpired(DateTime.now())) {
|
||||
// 실패는 사본 변형 `494:14292`. 취소·판정 대기는 사본에 없어 같은 틀에 정책 문구를 얹는다.
|
||||
final (title, message) = switch (result) {
|
||||
EclipseResult.failure => (
|
||||
context.l10n.eclipseFailTitle,
|
||||
context.l10n.eclipseFailBody,
|
||||
),
|
||||
EclipseResult.cancelled => (
|
||||
context.l10n.eclipseCancelTitle,
|
||||
context.l10n.eclipseCancelBody,
|
||||
),
|
||||
_ => (
|
||||
context.l10n.eclipsePendingTitle,
|
||||
context.l10n.eclipsePendingBody,
|
||||
),
|
||||
};
|
||||
return _Outcome(
|
||||
title: title,
|
||||
success: false,
|
||||
// 판정 대기 중에는 닫을 결과가 아직 없다.
|
||||
confirm: result != null,
|
||||
extra: [
|
||||
_Note(message),
|
||||
if (_mine >= state.qualifyShakes)
|
||||
_Note(context.l10n.eclipseQualified),
|
||||
if (_batch != null) _retryButton(),
|
||||
],
|
||||
);
|
||||
}
|
||||
// 사본 `홈/이클립스 방어`(흔들기 전) → `홈/이클립스 상세`(흔드는 중).
|
||||
final started = _mine > 0 || _pending > 0 || _batch != null;
|
||||
return _Countdown(
|
||||
endsAt: state.endsAt,
|
||||
builder: (context, seconds) => Column(
|
||||
children: [
|
||||
const _CloseBar(),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
height: 68,
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
if (!started && seconds != null)
|
||||
TextSpan(
|
||||
text: '$seconds',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Pretendard',
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: !started && seconds != null
|
||||
? context.l10n.eclipseShakeTimed
|
||||
: context.l10n.eclipseShake,
|
||||
),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
style: _titleStyle,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// 짧은 화면에서는 아래 안내·버튼이 밀려나지 않게 행성 자리가 먼저 줄어든다.
|
||||
Flexible(
|
||||
child: SizedBox(
|
||||
height: 400,
|
||||
child: _DefendPlanet(shaking: started, pending: _pending),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
_Note(
|
||||
// 소모가 없다는 것을 화면이 직접 말한다 — 「POP 이 빠지나?」를
|
||||
// 묻게 두지 않는다(설계서 v1.1 §9-#5).
|
||||
'${context.l10n.eclipseMyCount(formatPop(_mine))}'
|
||||
'${_pending > 0
|
||||
? context.l10n.eclipsePendingSuffix('$_pending')
|
||||
: _sending
|
||||
? context.l10n.eclipseSendingSuffix
|
||||
: ''}'
|
||||
'${_mine >= state.qualifyShakes ? ' · ${context.l10n.eclipseQualified}' : ''}\n'
|
||||
'${context.l10n.eclipseNoPopNote('${state.qualifyShakes}')}',
|
||||
),
|
||||
if (_batch != null) _retryButton(),
|
||||
// 센서가 없는 단말에서도 참여할 수 있게 둔다. 판정은 서버가 하므로
|
||||
// 이 버튼이 흔들기보다 유리하지 않다(상한·속도 제한이 같다). 사본에 없는
|
||||
// 보조 입력이라 채움 없는 낮은 강조로 둔다.
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpace.s4,
|
||||
AppSpace.s2,
|
||||
AppSpace.s4,
|
||||
AppSpace.s4,
|
||||
),
|
||||
child: FilledButton(
|
||||
onPressed: state.canDefend && (_batch == null || _sending)
|
||||
? _shake
|
||||
: null,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.cosmicSurfaceContainer,
|
||||
foregroundColor: AppColors.onCosmicVariant,
|
||||
minimumSize: const Size.fromHeight(AppSizing.touchMinApp),
|
||||
),
|
||||
child: Text(context.l10n.eclipseTapToJoin),
|
||||
),
|
||||
),
|
||||
// 흔들기 전(`373:445267`)은 남은 시간을 제목의 「N초간」으로 보여 준다.
|
||||
// 흔드는 중(`373:444865`)은 맨 아래 `Frame 1984081868`(72 높이) 가운데.
|
||||
if (started && seconds != null)
|
||||
SizedBox(
|
||||
height: 72,
|
||||
child: Center(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
text: context.l10n.eclipseTimeLeft,
|
||||
children: [
|
||||
TextSpan(
|
||||
text: context.l10n.eclipseSeconds('$seconds'),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
color: AppColors.onCosmicMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 사본 제목 — ABeeZee 24 · 행간 140% · 흰색 · 가운데.
|
||||
const _titleStyle = TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.4,
|
||||
color: AppColors.onCosmic,
|
||||
);
|
||||
|
||||
/// 사본 상단 `wrapper`(56) — 왼쪽 16 에 닫기 X(24).
|
||||
class _CloseBar extends StatelessWidget {
|
||||
const _CloseBar();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SizedBox(
|
||||
height: 56,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: IconButton(
|
||||
constraints: const BoxConstraints.tightFor(width: 56, height: 56),
|
||||
icon: const ImageIcon(AppIcons.x, size: 24, color: AppColors.onCosmic),
|
||||
tooltip: context.l10n.commonClose,
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 사본에 없는 정책 안내 한 단락 — 12 · #767676 · 가운데.
|
||||
class _Note extends StatelessWidget {
|
||||
const _Note(this.text);
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
|
||||
child: Text(
|
||||
text,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.4,
|
||||
color: AppColors.onCosmicMuted,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 초 단위로 다시 그린다. 마감은 서버 값이라 재진입해도 처음부터 세지 않는다.
|
||||
class _Countdown extends StatefulWidget {
|
||||
const _Countdown({required this.endsAt, required this.builder});
|
||||
|
||||
final DateTime? endsAt;
|
||||
final Widget Function(BuildContext context, int? seconds) builder;
|
||||
|
||||
@override
|
||||
State<_Countdown> createState() => _CountdownState();
|
||||
}
|
||||
|
||||
class _CountdownState extends State<_Countdown> {
|
||||
late final Timer _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final at = widget.endsAt;
|
||||
final ms = at?.difference(DateTime.now()).inMilliseconds;
|
||||
return widget.builder(
|
||||
context,
|
||||
ms == null || ms <= 0 ? null : (ms / 1000).ceil(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 행성 + 운석 — 사본 `Frame 1984081426`(355×400).
|
||||
///
|
||||
/// 흔들기 전은 검정 30% 원(280) 안의 행성(240)과 운석 셋, 흔드는 중은 행성 양옆에
|
||||
/// 흐린 사본을 겹친 「가려지는」 모습(`373:444966` 등, 흐림 4)이다.
|
||||
class _DefendPlanet extends StatelessWidget {
|
||||
const _DefendPlanet({required this.shaking, required this.pending});
|
||||
|
||||
final bool shaking;
|
||||
final int pending;
|
||||
|
||||
static const _planet = AssetImage('assets/figma/planet-home.png');
|
||||
|
||||
// 사본 좌표(프레임 원점 x10 · y220 기준).
|
||||
static Widget _at(double x, double y, double w, double h, Widget child) =>
|
||||
Positioned(left: x - 10, top: y - 220, width: w, height: h, child: child);
|
||||
|
||||
static Widget _shadow(double x, double y, double size, {double blur = 0}) =>
|
||||
_at(
|
||||
x,
|
||||
y,
|
||||
size,
|
||||
size,
|
||||
ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
||||
child: const ColorFiltered(
|
||||
colorFilter: ColorFilter.mode(Color(0xB3141414), BlendMode.srcATop),
|
||||
child: Image(image: _planet),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Center(
|
||||
child: SizedBox(
|
||||
width: 355,
|
||||
height: 400,
|
||||
child: AnimatedScale(
|
||||
scale: pending > 0 ? 1.04 : 1,
|
||||
duration: const Duration(milliseconds: 140),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: shaking
|
||||
? [
|
||||
_shadow(156, 348, 192, blur: 2),
|
||||
_shadow(27, 347, 192, blur: 2),
|
||||
_shadow(47, 320, 220),
|
||||
_shadow(108, 320, 220),
|
||||
_at(67.5, 300, 240, 240, const Image(image: _planet)),
|
||||
]
|
||||
: [
|
||||
_at(
|
||||
48,
|
||||
280,
|
||||
280,
|
||||
280,
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Color(0x4D000000),
|
||||
),
|
||||
),
|
||||
),
|
||||
_at(67.5, 300, 240, 240, const Image(image: _planet)),
|
||||
// 좌상단 운석(`Group 573`)은 내보낸 자산이 없어 `Group 575` 를 뒤집어 쓴다.
|
||||
_at(
|
||||
38,
|
||||
246,
|
||||
49,
|
||||
56.5,
|
||||
const RotatedBox(
|
||||
quarterTurns: 2,
|
||||
child: Image(
|
||||
image: AssetImage('assets/figma/meteor-b.png'),
|
||||
),
|
||||
),
|
||||
),
|
||||
_at(
|
||||
328,
|
||||
287,
|
||||
34.1,
|
||||
29.5,
|
||||
const Image(image: AssetImage('assets/figma/meteor-a.png')),
|
||||
),
|
||||
_at(
|
||||
230,
|
||||
580,
|
||||
29.5,
|
||||
34.1,
|
||||
const Image(image: AssetImage('assets/figma/meteor-b.png')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 결과 — 사본 `홈/이클립스 성공`(분홍 원 + 체크) · 실패 변형(회색 원).
|
||||
///
|
||||
/// 원 200 · 안쪽 그림자(흰 30% ↘ · 검정 30% ↖ · 3.3) · 아래 「확인」(343×56 · r8 · 분홍).
|
||||
class _Outcome extends StatelessWidget {
|
||||
const _Outcome({
|
||||
required this.title,
|
||||
required this.success,
|
||||
this.confirm = true,
|
||||
this.extra = const [],
|
||||
});
|
||||
|
||||
final String title;
|
||||
final bool success;
|
||||
final bool confirm;
|
||||
final List<Widget> extra;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
children: [
|
||||
const SizedBox(height: 56),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
height: 68,
|
||||
child: Text(title, textAlign: TextAlign.center, style: _titleStyle),
|
||||
),
|
||||
const SizedBox(height: 104),
|
||||
CustomPaint(
|
||||
size: const Size.square(200),
|
||||
painter: _OutcomeDisc(
|
||||
color: success ? AppColors.cosmicAccent : const Color(0xFFBDBDBD),
|
||||
check: success,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s5),
|
||||
...extra,
|
||||
const Spacer(),
|
||||
if (confirm)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpace.s4,
|
||||
0,
|
||||
AppSpace.s4,
|
||||
AppSpace.s4,
|
||||
),
|
||||
child: FilledButton(
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(56),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.commonConfirm),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 사본 `Group 567` — 200 원 · 안쪽 그림자 두 겹(흰 30% +3.3 · 검정 30% -3.3, 흐림 3.3)
|
||||
/// 과 `Vector` 체크(50×33.3 · 선 16.7 #F8F8F8 · 둥근 끝).
|
||||
class _OutcomeDisc extends CustomPainter {
|
||||
const _OutcomeDisc({required this.color, required this.check});
|
||||
|
||||
final Color color;
|
||||
final bool check;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final circle = Offset.zero & size;
|
||||
canvas.drawOval(circle, Paint()..color = color);
|
||||
canvas.save();
|
||||
canvas.clipPath(Path()..addOval(circle));
|
||||
for (final (shadow, d) in [(0x4DFFFFFF, 3.3), (0x4D000000, -3.3)]) {
|
||||
// 원 바깥을 칠한 뒤 d 만큼 밀어 흐리면 안쪽 가장자리에만 그림자가 남는다.
|
||||
final ring = Path()
|
||||
..fillType = PathFillType.evenOdd
|
||||
..addRect(circle.inflate(20))
|
||||
..addOval(circle.shift(Offset(d, d)));
|
||||
canvas.drawPath(
|
||||
ring,
|
||||
Paint()
|
||||
..color = Color(shadow)
|
||||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3.3 / 2),
|
||||
);
|
||||
}
|
||||
canvas.restore();
|
||||
if (!check) return;
|
||||
canvas.drawPath(
|
||||
Path()
|
||||
..moveTo(74.1, 99.2)
|
||||
..lineTo(90.8, 115.8)
|
||||
..lineTo(124.1, 82.5),
|
||||
Paint()
|
||||
..color = const Color(0xFFF8F8F8)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 16.7
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeJoin = StrokeJoin.round,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_OutcomeDisc old) =>
|
||||
old.color != color || old.check != check;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
|
||||
import '../../core/data/backend.dart';
|
||||
import '../../core/data/shared_stream.dart';
|
||||
import 'eclipse_data.dart';
|
||||
|
||||
/// 이클립스의 실 서버 구현.
|
||||
///
|
||||
/// **요약 문서와 샤드를 함께 구독한다.** `fn-eclipse-aggregate` 는 1 분 주기인데
|
||||
/// (Cloud Scheduler 최소 주기) 별첨 1 ① 시나리오 5 는 다른 단말의 참여가 내 화면에
|
||||
/// 곧 보이기를 요구한다. 그래서 목표·마감 같은 정적 값은 요약 문서에서 읽고,
|
||||
/// **움직이는 값(방어량 · 참여 수)은 샤드 10 개를 직접 합산한다.**
|
||||
///
|
||||
/// 침식률만은 서버가 적은 값을 그대로 쓴다. 화면이 다시 계산하면 단말마다 다른
|
||||
/// 침식률이 뜨고, 그 순간 「방어했는데 수치가 안 맞는다」가 된다.
|
||||
///
|
||||
/// **같은 팬덤·조회 범위의 구독은 공유한다.** HOME 배너·GALAXY 배너·행성 HUD가
|
||||
/// 같은 집계를 보므로, 화면마다 `snapshots()` 를 열면 한 팬덤에 리스너가 화면 수만큼
|
||||
/// 붙는다. [SharedStream]이 같은 원본을 나눠 주고 방어 화면은 마감 결과까지 별도로 받는다.
|
||||
class FirestoreEclipseRepository implements EclipseRepository {
|
||||
FirestoreEclipseRepository(this._backend);
|
||||
|
||||
final Backend _backend;
|
||||
|
||||
/// 내 소속 팬덤. 구독마다 다시 읽으면 화면 수만큼 왕복이 늘어 한 번만 읽는다.
|
||||
Future<String>? _myFandomId;
|
||||
|
||||
/// 팬덤별 공유 구독. 키가 빈 문자열이면 「내 팬덤」이다.
|
||||
final _watchers = <String, SharedStream<EclipseState?>>{};
|
||||
final _tickers = <String, SharedStream<List<LiveEvent>>>{};
|
||||
|
||||
Future<String> _fandomId(String? given) {
|
||||
if (given != null && given.isNotEmpty) return Future.value(given);
|
||||
return _myFandomId ??= _backend.db
|
||||
.doc('users/${_backend.requireUid}')
|
||||
.get()
|
||||
.then((snap) => (snap.data() ?? const {}).strOr('fandomId'));
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<EclipseState?> watch({String? fandomId, bool includeResults = false}) =>
|
||||
(_watchers['${fandomId ?? ''}#$includeResults'] ??= SharedStream<EclipseState?>(
|
||||
() => _openWatch(fandomId, includeResults),
|
||||
)).stream;
|
||||
|
||||
@override
|
||||
Stream<List<LiveEvent>> liveEvents({String? fandomId, int limit = 20}) =>
|
||||
(_tickers['${fandomId ?? ''}#$limit'] ??= SharedStream<List<LiveEvent>>(
|
||||
() => _openTicker(fandomId, limit),
|
||||
)).stream;
|
||||
|
||||
Stream<EclipseState?> _openWatch(
|
||||
String? fandomId,
|
||||
bool includeResults,
|
||||
) => Stream.fromFuture(_fandomId(fandomId)).asyncExpand((id) {
|
||||
if (id.isEmpty) return Stream<EclipseState?>.value(null);
|
||||
return Stream<EclipseState?>.multi((out) {
|
||||
StreamSubscription<QuerySnapshot<Map<String, dynamic>>>? shardsSub;
|
||||
Timer? expiry;
|
||||
var generation = 0;
|
||||
final eventsSub = _backend.db
|
||||
.collection('fandoms/$id/eclipseRounds')
|
||||
.where('status', whereIn: includeResults ? ['active', 'ended', 'cancelled'] : ['active'])
|
||||
.orderBy('startAt', descending: true)
|
||||
.limit(1)
|
||||
.snapshots(includeMetadataChanges: true)
|
||||
.where((s) => !s.metadata.isFromCache && !s.metadata.hasPendingWrites)
|
||||
.listen((events) {
|
||||
final current = ++generation;
|
||||
shardsSub?.cancel();
|
||||
expiry?.cancel();
|
||||
if (events.docs.isEmpty) {
|
||||
out.add(null);
|
||||
return;
|
||||
}
|
||||
final doc = events.docs.first;
|
||||
EclipseState? latest;
|
||||
final endsAt = doc.data()['endsAt'];
|
||||
if (endsAt is Timestamp) {
|
||||
final left = endsAt.toDate().difference(DateTime.now());
|
||||
if (left <= Duration.zero && !includeResults) {
|
||||
out.add(null);
|
||||
return;
|
||||
}
|
||||
if (left > Duration.zero) {
|
||||
expiry = Timer(left, () {
|
||||
if (current == generation) out.add(includeResults ? latest : null);
|
||||
});
|
||||
}
|
||||
}
|
||||
// asyncExpand는 무한 샤드 스트림이 끝날 때까지 다음 회차/종료를
|
||||
// 막는다. 최신 요약이 올 때 이전 샤드 구독을 교체한다.
|
||||
shardsSub = doc.reference
|
||||
.collection('shards')
|
||||
.snapshots(includeMetadataChanges: true)
|
||||
.where(
|
||||
(s) =>
|
||||
!s.metadata.isFromCache && !s.metadata.hasPendingWrites,
|
||||
)
|
||||
.listen((shards) {
|
||||
if (current != generation) return;
|
||||
final state = _state(
|
||||
fandomId: id,
|
||||
eclipseId: doc.id,
|
||||
data: doc.data(),
|
||||
shards: shards.docs,
|
||||
);
|
||||
latest = state;
|
||||
out.add(!includeResults && state.isExpired(DateTime.now()) ? null : state);
|
||||
}, onError: out.addError);
|
||||
}, onError: out.addError);
|
||||
out.onCancel = () async {
|
||||
generation++;
|
||||
expiry?.cancel();
|
||||
await eventsSub.cancel();
|
||||
await shardsSub?.cancel();
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
Stream<List<LiveEvent>> _openTicker(String? fandomId, int limit) =>
|
||||
Stream.fromFuture(_fandomId(fandomId)).asyncExpand((id) {
|
||||
if (id.isEmpty) return Stream.value(const <LiveEvent>[]);
|
||||
return _backend.db
|
||||
.collection('fandoms/$id/liveEvents')
|
||||
.orderBy('createdAt', descending: true)
|
||||
.limit(limit)
|
||||
.snapshots()
|
||||
.map(
|
||||
(snap) => [
|
||||
for (final doc in snap.docs) _liveEvent(doc.id, doc.data()),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@override
|
||||
Future<DefendResult> defend({
|
||||
required String fandomId,
|
||||
required String eclipseId,
|
||||
required String clientSeq,
|
||||
required int shakes,
|
||||
}) async {
|
||||
final data = await _backend.call('fnEclipseDefend', {
|
||||
'fandomId': fandomId,
|
||||
'eclipseId': eclipseId,
|
||||
'clientSeq': clientSeq,
|
||||
'shakes': shakes,
|
||||
});
|
||||
return DefendResult(
|
||||
accepted: (data['accepted'] as num?)?.toInt() ?? 0,
|
||||
clamped: (data['clamped'] as num?)?.toInt() ?? 0,
|
||||
myTotal: (data['myTotal'] as num?)?.toInt() ?? 0,
|
||||
duplicate: data['duplicate'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
EclipseState _state({
|
||||
required String fandomId,
|
||||
required String eclipseId,
|
||||
required Map<String, dynamic> data,
|
||||
required List<QueryDocumentSnapshot<Map<String, dynamic>>> shards,
|
||||
}) {
|
||||
// 샤드 합. 샤드가 아직 없으면 요약 문서의 값으로 물러난다 — 첫 참여 전에는
|
||||
// 샤드가 비어 있고, 그때 0 을 그리면 이미 모인 방어가 사라져 보인다.
|
||||
final live = shards.fold<(int, int)>((0, 0), (acc, doc) {
|
||||
final d = doc.data();
|
||||
return (acc.$1 + d.intOr('qualifiedParticipants'), acc.$2 + d.intOr('defenseCount'));
|
||||
});
|
||||
final endsAt = data['endsAt'];
|
||||
return EclipseState(
|
||||
fandomId: fandomId,
|
||||
eclipseId: eclipseId,
|
||||
fandomName: data.strOr('fandomName', fandomId),
|
||||
erosionRate: data.intOr('erosionRate', 100),
|
||||
defense: shards.isEmpty ? data.intOr('qualifiedParticipants') : live.$1,
|
||||
// 원장 C-25·C-26: 인정 횟수 이상인 고유 참여자 수 / DAU 기반 목표.
|
||||
targetDefense: data.intOr('target'),
|
||||
qualifyShakes: data.intOr('qualifyShakes', 60),
|
||||
defenseCount: shards.isEmpty ? data.intOr('defenseCount') : live.$2,
|
||||
defended: data.boolOr('defended') || data['result'] == 'success',
|
||||
result: switch (data['result']) {
|
||||
'success' => EclipseResult.success,
|
||||
'failed' => EclipseResult.failure,
|
||||
'cancelled_error' => EclipseResult.cancelled,
|
||||
_ => null,
|
||||
},
|
||||
endsAt: endsAt is Timestamp ? endsAt.toDate() : null,
|
||||
);
|
||||
}
|
||||
|
||||
LiveEvent _liveEvent(String id, Map<String, dynamic> data) => LiveEvent(
|
||||
id: id,
|
||||
// 서버가 닉네임을 비워 보내는 경우가 없도록 막아 두었으나, 옛 문서가 섞일 수
|
||||
// 있어 화면 쪽에서도 기본값을 둔다.
|
||||
// 저장값 「팬」도 지금 언어의 「팬」으로(M-7).
|
||||
nickname: publicName(data['nickname']),
|
||||
defense: data.intOr('defense'),
|
||||
at: data.dateOr('createdAt', DateTime.now()),
|
||||
kind: LiveEventKind.of(data.strOr('kind')),
|
||||
priority: data.intOr('priority', 50),
|
||||
linkTo: data.strOr('linkTo'),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../../../core/ui/live_stream.dart';
|
||||
import '../../home/home_screen.dart' show formatPop;
|
||||
import '../../planet/fpi_ranking_screen.dart' show MockBadge;
|
||||
import '../eclipse_data.dart';
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 이클립스 인앱 경보 배너 — HOME · GALAXY 상단.
|
||||
///
|
||||
/// 근거: 별첨 1 ① 예외 처리 「**푸시 미수신 단말 — 인앱 워닝 배너 병행**」. 푸시가
|
||||
/// 유일한 통보 수단이면 알림을 끈 사용자와 토큰이 만료된 단말은 이클립스를 모른 채
|
||||
/// 침식을 맞는다. 푸시(FCM) 자체는 다른 작업 범위이고 이 배너는 그와 **병행**한다.
|
||||
///
|
||||
/// 배너는 세 상태를 그린다.
|
||||
/// - 활성 이클립스 없음(또는 마감이 지남) → 아무것도 그리지 않는다(빈 [SizedBox]).
|
||||
/// - 침식 진행 중 → 침식률 · 방어 진행률 · 남은 시간.
|
||||
/// - 방어 성공(`defended`) → 회복 문구(원장 B-55 「방어 성공 시 침식분 회복」).
|
||||
///
|
||||
/// 방어는 **공동 Shake 참여**이며 POP 을 소모하지 않는다. 눌러 들어가는 곳은
|
||||
/// 「흔들어 방어」 화면이다.
|
||||
class EclipseBanner extends StatelessWidget {
|
||||
const EclipseBanner({
|
||||
super.key,
|
||||
required this.repository,
|
||||
this.fandomId,
|
||||
this.onTap,
|
||||
this.margin = const EdgeInsets.fromLTRB(
|
||||
AppSpace.s4,
|
||||
AppSpace.s2,
|
||||
AppSpace.s4,
|
||||
0,
|
||||
),
|
||||
});
|
||||
|
||||
final EclipseRepository repository;
|
||||
|
||||
/// 어느 팬덤의 이클립스인가. 주지 않으면 내 소속 팬덤이다.
|
||||
final String? fandomId;
|
||||
|
||||
final VoidCallback? onTap;
|
||||
final EdgeInsets margin;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => LiveStreamBuilder<EclipseState?>(
|
||||
// 대상 팬덤이 바뀌면 구독을 갈아탄다 — [LiveStreamBuilder] 주석 참고.
|
||||
key: ValueKey('eclipse-${fandomId ?? 'mine'}'),
|
||||
create: () => repository.watch(fandomId: fandomId),
|
||||
builder: (context, state) => state == null
|
||||
// 활성 이클립스가 없으면 자리도 차지하지 않는다. 빈 배너를 남겨 두면
|
||||
// 홈 상단에 설명 없는 여백이 생긴다.
|
||||
? const SizedBox.shrink()
|
||||
: Padding(
|
||||
padding: margin,
|
||||
child: EclipseBannerCard(state: state, onTap: onTap),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 배너 본체. 스트림과 떼어 두어 위젯 테스트가 값을 직접 넣을 수 있게 한다.
|
||||
class EclipseBannerCard extends StatelessWidget {
|
||||
const EclipseBannerCard({super.key, required this.state, this.onTap});
|
||||
|
||||
final EclipseState state;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
final won = state.defended;
|
||||
final color = won ? AppColors.neonEmerald : AppColors.neonMagenta;
|
||||
final left = state.remaining(DateTime.now());
|
||||
|
||||
return Semantics(
|
||||
// 배너는 경보다. 스크린리더가 「컨테이너」로 읽으면 경보가 전달되지 않는다.
|
||||
liveRegion: true,
|
||||
container: true,
|
||||
child: Material(
|
||||
color: AppColors.cosmicSurfaceContainer,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(AppSpace.s3),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
border: Border.all(color: color.withValues(alpha: 0.7)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
won ? Icons.verified_outlined : Icons.warning_amber_rounded,
|
||||
size: 18,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(width: AppSpace.s2),
|
||||
Expanded(
|
||||
child: Text(
|
||||
won
|
||||
? context.l10n.eclipseBannerDefended(state.fandomName)
|
||||
: context.l10n.eclipseBannerActive(
|
||||
state.fandomName,
|
||||
'${state.erosionRate}',
|
||||
),
|
||||
maxLines: 2,
|
||||
style: t.bodyMedium?.copyWith(color: color),
|
||||
),
|
||||
),
|
||||
if (left != null)
|
||||
Text(_left(left), style: t.labelSmall),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpace.s2),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
child: LinearProgressIndicator(
|
||||
value: state.progress,
|
||||
minHeight: 4,
|
||||
backgroundColor: AppColors.cosmicOutline,
|
||||
valueColor: AlwaysStoppedAnimation(color),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s1),
|
||||
Text(
|
||||
// **POP 단위가 아니다** — 방어는 공동 Shake 참여이고 POP 을
|
||||
// 소모하지 않는다(설계서 v1.1 §9-#5).
|
||||
context.l10n.eclipseBannerDefense(
|
||||
formatPop(state.defense),
|
||||
formatPop(state.targetDefense),
|
||||
),
|
||||
style: t.labelSmall,
|
||||
),
|
||||
// 실데이터가 아니면 숨기지 않는다 — 대조표 3-5 · 공통 규칙 (4).
|
||||
if (state.mockNote case final note?)
|
||||
MockBadge(note: note),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 남은 시간 표기. 분 단위까지면 충분하다 — 방어 창은 분 단위 운영값이다.
|
||||
static String _left(Duration d) => d.inHours > 0
|
||||
? appL10n.timeHoursMinutesLeft('${d.inHours}', '${d.inMinutes % 60}')
|
||||
: appL10n.timeMinutesLeft('${d.inMinutes}');
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import '../../home/home_screen.dart' show formatPop;
|
||||
import '../eclipse_data.dart';
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 티커 한 줄의 문구.
|
||||
///
|
||||
/// Figma 홈 상단 라이브 띠의 문장 형식을 그대로 쓴다 —
|
||||
/// *「[LIVE] …님의 아이템 사용으로 버프가 발동되었습니다!」*.
|
||||
/// 이클립스 방어 참여는 같은 형식에 **참여량**을 싣는다. POP 이 아니다 — 방어는
|
||||
/// POP 을 소모하지 않는 공동 Shake 참여다(설계서 v1.1 §9-#5).
|
||||
///
|
||||
/// **문구를 만드는 곳을 한 곳에 둔다.** 홈 티커와 갤럭시 섬광 배너가 각자 문장을
|
||||
/// 조립하면 같은 이벤트가 두 화면에서 다르게 읽힌다.
|
||||
///
|
||||
/// 별첨 B 「Global Ticker」로 Eclipse 외의 종류가 뒤에 들어오므로 종류마다 한 줄씩
|
||||
/// 늘려 간다. 모르는 종류는 공지로 읽어 티커가 멈추지 않게 한다.
|
||||
String liveEventMessage(LiveEvent event) => switch (event.kind) {
|
||||
LiveEventKind.eclipseDefense =>
|
||||
appL10n.liveEclipseDefense(event.nickname, formatPop(event.defense)),
|
||||
LiveEventKind.artistHotTime =>
|
||||
appL10n.liveHotTime(event.nickname),
|
||||
LiveEventKind.heroGift =>
|
||||
appL10n.liveHeroGift(event.nickname),
|
||||
LiveEventKind.campaign =>
|
||||
appL10n.liveCampaign(event.nickname),
|
||||
LiveEventKind.notice => '[LIVE] ${event.nickname}',
|
||||
};
|
||||
Reference in New Issue
Block a user