이클립스 흐름 — 배너 진입 · 이벤트 버튼 · 오버레이 · 방어 진행 표시 · 흔들기 감지 통일
This commit is contained in:
@@ -29,6 +29,8 @@ class EclipseState {
|
||||
required this.defenseCount,
|
||||
required this.defended,
|
||||
this.qualifyShakes = 60,
|
||||
this.startAt,
|
||||
this.scheduled = false,
|
||||
this.endsAt,
|
||||
this.result,
|
||||
this.mockNote,
|
||||
@@ -60,7 +62,21 @@ class EclipseState {
|
||||
|
||||
/// 서버 확정 판정. 단말 타이머 만료는 failure가 아니다.
|
||||
final EclipseResult? result;
|
||||
bool get canDefend => result == null && !defended && endsAt != null && !isExpired(DateTime.now());
|
||||
bool get canDefend => !scheduled && canShake(DateTime.now());
|
||||
|
||||
/// 흔들기를 받을 때인가 — 발생 시각이 지났고 아직 열려 있다. 서버가 회차를 `active` 로
|
||||
/// 바꾸기 전(스케줄러 지연 수 초)의 흔들기는 모아 두었다가 [canDefend] 가 되면 보낸다.
|
||||
bool canShake(DateTime now) =>
|
||||
result == null && !defended && endsAt != null && hasStarted(now) && !isExpired(now);
|
||||
|
||||
/// 발생 시각. 사전 알림(`warnAt`)부터 문서가 있으므로 그 전에는 남은 시간을 센다.
|
||||
/// 없으면 이미 시작한 회차로 본다.
|
||||
final DateTime? startAt;
|
||||
|
||||
/// 서버 회차가 아직 `scheduled`(경보 단계)인가. 방어 호출은 `active` 에서만 받는다.
|
||||
final bool scheduled;
|
||||
|
||||
bool hasStarted(DateTime now) => startAt == null || !startAt!.isAfter(now);
|
||||
|
||||
/// 방어 마감. 없으면 남은 시간을 띄우지 않는다.
|
||||
final DateTime? endsAt;
|
||||
@@ -172,7 +188,13 @@ enum LiveEventKind {
|
||||
abstract interface class EclipseRepository {
|
||||
/// 활성 이클립스. [fandomId] 를 주지 않으면 내 소속 팬덤을 본다.
|
||||
/// 기본은 활성 회차만, includeResults는 방어 화면에서 마감 후 판정까지 받는다.
|
||||
Stream<EclipseState?> watch({String? fandomId, bool includeResults = false});
|
||||
/// includeScheduled는 사전 알림(`warnAt`)부터의 `scheduled` 회차도 받는다 — HOME 이클립스
|
||||
/// 이벤트 버튼(`516:88125` 「발생 N분 전 노출」)과 이클립스 상세가 쓴다.
|
||||
Stream<EclipseState?> watch({
|
||||
String? fandomId,
|
||||
bool includeResults = false,
|
||||
bool includeScheduled = false,
|
||||
});
|
||||
|
||||
/// 최근 티커 이벤트. 최신순.
|
||||
Stream<List<LiveEvent>> liveEvents({String? fandomId, int limit = 20});
|
||||
@@ -214,8 +236,11 @@ class SampleEclipseRepository implements EclipseRepository {
|
||||
);
|
||||
|
||||
@override
|
||||
Stream<EclipseState?> watch({String? fandomId, bool includeResults = false}) =>
|
||||
Stream.value(_snapshot(fandomId: fandomId));
|
||||
Stream<EclipseState?> watch({
|
||||
String? fandomId,
|
||||
bool includeResults = false,
|
||||
bool includeScheduled = false,
|
||||
}) => Stream.value(_snapshot(fandomId: fandomId));
|
||||
|
||||
@override
|
||||
Stream<List<LiveEvent>> liveEvents({String? fandomId, int limit = 20}) {
|
||||
|
||||
@@ -4,13 +4,13 @@ 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 '../quest/quest_evidence.dart' show deviceShakes;
|
||||
import 'eclipse_data.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
|
||||
@@ -48,7 +48,7 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
|
||||
with WidgetsBindingObserver {
|
||||
bool _foreground = true;
|
||||
String? _roundId;
|
||||
StreamSubscription<AccelerometerEvent>? _accel;
|
||||
StreamSubscription<DateTime>? _accel;
|
||||
|
||||
/// 아직 서버에 보내지 않은 흔들기 수.
|
||||
int _pending = 0;
|
||||
@@ -63,23 +63,16 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
|
||||
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,
|
||||
// 흔들기 판정 규칙(문턱 · 표본 주기)은 Music Shake 와 같다 — [ShakeDetector].
|
||||
_accel = deviceShakes().listen(
|
||||
(_) => _shake(),
|
||||
onError: (_) {
|
||||
// 센서가 없는 단말(에뮬레이터·일부 태블릿)이다. 화면은 그대로 두고
|
||||
// 아래의 「직접 누르기」로 참여할 수 있게 한다.
|
||||
@@ -106,21 +99,10 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
|
||||
}
|
||||
}
|
||||
|
||||
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 ||
|
||||
_current?.canShake(DateTime.now()) != true ||
|
||||
(_batch != null && !_sending))
|
||||
return;
|
||||
setState(() => _pending += 1);
|
||||
@@ -221,6 +203,8 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
|
||||
create: () => widget.repository.watch(
|
||||
fandomId: widget.fandomId,
|
||||
includeResults: true,
|
||||
// 경보 단계부터 들어온다 — HOME 이클립스 이벤트 버튼(`516:88125`) → 이클립스 상세.
|
||||
includeScheduled: true,
|
||||
),
|
||||
builder: (context, state) {
|
||||
_current = state;
|
||||
@@ -233,11 +217,16 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
|
||||
_burst?.cancel();
|
||||
_burst = null;
|
||||
}
|
||||
if (state?.canDefend != true) {
|
||||
if (state == null ||
|
||||
(!state.canShake(DateTime.now()) && state.hasStarted(DateTime.now()))) {
|
||||
_pending = 0;
|
||||
_burst?.cancel();
|
||||
_burst = null;
|
||||
}
|
||||
// 발생 직후 서버가 회차를 `active` 로 바꾸기 전에 모인 흔들기를 이제 보낸다.
|
||||
if (state != null && state.canDefend && _pending > 0 && _batch == null) {
|
||||
_scheduleFlush();
|
||||
}
|
||||
if (state == null) {
|
||||
return Column(
|
||||
children: [
|
||||
@@ -246,162 +235,257 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
|
||||
],
|
||||
);
|
||||
}
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// 단계는 시각으로 갈린다 — 상세(발생 전) → 오버레이(발생 직전) → 방어 → 결과.
|
||||
return EclipseTicker(
|
||||
builder: (context, now) => _phase(context, state, now),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/// 발생 직전 오버레이 길이. 사본 `373:444976` 의 카운트다운이 「3」이고, 상세
|
||||
/// `373:444865` 는 「발생 남은 시간 : 10초」에도 아직 상세다 — 그래서 10초가 아니라 3초다.
|
||||
static const overlayLead = Duration(seconds: 3);
|
||||
|
||||
Widget _phase(BuildContext context, EclipseState state, DateTime now) {
|
||||
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(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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
if (!state.hasStarted(now)) {
|
||||
final left = state.startAt!.difference(now);
|
||||
// 사본 `홈/이클립스 상세`(`373:444865`) — 침식되는 행성 · 맨 아래 「이클립스 발생 남은 시간」.
|
||||
final detail = Column(
|
||||
children: [
|
||||
const _CloseBar(),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
height: 68,
|
||||
child: Text(
|
||||
context.l10n.eclipseShake,
|
||||
textAlign: TextAlign.center,
|
||||
style: _titleStyle,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Flexible(
|
||||
child: SizedBox(height: 400, child: _ErodingPlanet()),
|
||||
),
|
||||
const Spacer(),
|
||||
// `516:88184` 남은 시간 노출 영역 — 하단 정렬, 끝나면 방어 화면으로.
|
||||
SizedBox(
|
||||
height: 72,
|
||||
child: Center(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
text: context.l10n.eclipseTimeLeft,
|
||||
children: [
|
||||
TextSpan(
|
||||
text: ' ${eclipseLeftLabel(left)}',
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (left > overlayLead) return detail;
|
||||
// 사본 `373:444976` — 검정 막 · 위 「행성 운석 충돌이 시작됩니다!」 · 가운데 카운트다운.
|
||||
// `516:88197` 「0 이 되면 오버레이 종료 후 방어 이벤트 시작」.
|
||||
return Stack(
|
||||
children: [
|
||||
detail,
|
||||
const Positioned.fill(child: ColoredBox(color: Color(0xB3000000))),
|
||||
Column(
|
||||
children: [
|
||||
const SizedBox(height: 56 + 24),
|
||||
SizedBox(
|
||||
height: 68,
|
||||
child: Text(
|
||||
context.l10n.eclipseOverlayTitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: _titleStyle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Center(
|
||||
child: Text(
|
||||
'${_ceilSeconds(left)}',
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontSize: 62,
|
||||
height: 1.4,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 사본 `홈/이클립스 방어`(`373:445267`) — 「60초간 휴대폰을 흔들어…」의 숫자가 실시간
|
||||
// 잔여 시간이다(`516:88225` 60초 타이머). 행성 둘레 진행 막대는 잔여 시간 비율
|
||||
// (`516:88231`), 운석은 시간이 흐를수록 행성으로 다가온다.
|
||||
final left = state.endsAt!.difference(now);
|
||||
final window = state.startAt == null
|
||||
? const Duration(seconds: 60)
|
||||
: state.endsAt!.difference(state.startAt!);
|
||||
final remaining = window.inMilliseconds <= 0
|
||||
? 0.0
|
||||
: (left.inMilliseconds / window.inMilliseconds).clamp(0.0, 1.0);
|
||||
final canShake = state.canShake(now);
|
||||
return Column(
|
||||
children: [
|
||||
const _CloseBar(),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
height: 68,
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '${_ceilSeconds(left)}',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Pretendard',
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
TextSpan(text: context.l10n.eclipseShakeTimed),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
style: _titleStyle,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// 짧은 화면에서는 아래 진행 표시·버튼이 밀려나지 않게 행성 자리가 먼저 줄어든다.
|
||||
Flexible(
|
||||
child: SizedBox(
|
||||
height: 400,
|
||||
child: _DefendPlanet(remaining: remaining, pending: _pending),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// 진행 표시 — 내 유효 Shake / 인정 횟수(C-25) · 팬덤 고유 참여자 / 목표(C-26).
|
||||
// 사본에 없는 정책 정보라 행성 아래에 낮은 강조로 둔다.
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
|
||||
child: Column(
|
||||
children: [
|
||||
_Meter(
|
||||
key: const ValueKey('eclipse-my-shakes'),
|
||||
label:
|
||||
'${context.l10n.eclipseMyShakes(formatPop(_mine), '${state.qualifyShakes}')}'
|
||||
'${_pending > 0
|
||||
? context.l10n.eclipsePendingSuffix('$_pending')
|
||||
: _sending
|
||||
? context.l10n.eclipseSendingSuffix
|
||||
: ''}'
|
||||
'${_mine >= state.qualifyShakes ? ' · ${context.l10n.eclipseQualified}' : ''}',
|
||||
value: state.qualifyShakes <= 0
|
||||
? 0
|
||||
: (_mine / state.qualifyShakes).clamp(0.0, 1.0),
|
||||
color: AppColors.cosmicAccent,
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
_Meter(
|
||||
key: const ValueKey('eclipse-fandom'),
|
||||
label: context.l10n.eclipseBannerDefense(
|
||||
formatPop(state.defense),
|
||||
formatPop(state.targetDefense),
|
||||
),
|
||||
value: state.progress,
|
||||
color: AppColors.neonEmerald,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
// 소모가 없다는 것을 화면이 직접 말한다 — 「POP 이 빠지나?」를 묻게 두지 않는다(설계서 v1.1 §9-#5).
|
||||
_Note(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: canShake && (_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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
int _ceilSeconds(Duration d) => math.max(0, (d.inMilliseconds / 1000).ceil());
|
||||
|
||||
/// 남은 시간 표기 — 1분 미만은 「N초」, 그 이상은 「M분 N초」. HOME 버튼과 상세가 같이 쓴다.
|
||||
String eclipseLeftLabel(Duration left) {
|
||||
final s = _ceilSeconds(left);
|
||||
return s < 60
|
||||
? appL10n.eclipseSec('$s')
|
||||
: appL10n.eclipseMinSec('${s ~/ 60}', '${s % 60}');
|
||||
}
|
||||
|
||||
/// 사본 제목 — ABeeZee 24 · 행간 140% · 흰색 · 가운데.
|
||||
@@ -455,24 +539,68 @@ class _Note extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// 초 단위로 다시 그린다. 마감은 서버 값이라 재진입해도 처음부터 세지 않는다.
|
||||
class _Countdown extends StatefulWidget {
|
||||
const _Countdown({required this.endsAt, required this.builder});
|
||||
/// 진행 한 줄 — 글(14 흰색) 위, 6 높이 막대 아래.
|
||||
class _Meter extends StatelessWidget {
|
||||
const _Meter({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
final DateTime? endsAt;
|
||||
final Widget Function(BuildContext context, int? seconds) builder;
|
||||
final String label;
|
||||
final double value;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
State<_Countdown> createState() => _CountdownState();
|
||||
Widget build(BuildContext context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.4,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s1),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
child: LinearProgressIndicator(
|
||||
value: value,
|
||||
minHeight: 6,
|
||||
backgroundColor: AppColors.cosmicOutline,
|
||||
valueColor: AlwaysStoppedAnimation(color),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class _CountdownState extends State<_Countdown> {
|
||||
/// 화면을 짧은 주기로 다시 그린다. 시각은 서버 값(`startAt` · `endsAt`)이라 재진입해도
|
||||
/// 처음부터 세지 않는다. 1초 주기면 표시가 최대 1초 늦어 오버레이 「3·2·1」이 어긋난다.
|
||||
class EclipseTicker extends StatefulWidget {
|
||||
const EclipseTicker({super.key, required this.builder});
|
||||
|
||||
final Widget Function(BuildContext context, DateTime now) builder;
|
||||
|
||||
@override
|
||||
State<EclipseTicker> createState() => _EclipseTickerState();
|
||||
}
|
||||
|
||||
class _EclipseTickerState extends State<EclipseTicker> {
|
||||
late final Timer _timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
|
||||
_timer = Timer.periodic(
|
||||
const Duration(milliseconds: 250),
|
||||
(_) => setState(() {}),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -482,31 +610,20 @@ class _CountdownState extends State<_Countdown> {
|
||||
}
|
||||
|
||||
@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(),
|
||||
);
|
||||
}
|
||||
Widget build(BuildContext context) => widget.builder(context, DateTime.now());
|
||||
}
|
||||
|
||||
/// 행성 + 운석 — 사본 `Frame 1984081426`(355×400).
|
||||
const _planet = AssetImage('assets/figma/planet-home.png');
|
||||
|
||||
// 사본 좌표(프레임 원점 x10 · y220 기준).
|
||||
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);
|
||||
|
||||
/// 침식되는 행성 — 사본 `홈/이클립스 상세`(`373:444865`) `Frame 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);
|
||||
/// 행성(240) 양옆에 흐린 사본(`image 9~12`, 흐림 4)을 겹쳐 그림자가 번지는 모습이다.
|
||||
class _ErodingPlanet extends StatelessWidget {
|
||||
const _ErodingPlanet();
|
||||
|
||||
static Widget _shadow(double x, double y, double size, {double blur = 0}) =>
|
||||
_at(
|
||||
@@ -523,6 +640,49 @@ class _DefendPlanet extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Center(
|
||||
child: SizedBox(
|
||||
width: 355,
|
||||
height: 400,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
_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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 방어 중 행성 — 사본 `홈/이클립스 방어`(`373:445267`) `Frame 355×400`.
|
||||
///
|
||||
/// 검정 30% 원(280) 안의 행성(240)과 운석 셋. 원 둘레의 진행 막대(`373:537356`
|
||||
/// `Group 572` 그라디언트 원 · `516:88231`)는 잔여 시간 비율 [remaining] 이고, 운석은
|
||||
/// 시간이 흐를수록(1 − [remaining]) 행성 쪽으로 다가온다.
|
||||
class _DefendPlanet extends StatelessWidget {
|
||||
const _DefendPlanet({required this.remaining, required this.pending});
|
||||
|
||||
/// 남은 시간 비율 1 → 0.
|
||||
final double remaining;
|
||||
final int pending;
|
||||
|
||||
// 행성 가운데(사본 좌표).
|
||||
static const _cx = 187.5;
|
||||
static const _cy = 420.0;
|
||||
|
||||
/// 운석 하나 — 사본 자리에서 행성 쪽으로 최대 60% 다가온다.
|
||||
Widget _meteor(double x, double y, double w, double h, Widget image) {
|
||||
final t = (1 - remaining) * 0.6;
|
||||
final dx = (_cx - (x + w / 2)) * t;
|
||||
final dy = (_cy - (y + h / 2)) * t;
|
||||
return _at(x + dx, y + dy, w, h, image);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Center(
|
||||
child: SizedBox(
|
||||
@@ -533,62 +693,89 @@ class _DefendPlanet extends StatelessWidget {
|
||||
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')),
|
||||
),
|
||||
],
|
||||
children: [
|
||||
_at(
|
||||
48,
|
||||
280,
|
||||
280,
|
||||
280,
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Color(0x4D000000),
|
||||
),
|
||||
),
|
||||
),
|
||||
_at(
|
||||
48,
|
||||
280,
|
||||
280,
|
||||
280,
|
||||
CustomPaint(painter: _RemainingRing(remaining)),
|
||||
),
|
||||
_at(67.5, 300, 240, 240, const Image(image: _planet)),
|
||||
// 좌상단 운석(`Group 573`)은 내보낸 자산이 없어 `Group 575` 를 뒤집어 쓴다.
|
||||
_meteor(
|
||||
38,
|
||||
246,
|
||||
49,
|
||||
56.5,
|
||||
const RotatedBox(
|
||||
quarterTurns: 2,
|
||||
child: Image(image: AssetImage('assets/figma/meteor-b.png')),
|
||||
),
|
||||
),
|
||||
_meteor(
|
||||
328,
|
||||
287,
|
||||
34.1,
|
||||
29.5,
|
||||
const Image(image: AssetImage('assets/figma/meteor-a.png')),
|
||||
),
|
||||
_meteor(
|
||||
230,
|
||||
580,
|
||||
29.5,
|
||||
34.1,
|
||||
const Image(image: AssetImage('assets/figma/meteor-b.png')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 잔여 시간 원호 — 12시에서 시계 방향, 분홍(#FF7DDD) → 보라(#6866F3).
|
||||
class _RemainingRing extends CustomPainter {
|
||||
const _RemainingRing(this.remaining);
|
||||
|
||||
final double remaining;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (remaining <= 0) return;
|
||||
const stroke = 6.0;
|
||||
final rect = (Offset.zero & size).deflate(stroke / 2);
|
||||
canvas.drawArc(
|
||||
rect,
|
||||
-math.pi / 2,
|
||||
2 * math.pi * remaining,
|
||||
false,
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = stroke
|
||||
..strokeCap = StrokeCap.round
|
||||
..shader = const LinearGradient(
|
||||
colors: [AppColors.cosmicAccent, AppColors.neonViolet],
|
||||
).createShader(rect),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_RemainingRing old) => old.remaining != remaining;
|
||||
}
|
||||
|
||||
/// 결과 — 사본 `홈/이클립스 성공`(분홍 원 + 체크) · 실패 변형(회색 원).
|
||||
///
|
||||
/// 원 200 · 안쪽 그림자(흰 30% ↘ · 검정 30% ↖ · 3.3) · 아래 「확인」(343×56 · r8 · 분홍).
|
||||
|
||||
@@ -40,10 +40,16 @@ class FirestoreEclipseRepository implements EclipseRepository {
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<EclipseState?> watch({String? fandomId, bool includeResults = false}) =>
|
||||
(_watchers['${fandomId ?? ''}#$includeResults'] ??= SharedStream<EclipseState?>(
|
||||
() => _openWatch(fandomId, includeResults),
|
||||
)).stream;
|
||||
Stream<EclipseState?> watch({
|
||||
String? fandomId,
|
||||
bool includeResults = false,
|
||||
bool includeScheduled = false,
|
||||
}) =>
|
||||
(_watchers['${fandomId ?? ''}#$includeResults#$includeScheduled'] ??=
|
||||
SharedStream<EclipseState?>(
|
||||
() => _openWatch(fandomId, includeResults, includeScheduled),
|
||||
))
|
||||
.stream;
|
||||
|
||||
@override
|
||||
Stream<List<LiveEvent>> liveEvents({String? fandomId, int limit = 20}) =>
|
||||
@@ -54,6 +60,7 @@ class FirestoreEclipseRepository implements EclipseRepository {
|
||||
Stream<EclipseState?> _openWatch(
|
||||
String? fandomId,
|
||||
bool includeResults,
|
||||
bool includeScheduled,
|
||||
) => Stream.fromFuture(_fandomId(fandomId)).asyncExpand((id) {
|
||||
if (id.isEmpty) return Stream<EclipseState?>.value(null);
|
||||
return Stream<EclipseState?>.multi((out) {
|
||||
@@ -62,7 +69,11 @@ class FirestoreEclipseRepository implements EclipseRepository {
|
||||
var generation = 0;
|
||||
final eventsSub = _backend.db
|
||||
.collection('fandoms/$id/eclipseRounds')
|
||||
.where('status', whereIn: includeResults ? ['active', 'ended', 'cancelled'] : ['active'])
|
||||
.where('status', whereIn: [
|
||||
if (includeScheduled) 'scheduled',
|
||||
'active',
|
||||
if (includeResults) ...['ended', 'cancelled'],
|
||||
])
|
||||
.orderBy('startAt', descending: true)
|
||||
.limit(1)
|
||||
.snapshots(includeMetadataChanges: true)
|
||||
@@ -169,6 +180,7 @@ class FirestoreEclipseRepository implements EclipseRepository {
|
||||
return (acc.$1 + d.intOr('qualifiedParticipants'), acc.$2 + d.intOr('defenseCount'));
|
||||
});
|
||||
final endsAt = data['endsAt'];
|
||||
final startAt = data['startAt'];
|
||||
return EclipseState(
|
||||
fandomId: fandomId,
|
||||
eclipseId: eclipseId,
|
||||
@@ -186,6 +198,8 @@ class FirestoreEclipseRepository implements EclipseRepository {
|
||||
'cancelled_error' => EclipseResult.cancelled,
|
||||
_ => null,
|
||||
},
|
||||
startAt: startAt is Timestamp ? startAt.toDate() : null,
|
||||
scheduled: data['status'] == 'scheduled',
|
||||
endsAt: endsAt is Timestamp ? endsAt.toDate() : null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../../../core/theme/tokens.dart';
|
||||
import '../../../core/ui/live_stream.dart';
|
||||
import '../../home/home_screen.dart' show formatPop;
|
||||
import '../eclipse_data.dart';
|
||||
import '../eclipse_defend_screen.dart';
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 이클립스 인앱 경보 배너 — HOME · GALAXY 상단.
|
||||
@@ -52,7 +53,16 @@ class EclipseBanner extends StatelessWidget {
|
||||
? const SizedBox.shrink()
|
||||
: Padding(
|
||||
padding: margin,
|
||||
child: EclipseBannerCard(state: state, onTap: onTap),
|
||||
// 넘겨받은 동작이 없으면 「흔들어 방어」 화면을 연다 — HOME·행성 상세·GALAXY 홈
|
||||
// 모두 이 기본 동작을 쓴다(배너를 눌러도 아무 일이 없던 결함).
|
||||
child: EclipseBannerCard(
|
||||
state: state,
|
||||
onTap: onTap ?? () => Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(builder: (_) => EclipseDefendScreen(
|
||||
repository: repository, fandomId: state.fandomId,
|
||||
)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../../../core/ui/live_stream.dart';
|
||||
import '../eclipse_data.dart';
|
||||
import '../eclipse_defend_screen.dart';
|
||||
|
||||
/// HOME 이클립스 이벤트 버튼 — 사본 「홈」(`373:443167`)의 `Group 563`.
|
||||
///
|
||||
/// 명세 `516:88125` *데이터: 이클립스 발생 잔여 시간 · 규칙: 성운(행성) 아래 중앙 정렬,
|
||||
/// 어드민에서 설정된 이클립스 이벤트 발생 시간의 N분 전 노출 · 동작: 클릭 시 이클립스 상세로
|
||||
/// 이동*. N분은 서버 회차의 `warnAt`(원장 C-22 발생 3분 전)이다 — 회차 문서가 그때부터
|
||||
/// `scheduled` 로 있으므로 그 문서가 보이는 동안 띄운다. 발생 뒤 방어 창(60초) 동안은
|
||||
/// 남은 초로 바뀌고, 결과가 나거나 창이 닫히면 접는다.
|
||||
///
|
||||
/// 모양: 위를 가리키는 꼭지 + 검정 80% 알약(229×46 · r 끝까지) · 42dot Medium 12 흰색 두 줄.
|
||||
class EclipseEventButton extends StatelessWidget {
|
||||
const EclipseEventButton({
|
||||
super.key,
|
||||
required this.repository,
|
||||
required this.fandomId,
|
||||
});
|
||||
|
||||
final EclipseRepository repository;
|
||||
final String fandomId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => LiveStreamBuilder<EclipseState?>(
|
||||
key: ValueKey('eclipse-button-$fandomId'),
|
||||
create: () => repository.watch(fandomId: fandomId, includeScheduled: true),
|
||||
builder: (context, state) {
|
||||
if (state == null) return const SizedBox.shrink();
|
||||
return EclipseTicker(
|
||||
builder: (context, now) {
|
||||
if (state.result != null || state.defended || state.isExpired(now)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final text = state.hasStarted(now)
|
||||
? context.l10n.eclipseButtonActive(
|
||||
'${((state.remaining(now)?.inMilliseconds ?? 0) / 1000).ceil()}',
|
||||
)
|
||||
: context.l10n.eclipseButtonSoon(
|
||||
eclipseLeftLabel(state.startAt!.difference(now)),
|
||||
);
|
||||
return Semantics(
|
||||
button: true,
|
||||
liveRegion: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(
|
||||
// 명세 `516:88164` 「닫기 — 하단 move out」 — 아래에서 올라오는 전체 화면.
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => EclipseDefendScreen(
|
||||
repository: repository,
|
||||
fandomId: state.fandomId,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CustomPaint(
|
||||
size: const Size(16, 7),
|
||||
painter: _Tip(),
|
||||
),
|
||||
Container(
|
||||
constraints: const BoxConstraints(minWidth: 229, minHeight: 46),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpace.s4,
|
||||
vertical: 6,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xCC000000),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 17 / 12,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 말풍선 꼭지 — 행성을 가리킨다.
|
||||
class _Tip extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) => canvas.drawPath(
|
||||
Path()
|
||||
..moveTo(size.width / 2, 0)
|
||||
..lineTo(size.width, size.height)
|
||||
..lineTo(0, size.height)
|
||||
..close(),
|
||||
Paint()..color = const Color(0xCC000000),
|
||||
);
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_Tip old) => false;
|
||||
}
|
||||
Reference in New Issue
Block a user