HOME · GALAXY · 행성·성운 상세 · 팬덤 라운지 · 이클립스 화면

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent c4e9a9430e
commit 5de04ac281
44 changed files with 14898 additions and 16 deletions
@@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import '../../../core/theme/tokens.dart';
import '../../../core/ui/live_stream.dart';
import '../../home/home_screen.dart' show formatPop;
import '../../planet/fpi_ranking_screen.dart' show MockBadge;
import '../eclipse_data.dart';
import '../../../core/i18n/app_locale.dart';
/// 이클립스 인앱 경보 배너 — HOME · GALAXY 상단.
///
/// 근거: 별첨 1 ① 예외 처리 「**푸시 미수신 단말 — 인앱 워닝 배너 병행**」. 푸시가
/// 유일한 통보 수단이면 알림을 끈 사용자와 토큰이 만료된 단말은 이클립스를 모른 채
/// 침식을 맞는다. 푸시(FCM) 자체는 다른 작업 범위이고 이 배너는 그와 **병행**한다.
///
/// 배너는 세 상태를 그린다.
/// - 활성 이클립스 없음(또는 마감이 지남) → 아무것도 그리지 않는다(빈 [SizedBox]).
/// - 침식 진행 중 → 침식률 · 방어 진행률 · 남은 시간.
/// - 방어 성공(`defended`) → 회복 문구(원장 B-55 「방어 성공 시 침식분 회복」).
///
/// 방어는 **공동 Shake 참여**이며 POP 을 소모하지 않는다. 눌러 들어가는 곳은
/// 「흔들어 방어」 화면이다.
class EclipseBanner extends StatelessWidget {
const EclipseBanner({
super.key,
required this.repository,
this.fandomId,
this.onTap,
this.margin = const EdgeInsets.fromLTRB(
AppSpace.s4,
AppSpace.s2,
AppSpace.s4,
0,
),
});
final EclipseRepository repository;
/// 어느 팬덤의 이클립스인가. 주지 않으면 내 소속 팬덤이다.
final String? fandomId;
final VoidCallback? onTap;
final EdgeInsets margin;
@override
Widget build(BuildContext context) => LiveStreamBuilder<EclipseState?>(
// 대상 팬덤이 바뀌면 구독을 갈아탄다 — [LiveStreamBuilder] 주석 참고.
key: ValueKey('eclipse-${fandomId ?? 'mine'}'),
create: () => repository.watch(fandomId: fandomId),
builder: (context, state) => state == null
// 활성 이클립스가 없으면 자리도 차지하지 않는다. 빈 배너를 남겨 두면
// 홈 상단에 설명 없는 여백이 생긴다.
? const SizedBox.shrink()
: Padding(
padding: margin,
child: EclipseBannerCard(state: state, onTap: onTap),
),
);
}
/// 배너 본체. 스트림과 떼어 두어 위젯 테스트가 값을 직접 넣을 수 있게 한다.
class EclipseBannerCard extends StatelessWidget {
const EclipseBannerCard({super.key, required this.state, this.onTap});
final EclipseState state;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final won = state.defended;
final color = won ? AppColors.neonEmerald : AppColors.neonMagenta;
final left = state.remaining(DateTime.now());
return Semantics(
// 배너는 경보다. 스크린리더가 「컨테이너」로 읽으면 경보가 전달되지 않는다.
liveRegion: true,
container: true,
child: Material(
color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(AppRadius.md),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.md),
child: Container(
padding: const EdgeInsets.all(AppSpace.s3),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(color: color.withValues(alpha: 0.7)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(
won ? Icons.verified_outlined : Icons.warning_amber_rounded,
size: 18,
color: color,
),
const SizedBox(width: AppSpace.s2),
Expanded(
child: Text(
won
? context.l10n.eclipseBannerDefended(state.fandomName)
: context.l10n.eclipseBannerActive(
state.fandomName,
'${state.erosionRate}',
),
maxLines: 2,
style: t.bodyMedium?.copyWith(color: color),
),
),
if (left != null)
Text(_left(left), style: t.labelSmall),
],
),
const SizedBox(height: AppSpace.s2),
ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.full),
child: LinearProgressIndicator(
value: state.progress,
minHeight: 4,
backgroundColor: AppColors.cosmicOutline,
valueColor: AlwaysStoppedAnimation(color),
),
),
const SizedBox(height: AppSpace.s1),
Text(
// **POP 단위가 아니다** — 방어는 공동 Shake 참여이고 POP 을
// 소모하지 않는다(설계서 v1.1 §9-#5).
context.l10n.eclipseBannerDefense(
formatPop(state.defense),
formatPop(state.targetDefense),
),
style: t.labelSmall,
),
// 실데이터가 아니면 숨기지 않는다 — 대조표 3-5 · 공통 규칙 (4).
if (state.mockNote case final note?)
MockBadge(note: note),
],
),
),
),
),
);
}
/// 남은 시간 표기. 분 단위까지면 충분하다 — 방어 창은 분 단위 운영값이다.
static String _left(Duration d) => d.inHours > 0
? appL10n.timeHoursMinutesLeft('${d.inHours}', '${d.inMinutes % 60}')
: appL10n.timeMinutesLeft('${d.inMinutes}');
}
@@ -0,0 +1,27 @@
import '../../home/home_screen.dart' show formatPop;
import '../eclipse_data.dart';
import '../../../core/i18n/app_locale.dart';
/// 티커 한 줄의 문구.
///
/// Figma 홈 상단 라이브 띠의 문장 형식을 그대로 쓴다 —
/// *「[LIVE] …님의 아이템 사용으로 버프가 발동되었습니다!」*.
/// 이클립스 방어 참여는 같은 형식에 **참여량**을 싣는다. POP 이 아니다 — 방어는
/// POP 을 소모하지 않는 공동 Shake 참여다(설계서 v1.1 §9-#5).
///
/// **문구를 만드는 곳을 한 곳에 둔다.** 홈 티커와 갤럭시 섬광 배너가 각자 문장을
/// 조립하면 같은 이벤트가 두 화면에서 다르게 읽힌다.
///
/// 별첨 B 「Global Ticker」로 Eclipse 외의 종류가 뒤에 들어오므로 종류마다 한 줄씩
/// 늘려 간다. 모르는 종류는 공지로 읽어 티커가 멈추지 않게 한다.
String liveEventMessage(LiveEvent event) => switch (event.kind) {
LiveEventKind.eclipseDefense =>
appL10n.liveEclipseDefense(event.nickname, formatPop(event.defense)),
LiveEventKind.artistHotTime =>
appL10n.liveHotTime(event.nickname),
LiveEventKind.heroGift =>
appL10n.liveHeroGift(event.nickname),
LiveEventKind.campaign =>
appL10n.liveCampaign(event.nickname),
LiveEventKind.notice => '[LIVE] ${event.nickname}',
};