이클립스 사전 경고 연출 · 브리핑 출처·게시시각 · PASSPORT 역사적 사건 Stamp

This commit is contained in:
Sn-Kinos
2026-10-08 15:43:12 +09:00
parent 62b9637db9
commit 586039da0a
26 changed files with 1223 additions and 1013 deletions
@@ -30,6 +30,7 @@ class EclipseState {
required this.defended,
this.qualifyShakes = 60,
this.startAt,
this.warnAt,
this.scheduled = false,
this.endsAt,
this.result,
@@ -73,6 +74,9 @@ class EclipseState {
/// 없으면 이미 시작한 회차로 본다.
final DateTime? startAt;
/// 사전 경고 시각(`warnAt` = 발생 − 사전 알림, 원장 C-22 3분). 행성 그림자 접근의 출발점.
final DateTime? warnAt;
/// 서버 회차가 아직 `scheduled`(경보 단계)인가. 방어 호출은 `active` 에서만 받는다.
final bool scheduled;
@@ -452,6 +452,7 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
remaining: remaining,
pending: _pending,
pulse: _pulse,
shield: state.progress,
image: _imageOf(widget.nebula),
),
),
@@ -713,8 +714,12 @@ class _DefendPlanet extends StatelessWidget {
required this.pending,
required this.image,
this.pulse = 0,
this.shield = 0,
});
/// 고유 유효 참여자 / 목표(서버 값 [EclipseState.progress]) — Shield 조각이 이어지는 정도.
final double shield;
/// 흔들기 일련번호. 0 이면 아직 파동이 없다.
final int pulse;
@@ -767,6 +772,23 @@ class _DefendPlanet extends StatelessWidget {
CustomPaint(painter: _RemainingRing(remaining)),
),
_at(67.5, 300, 240, 240, Image(image: image)),
// Shield — 끊어진 Ring 이 참여자 비율만큼 이어지고 목표 달성 시 완전한 원(v1.5 §5.1).
// 값이 바뀌면 Shield Pulse 와 같은 길이로 이어 붙인다. 동작 줄이기면 바로 바뀐다.
_at(
48,
280,
280,
280,
TweenAnimationBuilder<double>(
key: const ValueKey('shield-ring'),
tween: Tween(end: shield.clamp(0.0, 1.0)),
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: shieldPulseDuration,
builder: (context, p, _) =>
CustomPaint(painter: ShieldRingPainter(p)),
),
),
// Shield Pulse — 흔들기마다 행성 가장자리에서 원형 파동이 한 번 퍼진다. 동작 줄이기면 그리지 않는다.
if (pulse > 0 && !MediaQuery.disableAnimationsOf(context))
_at(
@@ -978,3 +1000,53 @@ class ShieldPulsePainter extends CustomPainter {
@override
bool shouldRepaint(ShieldPulsePainter old) => old.t != t;
}
/// Shield Ring 조각 수. 원문(v1.5 §5.1)에 수치가 없는 임시값(해석 확인 항목).
const shieldSegments = 12;
/// 진행 [p] 0~1 → Ring 조각들의 (시작각, 길이). 처음엔 조각 사이 틈이 절반이고, 참여자가 늘수록
/// 틈이 좁아져 이어지며, 목표 달성(1)이면 틈 없는 원 하나다.
List<(double, double)> shieldArcs(double p) {
if (p >= 1) return const [(-math.pi / 2, 2 * math.pi)];
const slot = 2 * math.pi / shieldSegments;
final sweep = slot * (0.5 + 0.5 * p.clamp(0.0, 1.0));
return [
for (var i = 0; i < shieldSegments; i++)
(-math.pi / 2 + i * slot + (slot - sweep) / 2, sweep),
];
}
/// 행성(240)과 잔여 시간 원호(280) 사이의 Shield Ring. 완성되면 바깥 번짐을 한 겹 더한다.
class ShieldRingPainter extends CustomPainter {
const ShieldRingPainter(this.progress);
final double progress;
@override
void paint(Canvas canvas, Size size) {
final r = size.shortestSide / 2 * (260 / 280);
final rect = Rect.fromCircle(center: size.center(Offset.zero), radius: r);
final full = progress >= 1;
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = full ? 5 : 3
..strokeCap = StrokeCap.round
..color = AppColors.neonEmerald.withValues(alpha: 0.35 + 0.55 * progress.clamp(0.0, 1.0));
if (full) {
canvas.drawCircle(
rect.center,
r,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 12
..color = AppColors.neonEmerald.withValues(alpha: 0.25),
);
}
for (final (start, sweep) in shieldArcs(progress)) {
canvas.drawArc(rect, start, sweep, false, paint);
}
}
@override
bool shouldRepaint(ShieldRingPainter old) => old.progress != progress;
}
@@ -199,6 +199,7 @@ class FirestoreEclipseRepository implements EclipseRepository {
_ => null,
},
startAt: startAt is Timestamp ? startAt.toDate() : null,
warnAt: data['warnAt'] is Timestamp ? (data['warnAt'] as Timestamp).toDate() : null,
scheduled: data['status'] == 'scheduled',
endsAt: endsAt is Timestamp ? endsAt.toDate() : null,
);
@@ -12,9 +12,10 @@ import '../../../core/i18n/app_locale.dart';
/// *섹션 숨김, 모두 0 건이면 전체 Briefing 블록을 숨김*. 구획이 없으면 「섹션만
/// 숨김」이라는 판정 자체가 성립하지 않는다. 블록 전체 숨김은 홈 화면이 맡는다.
///
/// **한 줄은 Figma 대로 번호 + 제목만 띄운다.** 정책 원장 B-75(확정) 「각 항목에 출처명 ·
/// 게시/발행시각 · 원본 URL · 원문보기 · AI 요약 표시」는 Figma 에 배치가 없어, 줄을 누르면
/// 여는 상세 시트([showBriefingDetail])에 모두 싣는다. 시트의 「원문 보기」가 원문으로 나간다.
/// **한 줄은 Figma 의 번호 + 제목 아래에 출처명 · 게시시각을 12/500 으로 덧붙인다** — UX·FX v1.5
/// 「AI Briefing 출처 표시」(B-3 8-4). 정책 원장 B-75(확정)의 나머지(공식/팬덤 구분 · 원본 URL ·
/// 원문보기 · AI 요약 표시)는 Figma 에 배치가 없어, 줄을 누르면 여는 상세 시트([showBriefingDetail])에
/// 싣는다. 시트의 「원문 보기」가 원문으로 나간다.
class BriefingList extends StatelessWidget {
const BriefingList({super.key, required this.items, this.onOpen});
@@ -132,17 +133,35 @@ class _Row extends StatelessWidget {
),
const SizedBox(width: 28),
Expanded(
child: Text(
item.headline,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
color: AppColors.onCosmicVariant,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.headline,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
color: AppColors.onCosmicVariant,
),
),
const SizedBox(height: AppSpace.s1),
Text(
'${item.sourceName} · ${briefingStamp(item.sourceAt)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1.4,
color: AppColors.onCosmicMuted,
),
),
],
),
),
],
@@ -151,6 +170,13 @@ class _Row extends StatelessWidget {
}
}
/// 게시시각 표기 `yyyy.MM.dd HH:mm`(기기 시간대) — 줄과 상세 시트가 같이 쓴다.
String briefingStamp(DateTime sourceAt) {
String two(int v) => v.toString().padLeft(2, '0');
final at = sourceAt.toLocal();
return '${at.year}.${two(at.month)}.${two(at.day)} ${two(at.hour)}:${two(at.minute)}';
}
/// 브리핑 항목 상세 — B-75 5 요소(출처명 · 게시시각 · 원본 URL · 원문보기 · AI 요약 표시)와
/// 공식/팬덤 출처 구분을 한 시트에 싣는다.
Future<void> showBriefingDetail(
@@ -171,10 +197,7 @@ Future<void> showBriefingDetail(
'fandom' => l10n.briefingSourceFandom,
_ => null,
};
String two(int v) => v.toString().padLeft(2, '0');
final at = item.sourceAt.toLocal();
final stamp =
'${at.year}.${two(at.month)}.${two(at.day)} ${two(at.hour)}:${two(at.minute)}';
final stamp = briefingStamp(item.sourceAt);
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
+8 -1
View File
@@ -377,7 +377,14 @@ enum PlanetHistoryKind {
/// PASSPORT Stamp — Eclipse 성공 참여(FX §5.2) · 진화 기념 Emblem(상세정의 22장) · 최초 100명 Badge(별첨 B).
eclipseSuccess,
emblemEvolution,
founderBadge,
founderBadge;
/// UX·FX v1.5 §7.2 「역사적 사건」 — Planet Evolution · Eclipse 성공 참여 · 주요 Badge 획득은
/// 일반 활동 로그와 구분되는 Stamp 로 보인다. Campaign 성공은 역사 응답에 종류가 없어 아직 빠진다.
bool get isHistoric => switch (this) {
evolution || eclipseSuccess || emblemEvolution || founderBadge => true,
_ => false,
};
}
class PlanetHistoryEntry {
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'planet_data.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/ui/bottom_inset.dart';
@@ -86,47 +87,52 @@ class _PlanetHistoryScreenState extends State<PlanetHistoryScreen> {
),
const SizedBox(height: 16),
for (final item in _items)
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(switch (item.kind) {
PlanetHistoryKind.evolution => Icons.auto_awesome,
PlanetHistoryKind.hibernation => Icons.bedtime_outlined,
PlanetHistoryKind.wakeUp => Icons.wb_sunny_outlined,
PlanetHistoryKind.zap => Icons.bolt,
PlanetHistoryKind.questReward => Icons.task_alt,
PlanetHistoryKind.companyAdBonus => Icons.play_circle_outline,
PlanetHistoryKind.funding => Icons.favorite_border,
PlanetHistoryKind.eclipseSuccess => Icons.shield_outlined,
PlanetHistoryKind.emblemEvolution => Icons.military_tech_outlined,
PlanetHistoryKind.founderBadge => Icons.workspace_premium_outlined,
}),
title: Text(switch (item.kind) {
PlanetHistoryKind.evolution => context.l10n.historyEvolution(
'${item.fromStage}',
'${item.toStage}',
_Stamp(
historic: item.kind.isHistoric,
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(switch (item.kind) {
PlanetHistoryKind.evolution => Icons.auto_awesome,
PlanetHistoryKind.hibernation => Icons.bedtime_outlined,
PlanetHistoryKind.wakeUp => Icons.wb_sunny_outlined,
PlanetHistoryKind.zap => Icons.bolt,
PlanetHistoryKind.questReward => Icons.task_alt,
PlanetHistoryKind.companyAdBonus => Icons.play_circle_outline,
PlanetHistoryKind.funding => Icons.favorite_border,
PlanetHistoryKind.eclipseSuccess => Icons.shield_outlined,
PlanetHistoryKind.emblemEvolution =>
Icons.military_tech_outlined,
PlanetHistoryKind.founderBadge =>
Icons.workspace_premium_outlined,
}),
title: Text(switch (item.kind) {
PlanetHistoryKind.evolution => context.l10n.historyEvolution(
'${item.fromStage}',
'${item.toStage}',
),
PlanetHistoryKind.hibernation =>
context.l10n.historyHibernation,
PlanetHistoryKind.wakeUp => context.l10n.historyWakeUp,
PlanetHistoryKind.zap => context.l10n.historyZap,
PlanetHistoryKind.questReward => context.l10n.historyQuest,
PlanetHistoryKind.companyAdBonus => context.l10n.historyAdBonus,
PlanetHistoryKind.funding => context.l10n.historyFunding,
PlanetHistoryKind.eclipseSuccess =>
context.l10n.historyEclipseSuccess,
PlanetHistoryKind.emblemEvolution =>
'${context.l10n.historyEmblemEvolution} · STAGE ${item.toStage ?? ''}',
PlanetHistoryKind.founderBadge =>
'${context.l10n.historyFounderBadge} · #${(item.memberNo ?? 0).toString().padLeft(3, '0')}',
}),
subtitle: Text(
[
MaterialLocalizations.of(context)
.formatMediumDate(item.occurredAt.toLocal()),
if (item.amount != null)
context.l10n.historyGrowthPop('${item.amount}'),
if (item.participationConfirmed) context.l10n.historyJoined,
].join('\n'),
),
PlanetHistoryKind.hibernation => context.l10n.historyHibernation,
PlanetHistoryKind.wakeUp => context.l10n.historyWakeUp,
PlanetHistoryKind.zap => context.l10n.historyZap,
PlanetHistoryKind.questReward => context.l10n.historyQuest,
PlanetHistoryKind.companyAdBonus => context.l10n.historyAdBonus,
PlanetHistoryKind.funding => context.l10n.historyFunding,
PlanetHistoryKind.eclipseSuccess =>
context.l10n.historyEclipseSuccess,
PlanetHistoryKind.emblemEvolution =>
'${context.l10n.historyEmblemEvolution} · STAGE ${item.toStage ?? ''}',
PlanetHistoryKind.founderBadge =>
'${context.l10n.historyFounderBadge} · #${(item.memberNo ?? 0).toString().padLeft(3, '0')}',
}),
subtitle: Text(
[
MaterialLocalizations.of(
context,
).formatMediumDate(item.occurredAt.toLocal()),
if (item.amount != null)
context.l10n.historyGrowthPop('${item.amount}'),
if (item.participationConfirmed) context.l10n.historyJoined,
].join('\n'),
),
),
if (_loading) const Center(child: CircularProgressIndicator()),
@@ -145,3 +151,36 @@ class _PlanetHistoryScreenState extends State<PlanetHistoryScreen> {
),
);
}
/// 「역사적 사건」(UX·FX v1.5 §7.2)은 테두리 Stamp 와 표지로 일반 활동 줄과 가른다.
/// 앱 Figma 에 PASSPORT 기록 화면이 없어 화면 테마 색만 쓴다.
class _Stamp extends StatelessWidget {
const _Stamp({required this.historic, required this.child});
final bool historic;
final Widget child;
@override
Widget build(BuildContext context) {
if (!historic) return child;
final scheme = Theme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
decoration: BoxDecoration(
border: Border.all(color: scheme.primary),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.historyHistoric,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(color: scheme.primary),
),
child,
],
),
);
}
}