diff --git a/app/firebase/functions/src/quest/verify.ts b/app/firebase/functions/src/quest/verify.ts index 2cb5dcb..2ce2c98 100644 --- a/app/firebase/functions/src/quest/verify.ts +++ b/app/firebase/functions/src/quest/verify.ts @@ -131,7 +131,12 @@ export const fnQuestVerifyMusicShake = onCall(async (req) => { } const policy = await loadPolicy(); - const amount = Math.round(policy.stepsPop * GRADE_MULTIPLIER[grade]); + // 원장 B-24 「보상액은 운영값」 — 퀘스트 문서(Admin 「설정 보상 POP」)가 정하고, 없으면 + // 옛 공통값으로 물러난다. 앱 화면도 같은 순서로 읽는다(`QuestType.rewardFrom`). + const base = quest.get('rewardPop'); + const amount = Math.round( + (typeof base === 'number' && base >= 0 ? base : policy.stepsPop) * GRADE_MULTIPLIER[grade], + ); const result = await grantPop({ uid, popType: 'activity', diff --git a/app/firebase/functions/test/quest_e2e.test.ts b/app/firebase/functions/test/quest_e2e.test.ts index 8bf994c..55a5e68 100644 --- a/app/firebase/functions/test/quest_e2e.test.ts +++ b/app/firebase/functions/test/quest_e2e.test.ts @@ -507,6 +507,18 @@ describe('검증 활동 집계 규칙 — Quest 완료 1건 · 하루 1회 (Camp expect(await count()).toBe(2); }); + it('Music Shake 보상은 퀘스트의 rewardPop × 등급 배수다', async () => { + await db().doc('quests/music-reward').set({ + type: 'musicShake', trackId: 't1', totalSegments: 10, dailyLimit: 1, rewardPop: 30, + }); + const reply = await call(fnQuestVerifyMusicShake, { + questId: 'music-reward', trackId: 't1', totalSegments: 10, + judgement: { perfect: 10, good: 0, miss: 0, maxCombo: 10 }, + }); + expect(reply.grade).toBe('S'); + expect(reply.rewarded).toBe(60); + }); + it('Music Shake 는 완주마다 1회 · 방문은 1회', async () => { await db().doc('quests/music-count').set({ type: 'musicShake', trackId: 't1', totalSegments: 10, dailyLimit: 2 }); const shake = () => call(fnQuestVerifyMusicShake, { diff --git a/app/lib/features/eclipse/eclipse_data.dart b/app/lib/features/eclipse/eclipse_data.dart index 1170860..07f37ce 100644 --- a/app/lib/features/eclipse/eclipse_data.dart +++ b/app/lib/features/eclipse/eclipse_data.dart @@ -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 watch({String? fandomId, bool includeResults = false}); + /// includeScheduled는 사전 알림(`warnAt`)부터의 `scheduled` 회차도 받는다 — HOME 이클립스 + /// 이벤트 버튼(`516:88125` 「발생 N분 전 노출」)과 이클립스 상세가 쓴다. + Stream watch({ + String? fandomId, + bool includeResults = false, + bool includeScheduled = false, + }); /// 최근 티커 이벤트. 최신순. Stream> liveEvents({String? fandomId, int limit = 20}); @@ -214,8 +236,11 @@ class SampleEclipseRepository implements EclipseRepository { ); @override - Stream watch({String? fandomId, bool includeResults = false}) => - Stream.value(_snapshot(fandomId: fandomId)); + Stream watch({ + String? fandomId, + bool includeResults = false, + bool includeScheduled = false, + }) => Stream.value(_snapshot(fandomId: fandomId)); @override Stream> liveEvents({String? fandomId, int limit = 20}) { diff --git a/app/lib/features/eclipse/eclipse_defend_screen.dart b/app/lib/features/eclipse/eclipse_defend_screen.dart index af3edfb..76c8ad5 100644 --- a/app/lib/features/eclipse/eclipse_defend_screen.dart +++ b/app/lib/features/eclipse/eclipse_defend_screen.dart @@ -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 with WidgetsBindingObserver { bool _foreground = true; String? _roundId; - StreamSubscription? _accel; + StreamSubscription? _accel; /// 아직 서버에 보내지 않은 흔들기 수. int _pending = 0; @@ -63,23 +63,16 @@ class _EclipseDefendScreenState extends State 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 } } - 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 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 _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 ], ); } - 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 createState() => _EclipseTickerState(); +} + +class _EclipseTickerState extends State { 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 · 분홍). diff --git a/app/lib/features/eclipse/eclipse_repository_firestore.dart b/app/lib/features/eclipse/eclipse_repository_firestore.dart index f0e48e7..9eed283 100644 --- a/app/lib/features/eclipse/eclipse_repository_firestore.dart +++ b/app/lib/features/eclipse/eclipse_repository_firestore.dart @@ -40,10 +40,16 @@ class FirestoreEclipseRepository implements EclipseRepository { } @override - Stream watch({String? fandomId, bool includeResults = false}) => - (_watchers['${fandomId ?? ''}#$includeResults'] ??= SharedStream( - () => _openWatch(fandomId, includeResults), - )).stream; + Stream watch({ + String? fandomId, + bool includeResults = false, + bool includeScheduled = false, + }) => + (_watchers['${fandomId ?? ''}#$includeResults#$includeScheduled'] ??= + SharedStream( + () => _openWatch(fandomId, includeResults, includeScheduled), + )) + .stream; @override Stream> liveEvents({String? fandomId, int limit = 20}) => @@ -54,6 +60,7 @@ class FirestoreEclipseRepository implements EclipseRepository { Stream _openWatch( String? fandomId, bool includeResults, + bool includeScheduled, ) => Stream.fromFuture(_fandomId(fandomId)).asyncExpand((id) { if (id.isEmpty) return Stream.value(null); return Stream.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, ); } diff --git a/app/lib/features/eclipse/widgets/eclipse_banner.dart b/app/lib/features/eclipse/widgets/eclipse_banner.dart index 5dadbf8..ae4b16e 100644 --- a/app/lib/features/eclipse/widgets/eclipse_banner.dart +++ b/app/lib/features/eclipse/widgets/eclipse_banner.dart @@ -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(builder: (_) => EclipseDefendScreen( + repository: repository, fandomId: state.fandomId, + )), + ), + ), ), ); } diff --git a/app/lib/features/eclipse/widgets/eclipse_event_button.dart b/app/lib/features/eclipse/widgets/eclipse_event_button.dart new file mode 100644 index 0000000..4fc099e --- /dev/null +++ b/app/lib/features/eclipse/widgets/eclipse_event_button.dart @@ -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( + 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( + // 명세 `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; +} diff --git a/app/lib/features/home/home_screen.dart b/app/lib/features/home/home_screen.dart index 8c55c7c..591480e 100644 --- a/app/lib/features/home/home_screen.dart +++ b/app/lib/features/home/home_screen.dart @@ -19,7 +19,7 @@ import '../../core/ui/app_icons.dart'; import '../../core/data/planet_visual_state.dart'; import '../../core/ui/live_stream.dart'; import '../eclipse/eclipse_data.dart'; -import '../eclipse/widgets/eclipse_banner.dart'; +import '../eclipse/widgets/eclipse_event_button.dart'; import '../eclipse/widgets/live_proof.dart'; import 'home_data.dart'; import 'nebula_create_screen.dart'; @@ -221,19 +221,36 @@ class _LiveTickerSlot extends StatelessWidget { final String? fallback; @override - Widget build(BuildContext context) => LiveStreamBuilder>( - key: ValueKey('live-$fandomId'), - // 띠는 한 줄만 그리므로 최신 몇 건만 받는다. + Widget build(BuildContext context) => LiveStreamBuilder( + key: ValueKey('live-eclipse-$fandomId'), + // 명세 `516:87664` 이벤트2 「이클립스 이벤트 — 행성/성운명」(사본 `373:443187` + // 「BTS 행성에 이클립스가 발생했어요!」). 발생한 회차만 본다. create: () => fandomId.isEmpty - ? Stream.value(const []) - : eclipse.liveEvents(fandomId: fandomId, limit: 5), - builder: (context, events) { - final latest = (events == null || events.isEmpty) - ? fallback - : liveEventMessage(events.first); - if (latest == null) return const SizedBox.shrink(); - return LiveTicker(text: latest); - }, + ? Stream.value(null) + : eclipse.watch(fandomId: fandomId), + builder: (context, eclipseState) => LiveStreamBuilder>( + key: ValueKey('live-$fandomId'), + // 띠는 한 줄만 그리므로 최신 몇 건만 받는다. + create: () => fandomId.isEmpty + ? Stream.value(const []) + : eclipse.liveEvents(fandomId: fandomId, limit: 5), + builder: (context, events) { + final event = (events == null || events.isEmpty) ? null : events.first; + final started = eclipseState?.startAt; + // 발생 소식은 그 뒤에 온 이벤트(방어 참여 등)가 없을 때 띠에 오른다. + final eclipseNews = + eclipseState != null && + eclipseState.result == null && + (event == null || started == null || event.at.isBefore(started)); + final latest = eclipseNews + ? context.l10n.eclipseLiveOccurred(eclipseState.fandomName) + : event == null + ? fallback + : liveEventMessage(event); + if (latest == null) return const SizedBox.shrink(); + return LiveTicker(text: latest); + }, + ), ); } @@ -540,6 +557,12 @@ class _HomeBody extends StatelessWidget { GoRouter.maybeOf(context)?.go('/galaxy'), zapButtonKey: zapKey, onHeroParticipate: _heroParticipate(context), + eclipseButton: data.fandomId.isEmpty + ? null + : EclipseEventButton( + repository: eclipse, + fandomId: data.fandomId, + ), ), ), ), @@ -564,20 +587,8 @@ class _HomeBody extends StatelessWidget { ), ], ), - // 이클립스 인앱 경보 — 별첨 1 ① 예외 처리 「푸시 미수신 단말 — 인앱 - // 워닝 배너 병행」. 활성 이클립스가 없으면 자리도 차지하지 않는다. - if (data.fandomId.isNotEmpty) - EclipseBanner( - repository: eclipse, - fandomId: data.fandomId, - // 히어로 아래 24 = 히어로 상자 12 + 여기 12. 구획 사이는 36(`Frame 4241`). - margin: const EdgeInsets.fromLTRB( - AppSpace.s4, - HomeSectionHeader.slack, - AppSpace.s4, - _sectionGap, - ), - ), + // 이클립스 인앱 경보(별첨 1 ① 「푸시 미수신 단말 — 인앱 워닝 배너 병행」)는 + // Figma 대로 히어로 행성 아래의 이클립스 이벤트 버튼(`516:88124`)이 맡는다. // 이하 구획은 Figma 「홈」 `2097:16428` `Frame 4241`(패딩 24/16 · 구획 사이 36 · 머리와 // 본문 16) 순서 — TODAY'S QUEST · 팬덤행성 인큐베이터 · 아티스트 소식 · HOT ARCHIVE · // 인기 K-INSIDE. diff --git a/app/lib/features/home/widgets/home_hero.dart b/app/lib/features/home/widgets/home_hero.dart index a431990..72e5b55 100644 --- a/app/lib/features/home/widgets/home_hero.dart +++ b/app/lib/features/home/widgets/home_hero.dart @@ -48,8 +48,12 @@ class HomeHero extends StatefulWidget { this.onItem, this.miniMap, this.zapButtonKey, + this.eclipseButton, }); + /// 이클립스 이벤트 버튼(`516:88124`). 경보 단계부터 행성 아래 가운데에 뜬다. + final Widget? eclipseButton; + /// 화면 하단에 떠 있는 ZAP 버튼([HomeZap])의 키 — 기여 연출이 이 자리에서 행성으로 날아간다. final GlobalKey? zapButtonKey; @@ -181,35 +185,57 @@ class _HomeHeroState extends State { builder: (context, box) => SizedBox( width: double.infinity, height: math.min(planet, box.maxHeight), - child: widget.unjoined - ? _EmptyPlanet( - onCreateNebula: widget.onCreateNebula, - onJoin: widget.onJoin, - ) - : growth.isNebula - ? _Nebula( - key: _planetKey, - // 탄생은 창립자만(Q-PN-01 A안) — 다른 멤버에게는 안내 · 탭이 없다. - ready: growth.nebulaReady && growth.isFounder, - onBirth: widget.onBirth, - ) - : Stack( - clipBehavior: Clip.none, - children: [ - Positioned.fill( - child: LivePlanet25D( - onOpenHistory: widget.onOpenHistory, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: widget.unjoined + ? _EmptyPlanet( + onCreateNebula: widget.onCreateNebula, + onJoin: widget.onJoin, + ) + : growth.isNebula + ? _Nebula( key: _planetKey, - fandomId: fandomId, - watch: watchPlanet, - stage: growth.stage, - image: 'assets/figma/planet-home.png', - size: math.min(planet, box.maxHeight), - glow: AppColors.neonViolet, + // 탄생은 창립자만(Q-PN-01 A안) — 다른 멤버에게는 안내 · 탭이 없다. + ready: + growth.nebulaReady && + growth.isFounder, + onBirth: widget.onBirth, + ) + : Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: LivePlanet25D( + onOpenHistory: widget.onOpenHistory, + key: _planetKey, + fandomId: fandomId, + watch: watchPlanet, + stage: growth.stage, + image: + 'assets/figma/planet-home.png', + size: math.min( + planet, + box.maxHeight, + ), + glow: AppColors.neonViolet, + ), + ), + ], ), - ), - ], + ), + // 이클립스 이벤트 버튼 — 명세 `516:88125` 「성운(행성) 아래 중앙 정렬」. + // 사본 `Group 563` 은 행성 상자(212) 안 아래쪽에 겹쳐 놓인다. + if (widget.eclipseButton case final button?) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Center(child: button), ), + ], + ), ), ), ), diff --git a/app/lib/features/home/widgets/live_ticker.dart b/app/lib/features/home/widgets/live_ticker.dart index fd99bbc..4e4b029 100644 --- a/app/lib/features/home/widgets/live_ticker.dart +++ b/app/lib/features/home/widgets/live_ticker.dart @@ -7,27 +7,45 @@ import '../../../core/theme/tokens.dart'; /// 사본 `Frame 1984081132`(「홈」 `2097:16428`) — 헤더 바로 아래 전폭 띠(높이 22 · 패딩 4/0 · /// 바탕 없음), 가운데 한 줄(42dot Medium 12 · 행간 14 흰색). 실시간 소셜 프루프(견적서 4주차)의 /// 표면이다. +/// +/// 명세 `516:87664` *동작: 이벤트 발생 시 텍스트 우측에서 좌측으로 1회 슬라이드* — 문구가 +/// 바뀔 때마다 오른쪽 밖에서 한 번 미끄러져 들어와 멈춘다. class LiveTicker extends StatelessWidget { const LiveTicker({super.key, required this.text}); final String text; @override - Widget build(BuildContext context) => Container( - height: 22, - padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4), - alignment: Alignment.center, - child: Text( - text, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontFamily: AppFonts.body, - fontSize: 12, - fontWeight: FontWeight.w500, - height: 14 / 12, - letterSpacing: -0.3, - color: AppColors.onCosmic, + Widget build(BuildContext context) => ClipRect( + child: Container( + height: 22, + padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4), + alignment: Alignment.center, + child: TweenAnimationBuilder( + key: ValueKey(text), + tween: Tween(begin: 1, end: 0), + duration: const Duration(milliseconds: 600), + curve: Curves.easeOutCubic, + // 띠 너비만큼 오른쪽 밖에서 출발한다. + builder: (context, dx, child) => LayoutBuilder( + builder: (context, box) => Transform.translate( + offset: Offset(dx * box.maxWidth, 0), + child: child, + ), + ), + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 12, + fontWeight: FontWeight.w500, + height: 14 / 12, + letterSpacing: -0.3, + color: AppColors.onCosmic, + ), + ), ), ), ); diff --git a/app/lib/features/planet/hero_participation_sheet.dart b/app/lib/features/planet/hero_participation_sheet.dart index 3ac1ddb..b40f2d2 100644 --- a/app/lib/features/planet/hero_participation_sheet.dart +++ b/app/lib/features/planet/hero_participation_sheet.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:sensors_plus/sensors_plus.dart'; @@ -8,6 +7,7 @@ import '../../core/data/backend.dart'; import '../../core/ui/live_stream.dart'; import 'planet_data.dart'; import '../../core/i18n/app_locale.dart'; +import '../quest/quest_evidence.dart' show ShakeDetector; /// 수량·상한·중복 여부는 서버가 결정한다. 단말은 참여 의사와 결과만 전달한다. class HeroParticipationSheet extends StatefulWidget { @@ -38,24 +38,16 @@ class _HeroParticipationSheetState extends State bool _sending = false; bool _failed = false; bool _foreground = true; - DateTime _lastShake = DateTime.fromMillisecondsSinceEpoch(0); @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - _sensor = (widget.accelerometer ?? accelerometerEventStream()).listen(( - event, - ) { - final magnitude = math.sqrt( - event.x * event.x + event.y * event.y + event.z * event.z, - ); - final now = DateTime.now(); - if (!magnitude.isFinite || - magnitude < 18 || - now.difference(_lastShake).inMilliseconds < 220) - return; - _lastShake = now; + final shakes = ShakeDetector(); + _sensor = (widget.accelerometer ?? + accelerometerEventStream(samplingPeriod: ShakeDetector.sampling)) + .listen((event) { + if (!shakes.add(event.x, event.y, event.z, DateTime.now())) return; if (!_failed) _claim(); }, onError: (_) {}); // 센서가 없어도 동일한 서버 경로의 참여 버튼을 제공한다. } diff --git a/app/lib/features/quest/quest_data.dart b/app/lib/features/quest/quest_data.dart index e1aaf3a..7ec10d5 100644 --- a/app/lib/features/quest/quest_data.dart +++ b/app/lib/features/quest/quest_data.dart @@ -112,8 +112,13 @@ enum QuestType { int? rewardFrom(Map quest, Map policy) { // 시청 · 스트리밍은 무보상 참여다(C-04) — 문서 값과 무관하게 0. if (this == QuestType.streaming) return 0; + // Music Shake 는 BPM 판정 성공 시 일반 POP(원장 B-24). 서버(`fn-quest-verify-music-shake`)가 + // 퀘스트의 `rewardPop` 을, 없으면 `config/policy.stepsPop` 을 기본액으로 쓰고 판정 등급 + // 배수를 곱한다. 화면은 기본액(C 등급)을 적는다 — 운영값이 둘 다 없을 때만 중립 문구. final raw = this == QuestType.attend ? policy['attendPop'] + : this == QuestType.musicShake + ? quest['rewardPop'] ?? policy['stepsPop'] : quest['rewardPop']; return raw is num ? raw.round() : null; } diff --git a/app/lib/features/quest/quest_evidence.dart b/app/lib/features/quest/quest_evidence.dart index b317716..be4cad0 100644 --- a/app/lib/features/quest/quest_evidence.dart +++ b/app/lib/features/quest/quest_evidence.dart @@ -48,22 +48,62 @@ typedef PlaceProbe = Future Function(); /// 흔들기 한 번마다 그 시각을 내는 스트림. typedef ShakeProbe = Stream Function(); -/// 흔들기 감지 — 가속도 크기가 중력(9.8)을 크게 넘는 순간. 이클립스 방어 화면과 같은 문턱 · -/// 간격이다(같은 동작을 같은 기준으로 센다). -Stream deviceShakes() async* { - var last = DateTime.fromMillisecondsSinceEpoch(0); - await for (final e in accelerometerEventStream()) { - final magnitude = math.sqrt(e.x * e.x + e.y * e.y + e.z * e.z); - final now = DateTime.now(); - if (!magnitude.isFinite || magnitude < 18 || - now.difference(last) < const Duration(milliseconds: 220)) { - continue; +/// 흔들기 한 번을 세는 규칙 — Music Shake · 이클립스 방어 · HERO TIME 이 함께 쓴다. +/// +/// **단위는 두 플랫폼 모두 m/s²(중력 포함)다.** sensors_plus 가 iOS 의 g 값에 9.81 을 +/// 곱해 넘긴다. 플랫폼 차이는 **표본 주기**였다(2026-10-05 실기기 — 같은 시간 흔들어 +/// iPhone 16 · Galaxy 63). 기본 주기(200ms)를 iOS 는 그대로 지켜 초당 5 표본만 오고, +/// Android 는 요청을 힌트로만 받아 훨씬 촘촘히 보낸다. 흔들기의 가속도 정점은 수십 ms +/// 라 5Hz 표본은 대부분을 놓친다. 그래서 [sampling] 을 명시하고(약 50Hz), +/// 정점 하나를 한 번만 세도록 [rearm] 아래로 내려와야 다시 센다(히스테리시스). +class ShakeDetector { + ShakeDetector({ + this.threshold = 18, + this.rearm = 13, + this.gap = const Duration(milliseconds: 220), + }); + + /// 흔들기로 보는 가속도 크기(m/s²). 정지 상태는 중력 9.8 이다. + final double threshold; + + /// 이 아래로 내려와야 다음 흔들기를 센다. 한 정점이 표본 여럿에 걸쳐도 한 번이다. + final double rearm; + + /// 두 흔들기 사이 최소 간격. 이보다 촘촘한 값은 같은 동작의 진동이다. + final Duration gap; + + /// 표본 주기. 두 플랫폼이 같은 주기로 보도록 기본값(200ms)을 쓰지 않는다. + static const sampling = SensorInterval.gameInterval; + + bool _armed = true; + DateTime _last = DateTime.fromMillisecondsSinceEpoch(0); + + /// 표본 하나를 넣는다. 흔들기 한 번으로 셀 표본이면 true. + bool add(double x, double y, double z, DateTime at) { + final magnitude = math.sqrt(x * x + y * y + z * z); + if (!magnitude.isFinite) return false; + if (magnitude < rearm) { + _armed = true; + return false; } - last = now; - yield now; + if (!_armed || magnitude < threshold || at.difference(_last) < gap) { + return false; + } + _armed = false; + _last = at; + return true; } } +/// 단말 가속도계에서 흔들기 한 번마다 그 시각을 낸다. +Stream deviceShakes() { + final detector = ShakeDetector(); + return accelerometerEventStream(samplingPeriod: ShakeDetector.sampling) + .map((e) => (e, DateTime.now())) + .where((s) => detector.add(s.$1.x, s.$1.y, s.$1.z, s.$2)) + .map((s) => s.$2); +} + /// Music Shake 구간 판정표 — `fn-quest-verify-music-shake` 가 받는 **증거**(원장 B-24). /// /// 곡의 박(`quests.bpm`)마다 한 구간이다. 구간 박 시각 ±반 박 안에 흔들기가 있으면 맞춘 것, diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 5543c4e..b9f0864 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -799,5 +799,12 @@ "homeIncubator": "Fandom Planet Incubator", "homeGrowTogether": "Grow together", "homeArtistNews": "Artist News", - "growthConditionsTitle": "Growth conditions" + "growthConditionsTitle": "Growth conditions", + "eclipseButtonSoon": "Eclipse in {time}!\nTap the planet to stop it!", + "eclipseButtonActive": "The eclipse has started!\nShake your phone within {seconds}s to stop it!", + "eclipseSec": "{seconds}s", + "eclipseMinSec": "{minutes}m {seconds}s", + "eclipseOverlayTitle": "Meteor impact\nis starting!", + "eclipseLiveOccurred": "An eclipse has hit planet {name}!", + "eclipseMyShakes": "My valid shakes {count} / {total}" } diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index 4a1f1b7..610c248 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -799,5 +799,12 @@ "homeIncubator": "Incubadora de planetas fandom", "homeGrowTogether": "Crecer juntos", "homeArtistNews": "Noticias del artista", - "growthConditionsTitle": "Condiciones de crecimiento" + "growthConditionsTitle": "Condiciones de crecimiento", + "eclipseButtonSoon": "¡Eclipse en {time}!\n¡Toca el planeta para detenerlo!", + "eclipseButtonActive": "¡El eclipse ha comenzado!\n¡Agita el teléfono en {seconds} s para detenerlo!", + "eclipseSec": "{seconds} s", + "eclipseMinSec": "{minutes} min {seconds} s", + "eclipseOverlayTitle": "¡El impacto de meteoritos\nva a comenzar!", + "eclipseLiveOccurred": "¡Un eclipse ha llegado al planeta {name}!", + "eclipseMyShakes": "Mis sacudidas válidas {count} / {total}" } diff --git a/app/lib/l10n/app_ja.arb b/app/lib/l10n/app_ja.arb index e9e8f05..e58b6f2 100644 --- a/app/lib/l10n/app_ja.arb +++ b/app/lib/l10n/app_ja.arb @@ -799,5 +799,12 @@ "homeIncubator": "ファンダム惑星インキュベーター", "homeGrowTogether": "一緒に成長する", "homeArtistNews": "アーティストニュース", - "growthConditionsTitle": "成長条件" + "growthConditionsTitle": "成長条件", + "eclipseButtonSoon": "{time}後にイクリプスが発生します!\n惑星をタップしてイクリプスを防ぎましょう!", + "eclipseButtonActive": "イクリプスが発生しました!\n{seconds}秒以内にスマホを振って防ぎましょう!", + "eclipseSec": "{seconds}秒", + "eclipseMinSec": "{minutes}分{seconds}秒", + "eclipseOverlayTitle": "惑星への隕石衝突が\n始まります!", + "eclipseLiveOccurred": "{name}惑星にイクリプスが発生しました!", + "eclipseMyShakes": "自分の有効Shake {count} / {total}回" } diff --git a/app/lib/l10n/app_ko.arb b/app/lib/l10n/app_ko.arb index 5ddff88..a36f8e3 100644 --- a/app/lib/l10n/app_ko.arb +++ b/app/lib/l10n/app_ko.arb @@ -1653,5 +1653,60 @@ "homeIncubator": "팬덤행성 인큐베이터", "homeGrowTogether": "함께 성장하기", "homeArtistNews": "아티스트 소식", - "growthConditionsTitle": "성장 조건" + "growthConditionsTitle": "성장 조건", + "eclipseButtonSoon": "{time} 후 이클립스가 발생해요!\n행성을 클릭해서 이클립스를 막으세요!", + "@eclipseButtonSoon": { + "placeholders": { + "time": { + "type": "String" + } + } + }, + "eclipseButtonActive": "이클립스가 발생했어요!\n{seconds}초 안에 휴대폰을 흔들어 막으세요!", + "@eclipseButtonActive": { + "placeholders": { + "seconds": { + "type": "String" + } + } + }, + "eclipseSec": "{seconds}초", + "@eclipseSec": { + "placeholders": { + "seconds": { + "type": "String" + } + } + }, + "eclipseMinSec": "{minutes}분 {seconds}초", + "@eclipseMinSec": { + "placeholders": { + "minutes": { + "type": "String" + }, + "seconds": { + "type": "String" + } + } + }, + "eclipseOverlayTitle": "행성 운석 충돌이\n시작됩니다!", + "eclipseLiveOccurred": "{name} 행성에 이클립스가 발생했어요!", + "@eclipseLiveOccurred": { + "placeholders": { + "name": { + "type": "String" + } + } + }, + "eclipseMyShakes": "내 유효 Shake {count} / {total}회", + "@eclipseMyShakes": { + "placeholders": { + "count": { + "type": "String" + }, + "total": { + "type": "String" + } + } + } } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 621b6ac..176d601 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -4915,6 +4915,48 @@ abstract class AppLocalizations { /// In ko, this message translates to: /// **'성장 조건'** String get growthConditionsTitle; + + /// No description provided for @eclipseButtonSoon. + /// + /// In ko, this message translates to: + /// **'{time} 후 이클립스가 발생해요!\n행성을 클릭해서 이클립스를 막으세요!'** + String eclipseButtonSoon(String time); + + /// No description provided for @eclipseButtonActive. + /// + /// In ko, this message translates to: + /// **'이클립스가 발생했어요!\n{seconds}초 안에 휴대폰을 흔들어 막으세요!'** + String eclipseButtonActive(String seconds); + + /// No description provided for @eclipseSec. + /// + /// In ko, this message translates to: + /// **'{seconds}초'** + String eclipseSec(String seconds); + + /// No description provided for @eclipseMinSec. + /// + /// In ko, this message translates to: + /// **'{minutes}분 {seconds}초'** + String eclipseMinSec(String minutes, String seconds); + + /// No description provided for @eclipseOverlayTitle. + /// + /// In ko, this message translates to: + /// **'행성 운석 충돌이\n시작됩니다!'** + String get eclipseOverlayTitle; + + /// No description provided for @eclipseLiveOccurred. + /// + /// In ko, this message translates to: + /// **'{name} 행성에 이클립스가 발생했어요!'** + String eclipseLiveOccurred(String name); + + /// No description provided for @eclipseMyShakes. + /// + /// In ko, this message translates to: + /// **'내 유효 Shake {count} / {total}회'** + String eclipseMyShakes(String count, String total); } class _AppLocalizationsDelegate diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 6a5ca84..fa36746 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -2788,4 +2788,37 @@ class AppLocalizationsEn extends AppLocalizations { @override String get growthConditionsTitle => 'Growth conditions'; + + @override + String eclipseButtonSoon(String time) { + return 'Eclipse in $time!\nTap the planet to stop it!'; + } + + @override + String eclipseButtonActive(String seconds) { + return 'The eclipse has started!\nShake your phone within ${seconds}s to stop it!'; + } + + @override + String eclipseSec(String seconds) { + return '${seconds}s'; + } + + @override + String eclipseMinSec(String minutes, String seconds) { + return '${minutes}m ${seconds}s'; + } + + @override + String get eclipseOverlayTitle => 'Meteor impact\nis starting!'; + + @override + String eclipseLiveOccurred(String name) { + return 'An eclipse has hit planet $name!'; + } + + @override + String eclipseMyShakes(String count, String total) { + return 'My valid shakes $count / $total'; + } } diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index 62dbb90..21e2b70 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -2800,4 +2800,37 @@ class AppLocalizationsEs extends AppLocalizations { @override String get growthConditionsTitle => 'Condiciones de crecimiento'; + + @override + String eclipseButtonSoon(String time) { + return '¡Eclipse en $time!\n¡Toca el planeta para detenerlo!'; + } + + @override + String eclipseButtonActive(String seconds) { + return '¡El eclipse ha comenzado!\n¡Agita el teléfono en $seconds s para detenerlo!'; + } + + @override + String eclipseSec(String seconds) { + return '$seconds s'; + } + + @override + String eclipseMinSec(String minutes, String seconds) { + return '$minutes min $seconds s'; + } + + @override + String get eclipseOverlayTitle => '¡El impacto de meteoritos\nva a comenzar!'; + + @override + String eclipseLiveOccurred(String name) { + return '¡Un eclipse ha llegado al planeta $name!'; + } + + @override + String eclipseMyShakes(String count, String total) { + return 'Mis sacudidas válidas $count / $total'; + } } diff --git a/app/lib/l10n/app_localizations_ja.dart b/app/lib/l10n/app_localizations_ja.dart index 408a0f2..cf12645 100644 --- a/app/lib/l10n/app_localizations_ja.dart +++ b/app/lib/l10n/app_localizations_ja.dart @@ -2695,4 +2695,37 @@ class AppLocalizationsJa extends AppLocalizations { @override String get growthConditionsTitle => '成長条件'; + + @override + String eclipseButtonSoon(String time) { + return '$time後にイクリプスが発生します!\n惑星をタップしてイクリプスを防ぎましょう!'; + } + + @override + String eclipseButtonActive(String seconds) { + return 'イクリプスが発生しました!\n$seconds秒以内にスマホを振って防ぎましょう!'; + } + + @override + String eclipseSec(String seconds) { + return '$seconds秒'; + } + + @override + String eclipseMinSec(String minutes, String seconds) { + return '$minutes分$seconds秒'; + } + + @override + String get eclipseOverlayTitle => '惑星への隕石衝突が\n始まります!'; + + @override + String eclipseLiveOccurred(String name) { + return '$name惑星にイクリプスが発生しました!'; + } + + @override + String eclipseMyShakes(String count, String total) { + return '自分の有効Shake $count / $total回'; + } } diff --git a/app/lib/l10n/app_localizations_ko.dart b/app/lib/l10n/app_localizations_ko.dart index 148b18e..49494b2 100644 --- a/app/lib/l10n/app_localizations_ko.dart +++ b/app/lib/l10n/app_localizations_ko.dart @@ -2697,4 +2697,37 @@ class AppLocalizationsKo extends AppLocalizations { @override String get growthConditionsTitle => '성장 조건'; + + @override + String eclipseButtonSoon(String time) { + return '$time 후 이클립스가 발생해요!\n행성을 클릭해서 이클립스를 막으세요!'; + } + + @override + String eclipseButtonActive(String seconds) { + return '이클립스가 발생했어요!\n$seconds초 안에 휴대폰을 흔들어 막으세요!'; + } + + @override + String eclipseSec(String seconds) { + return '$seconds초'; + } + + @override + String eclipseMinSec(String minutes, String seconds) { + return '$minutes분 $seconds초'; + } + + @override + String get eclipseOverlayTitle => '행성 운석 충돌이\n시작됩니다!'; + + @override + String eclipseLiveOccurred(String name) { + return '$name 행성에 이클립스가 발생했어요!'; + } + + @override + String eclipseMyShakes(String count, String total) { + return '내 유효 Shake $count / $total회'; + } } diff --git a/app/lib/l10n/app_localizations_zh.dart b/app/lib/l10n/app_localizations_zh.dart index ae5798f..6624522 100644 --- a/app/lib/l10n/app_localizations_zh.dart +++ b/app/lib/l10n/app_localizations_zh.dart @@ -2692,4 +2692,37 @@ class AppLocalizationsZh extends AppLocalizations { @override String get growthConditionsTitle => '成长条件'; + + @override + String eclipseButtonSoon(String time) { + return '$time后将发生日食!\n点击行星来阻止日食!'; + } + + @override + String eclipseButtonActive(String seconds) { + return '日食已发生!\n请在$seconds秒内摇动手机来阻止!'; + } + + @override + String eclipseSec(String seconds) { + return '$seconds秒'; + } + + @override + String eclipseMinSec(String minutes, String seconds) { + return '$minutes分$seconds秒'; + } + + @override + String get eclipseOverlayTitle => '陨石即将\n撞击行星!'; + + @override + String eclipseLiveOccurred(String name) { + return '$name行星发生了日食!'; + } + + @override + String eclipseMyShakes(String count, String total) { + return '我的有效摇动 $count / $total次'; + } } diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb index fbed7ce..b52e7b4 100644 --- a/app/lib/l10n/app_zh.arb +++ b/app/lib/l10n/app_zh.arb @@ -799,5 +799,12 @@ "homeIncubator": "粉丝星球孵化器", "homeGrowTogether": "一起成长", "homeArtistNews": "艺人动态", - "growthConditionsTitle": "成长条件" + "growthConditionsTitle": "成长条件", + "eclipseButtonSoon": "{time}后将发生日食!\n点击行星来阻止日食!", + "eclipseButtonActive": "日食已发生!\n请在{seconds}秒内摇动手机来阻止!", + "eclipseSec": "{seconds}秒", + "eclipseMinSec": "{minutes}分{seconds}秒", + "eclipseOverlayTitle": "陨石即将\n撞击行星!", + "eclipseLiveOccurred": "{name}行星发生了日食!", + "eclipseMyShakes": "我的有效摇动 {count} / {total}次" } diff --git a/app/lib/l10n/remote_app_localizations.dart b/app/lib/l10n/remote_app_localizations.dart index e710210..64c735a 100644 --- a/app/lib/l10n/remote_app_localizations.dart +++ b/app/lib/l10n/remote_app_localizations.dart @@ -562,6 +562,12 @@ class RemoteAppLocalizations extends AppLocalizations { @override String eclipseBannerDefense(String count, String target) => _fill('eclipseBannerDefense', {'count': count, 'target': target}) ?? _base.eclipseBannerDefense(count, target); + @override + String eclipseButtonActive(String seconds) => _fill('eclipseButtonActive', {'seconds': seconds}) ?? _base.eclipseButtonActive(seconds); + + @override + String eclipseButtonSoon(String time) => _fill('eclipseButtonSoon', {'time': time}) ?? _base.eclipseButtonSoon(time); + @override String get eclipseCancelBody => _remote['eclipseCancelBody'] ?? _base.eclipseCancelBody; @@ -583,15 +589,27 @@ class RemoteAppLocalizations extends AppLocalizations { @override String get eclipseFailTitle => _remote['eclipseFailTitle'] ?? _base.eclipseFailTitle; + @override + String eclipseLiveOccurred(String name) => _fill('eclipseLiveOccurred', {'name': name}) ?? _base.eclipseLiveOccurred(name); + + @override + String eclipseMinSec(String minutes, String seconds) => _fill('eclipseMinSec', {'minutes': minutes, 'seconds': seconds}) ?? _base.eclipseMinSec(minutes, seconds); + @override String eclipseMyCount(String count) => _fill('eclipseMyCount', {'count': count}) ?? _base.eclipseMyCount(count); + @override + String eclipseMyShakes(String count, String total) => _fill('eclipseMyShakes', {'count': count, 'total': total}) ?? _base.eclipseMyShakes(count, total); + @override String eclipseNoPopNote(String count) => _fill('eclipseNoPopNote', {'count': count}) ?? _base.eclipseNoPopNote(count); @override String get eclipseNone => _remote['eclipseNone'] ?? _base.eclipseNone; + @override + String get eclipseOverlayTitle => _remote['eclipseOverlayTitle'] ?? _base.eclipseOverlayTitle; + @override String get eclipsePendingBody => _remote['eclipsePendingBody'] ?? _base.eclipsePendingBody; @@ -610,6 +628,9 @@ class RemoteAppLocalizations extends AppLocalizations { @override String get eclipseRetrySame => _remote['eclipseRetrySame'] ?? _base.eclipseRetrySame; + @override + String eclipseSec(String seconds) => _fill('eclipseSec', {'seconds': seconds}) ?? _base.eclipseSec(seconds); + @override String eclipseSeconds(String seconds) => _fill('eclipseSeconds', {'seconds': seconds}) ?? _base.eclipseSeconds(seconds); diff --git a/app/test/eclipse_figma_flow_test.dart b/app/test/eclipse_figma_flow_test.dart new file mode 100644 index 0000000..0aab36f --- /dev/null +++ b/app/test/eclipse_figma_flow_test.dart @@ -0,0 +1,225 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sensors_plus/sensors_plus.dart' show SensorInterval; + +import 'package:fancake/core/theme/app_theme.dart'; +import 'package:fancake/features/eclipse/eclipse_defend_screen.dart'; +import 'package:fancake/features/eclipse/widgets/eclipse_event_button.dart'; +import 'package:fancake/features/home/home_data.dart'; +import 'package:fancake/features/home/home_screen.dart'; +import 'package:fancake/features/quest/quest_data.dart'; +import 'package:fancake/features/quest/quest_evidence.dart'; + +import 'eclipse_stubs.dart'; + +/// 이클립스 Figma 흐름 — HOME 이벤트 버튼(`516:88124`) → 상세(`373:444865`) → +/// 발생 직전 오버레이(`373:444976`) → 방어(`373:445267`) → 결과, 그리고 흔들기 판정. +Future _pump(WidgetTester tester, Widget home) async { + tester.view.physicalSize = const Size(1080, 2400); + tester.view.devicePixelRatio = 2; + addTearDown(tester.view.reset); + await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: home)); + for (var i = 0; i < 6; i++) { + await tester.pump(const Duration(milliseconds: 120)); + } +} + +void main() { + group('흔들기 판정 (iOS 16 · Galaxy 63 실기기 편차)', () { + test('표본 주기를 명시한다 — 기본 200ms(iOS 5Hz)를 쓰지 않는다', () { + expect(ShakeDetector.sampling, SensorInterval.gameInterval); + }); + + /// 흔들기 [count] 번을 50Hz 로 흉내 낸다. 정점마다 표본 여러 개가 문턱을 넘는다. + int countAt50Hz(int count, {Duration period = const Duration(milliseconds: 300)}) { + final d = ShakeDetector(); + var at = DateTime(2026, 10, 5); + var hits = 0; + for (var i = 0; i < count; i++) { + // 정점 3 표본(60ms) 연속 문턱 위 → 나머지는 중력 근처. + for (final m in [20.0, 24.0, 21.0]) { + if (d.add(m, 0, 0, at)) hits++; + at = at.add(const Duration(milliseconds: 20)); + } + for (var t = 60; t < period.inMilliseconds; t += 20) { + if (d.add(9.8, 0, 0, at)) hits++; + at = at.add(const Duration(milliseconds: 20)); + } + } + return hits; + } + + test('정점 하나는 표본이 여럿이어도 한 번이다', () { + expect(countAt50Hz(20), 20); + }); + + test('문턱 아래로 내려오지 않으면 다시 세지 않는다', () { + final d = ShakeDetector(); + final t0 = DateTime(2026, 10, 5); + expect(d.add(25, 0, 0, t0), isTrue); + // 1초 내내 문턱 위 — 같은 동작이다. + for (var i = 1; i <= 50; i++) { + expect(d.add(25, 0, 0, t0.add(Duration(milliseconds: 20 * i))), isFalse); + } + expect(d.add(9.8, 0, 0, t0.add(const Duration(seconds: 2))), isFalse); + expect(d.add(25, 0, 0, t0.add(const Duration(milliseconds: 2020))), isTrue); + }); + + test('간격보다 촘촘한 정점은 같은 동작의 진동이다', () { + expect(countAt50Hz(10, period: const Duration(milliseconds: 120)), lessThan(10)); + }); + }); + + group('HOME 이클립스 이벤트 버튼 (516:88125)', () { + testWidgets('경보(scheduled) 단계부터 잔여 시간을 띄우고 누르면 이클립스 상세로 간다', (tester) async { + final repo = FakeEclipseRepository( + state: eclipseFixture( + scheduled: true, + startAt: DateTime.now().add(const Duration(minutes: 2, seconds: 30)), + endsAt: DateTime.now().add(const Duration(minutes: 3, seconds: 30)), + ), + ); + addTearDown(repo.dispose); + await _pump( + tester, + Scaffold(body: Center(child: EclipseEventButton(repository: repo, fandomId: 'bts'))), + ); + expect(find.textContaining('후 이클립스가 발생해요!'), findsOneWidget); + expect(find.textContaining('2분'), findsOneWidget); + + await tester.tap(find.byType(EclipseEventButton)); + for (var i = 0; i < 6; i++) { + await tester.pump(const Duration(milliseconds: 120)); + } + expect(find.byType(EclipseDefendScreen), findsOneWidget); + expect(find.textContaining('이클립스 발생 남은 시간'), findsOneWidget); + }); + + testWidgets('발생 뒤에는 남은 방어 초를, 결과가 나면 접는다', (tester) async { + final repo = FakeEclipseRepository( + state: eclipseFixture( + startAt: DateTime.now().subtract(const Duration(seconds: 10)), + endsAt: DateTime.now().add(const Duration(seconds: 50)), + ), + ); + addTearDown(repo.dispose); + await _pump( + tester, + Scaffold(body: Center(child: EclipseEventButton(repository: repo, fandomId: 'bts'))), + ); + expect(find.textContaining('이클립스가 발생했어요!'), findsOneWidget); + + repo.push(eclipseFixture(defended: true)); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.textContaining('이클립스'), findsNothing); + }); + + testWidgets('HOME 라이브 띠가 이클립스 발생을 알리고 행성 아래 버튼이 뜬다', (tester) async { + final repo = FakeEclipseRepository( + state: eclipseFixture( + startAt: DateTime.now().subtract(const Duration(seconds: 5)), + endsAt: DateTime.now().add(const Duration(seconds: 55)), + ), + ); + addTearDown(repo.dispose); + await _pump( + tester, + HomeScreen( + repository: const SampleHomeRepository(), + questRepository: const SampleQuestRepository(), + eclipse: repo, + ), + ); + expect(find.text('BTS 행성에 이클립스가 발생했어요!'), findsOneWidget); + expect(find.byType(EclipseEventButton), findsOneWidget); + // Figma 에 없는 카드형 배너는 HOME 에서 빠졌다. + expect(find.textContaining('이클립스 발동 —'), findsNothing); + }); + }); + + group('이클립스 상세 → 오버레이 → 방어 (373:444865 · 373:444976 · 373:445267)', () { + testWidgets('발생 전은 상세 — 아래에 발생 남은 시간', (tester) async { + final repo = FakeEclipseRepository( + state: eclipseFixture( + scheduled: true, + startAt: DateTime.now().add(const Duration(seconds: 40)), + endsAt: DateTime.now().add(const Duration(seconds: 100)), + ), + ); + addTearDown(repo.dispose); + await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts')); + expect(find.text('휴대폰을 흔들어\n행성 운석 충돌을 막으세요!'), findsOneWidget); + expect(find.textContaining('이클립스 발생 남은 시간'), findsOneWidget); + expect(find.text('흔들기 어려우면 눌러서 참여'), findsNothing); + }); + + testWidgets('발생 3초 전부터 오버레이 카운트다운', (tester) async { + final repo = FakeEclipseRepository( + state: eclipseFixture( + scheduled: true, + startAt: DateTime.now().add(const Duration(milliseconds: 2500)), + endsAt: DateTime.now().add(const Duration(seconds: 62)), + ), + ); + addTearDown(repo.dispose); + await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts')); + expect(find.text('행성 운석 충돌이\n시작됩니다!'), findsOneWidget); + expect(find.text('3'), findsOneWidget); + }); + + testWidgets('방어 중 진행 표시 — 내 유효 Shake / 인정 횟수 · 팬덤 참여자 / 목표', (tester) async { + final repo = FakeEclipseRepository( + state: eclipseFixture( + defense: 3, + targetDefense: 10, + startAt: DateTime.now().subtract(const Duration(seconds: 1)), + endsAt: DateTime.now().add(const Duration(seconds: 59)), + ), + ); + addTearDown(repo.dispose); + await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts')); + expect(find.textContaining('초간 휴대폰을 흔들어'), findsOneWidget); + expect(find.text('내 유효 Shake 0 / 60회'), findsOneWidget); + expect(find.text('유효 참여 3 / 10명'), findsOneWidget); + + await tester.tap(find.text('흔들기 어려우면 눌러서 참여')); + for (var i = 0; i < 8; i++) { + await tester.pump(const Duration(milliseconds: 120)); + } + expect(find.text('내 유효 Shake 1 / 60회'), findsOneWidget); + }); + + testWidgets('발생 직후 서버가 아직 scheduled 면 모았다가 active 가 되면 보낸다', (tester) async { + final startAt = DateTime.now().subtract(const Duration(seconds: 1)); + final endsAt = DateTime.now().add(const Duration(seconds: 59)); + final repo = FakeEclipseRepository( + state: eclipseFixture(scheduled: true, startAt: startAt, endsAt: endsAt), + ); + addTearDown(repo.dispose); + await _pump(tester, EclipseDefendScreen(repository: repo, fandomId: 'bts')); + await tester.tap(find.text('흔들기 어려우면 눌러서 참여')); + for (var i = 0; i < 8; i++) { + await tester.pump(const Duration(milliseconds: 120)); + } + expect(repo.calls, isEmpty); + + repo.push(eclipseFixture(startAt: startAt, endsAt: endsAt)); + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 120)); + } + expect(repo.calls, hasLength(1)); + expect(repo.calls.single.$3, 1); + }); + }); + + group('Music Shake 보상 표시 (원장 B-24)', () { + test('퀘스트 rewardPop 이 없으면 서버와 같은 운영값 stepsPop 을 기본액으로 적는다', () { + expect(QuestType.musicShake.rewardFrom(const {}, const {'stepsPop': 50}), 50); + expect( + QuestType.musicShake.rewardFrom(const {'rewardPop': 30}, const {'stepsPop': 50}), + 30, + ); + expect(QuestType.musicShake.rewardFrom(const {}, const {}), isNull); + }); + }); +} diff --git a/app/test/eclipse_stubs.dart b/app/test/eclipse_stubs.dart index b55ba35..b69994c 100644 --- a/app/test/eclipse_stubs.dart +++ b/app/test/eclipse_stubs.dart @@ -12,7 +12,7 @@ class NoEclipseRepository implements EclipseRepository { const NoEclipseRepository(); @override - Stream watch({String? fandomId, bool includeResults = false}) => Stream.value(null); + Stream watch({String? fandomId, bool includeResults = false, bool includeScheduled = false}) => Stream.value(null); @override Stream> liveEvents({String? fandomId, int limit = 20}) => @@ -69,7 +69,7 @@ class FakeEclipseRepository implements EclipseRepository { } @override - Stream watch({String? fandomId, bool includeResults = false}) async* { + Stream watch({String? fandomId, bool includeResults = false, bool includeScheduled = false}) async* { yield _last; yield* _eclipse.stream; } @@ -110,6 +110,8 @@ EclipseState eclipseFixture({ int defenseCount = 12, bool defended = false, EclipseResult? result, + DateTime? startAt, + bool scheduled = false, DateTime? endsAt, }) => EclipseState( fandomId: fandomId, @@ -121,6 +123,8 @@ EclipseState eclipseFixture({ defenseCount: defenseCount, defended: defended, result: result, + startAt: startAt, + scheduled: scheduled, // 마감이 없으면 화면이 「지났는가」를 물을 수 없다. 기본을 넉넉히 둔다. endsAt: endsAt ?? DateTime.now().add(const Duration(minutes: 20)), ); @@ -142,7 +146,7 @@ class CountingEclipseRepository implements EclipseRepository { int active = 0; @override - Stream watch({String? fandomId, bool includeResults = false}) { + Stream watch({String? fandomId, bool includeResults = false, bool includeScheduled = false}) { late StreamController controller; controller = StreamController( onListen: () {