import 'package:flutter/material.dart'; 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 상단. /// /// 근거: 별첨 1 ① 예외 처리 「**푸시 미수신 단말 — 인앱 워닝 배너 병행**」. 푸시가 /// 유일한 통보 수단이면 알림을 끈 사용자와 토큰이 만료된 단말은 이클립스를 모른 채 /// 침식을 맞는다. 푸시(FCM) 자체는 다른 작업 범위이고 이 배너는 그와 **병행**한다. /// /// 배너는 세 상태를 그린다. /// - 활성 이클립스 없음(또는 마감이 지남) → 아무것도 그리지 않는다(빈 [SizedBox]). /// - 침식 진행 중 → 침식률 · 방어 진행률 · 남은 시간. /// - 방어 성공(`defended`) → 회복 문구(원장 B-55 「방어 성공 시 침식분 회복」). /// /// 방어는 **공동 Shake 참여**이며 POP 을 소모하지 않는다. 눌러 들어가는 곳은 /// 「흔들어 방어」 화면이다. class EclipseBanner extends StatelessWidget { const EclipseBanner({ super.key, required this.repository, this.fandomId, this.onTap, this.nebula = false, this.margin = const EdgeInsets.fromLTRB( AppSpace.s4, AppSpace.s2, AppSpace.s4, 0, ), }); final EclipseRepository repository; /// 어느 팬덤의 이클립스인가. 주지 않으면 내 소속 팬덤이다. final String? fandomId; final VoidCallback? onTap; final EdgeInsets margin; /// 대상 팬덤이 아직 성운인가 — 상세 · 방어 화면이 성운 그림을 그린다. final bool nebula; @override Widget build(BuildContext context) => LiveStreamBuilder( // 대상 팬덤이 바뀌면 구독을 갈아탄다 — [LiveStreamBuilder] 주석 참고. key: ValueKey('eclipse-${fandomId ?? 'mine'}'), create: () => repository.watch(fandomId: fandomId), builder: (context, state) => state == null // 활성 이클립스가 없으면 자리도 차지하지 않는다. 빈 배너를 남겨 두면 // 홈 상단에 설명 없는 여백이 생긴다. ? const SizedBox.shrink() : Padding( padding: margin, // 넘겨받은 동작이 없으면 「흔들어 방어」 화면을 연다 — HOME·행성 상세·GALAXY 홈 // 모두 이 기본 동작을 쓴다(배너를 눌러도 아무 일이 없던 결함). child: EclipseBannerCard( state: state, onTap: onTap ?? () => Navigator.of(context, rootNavigator: true).push( eclipseDefendRoute( repository: repository, fandomId: state.fandomId, nebula: nebula, ), ), ), ), ); } /// 배너 본체. 스트림과 떼어 두어 위젯 테스트가 값을 직접 넣을 수 있게 한다. 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, ), ], ), ), ), ), ); } /// 남은 시간 표기. 분 단위까지면 충분하다 — 방어 창은 분 단위 운영값이다. static String _left(Duration d) => d.inHours > 0 ? appL10n.timeHoursMinutesLeft('${d.inHours}', '${d.inMinutes % 60}') : appL10n.timeMinutesLeft('${d.inMinutes}'); }