이클립스 사전 경고 연출 · 브리핑 출처·게시시각 · PASSPORT 역사적 사건 Stamp
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user