HOME · GALAXY · 행성·성운 상세 · 팬덤 라운지 · 이클립스 화면
This commit is contained in:
@@ -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}');
|
||||
}
|
||||
Reference in New Issue
Block a user