Files
fanuzu/app/lib/features/eclipse/widgets/eclipse_banner.dart
T

161 lines
6.4 KiB
Dart

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.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,
// 넘겨받은 동작이 없으면 「흔들어 방어」 화면을 연다 — HOME·행성 상세·GALAXY 홈
// 모두 이 기본 동작을 쓴다(배너를 눌러도 아무 일이 없던 결함).
child: EclipseBannerCard(
state: state,
onTap: onTap ?? () => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(builder: (_) => EclipseDefendScreen(
repository: repository, fandomId: state.fandomId,
)),
),
),
),
);
}
/// 배너 본체. 스트림과 떼어 두어 위젯 테스트가 값을 직접 넣을 수 있게 한다.
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}');
}