167 lines
6.5 KiB
Dart
167 lines
6.5 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.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<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(
|
|
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}');
|
|
}
|