UX·FX v1.5 — 햅틱 단계 · 라이브 띠 · HERO 전광판 · Push 우선순위 · Shield · 이클립스 성공·실패 연출
This commit is contained in:
@@ -247,11 +247,21 @@ class _LiveTickerSlot extends StatelessWidget {
|
||||
required this.eclipse,
|
||||
required this.fandomId,
|
||||
required this.fallback,
|
||||
required this.fandomName,
|
||||
required this.watchPlanet,
|
||||
this.onHero,
|
||||
});
|
||||
|
||||
final EclipseRepository eclipse;
|
||||
final String fandomId;
|
||||
final String? fallback;
|
||||
final String fandomName;
|
||||
|
||||
/// HERO TIME 여부 · 공개 닉네임 — 행성 FX 와 같은 서버 확정 원천.
|
||||
final PlanetWatch watchPlanet;
|
||||
|
||||
/// HERO TIME 줄을 눌렀을 때 — 이 행성의 진행 중 HERO TIME 참여로 바로 간다(v1.5 §6 「진입」).
|
||||
final VoidCallback? onHero;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => LiveStreamBuilder<EclipseState?>(
|
||||
@@ -261,28 +271,36 @@ class _LiveTickerSlot extends StatelessWidget {
|
||||
create: () => fandomId.isEmpty
|
||||
? Stream<EclipseState?>.value(null)
|
||||
: eclipse.watch(fandomId: fandomId),
|
||||
builder: (context, eclipseState) => LiveStreamBuilder<List<LiveEvent>>(
|
||||
key: ValueKey('live-$fandomId'),
|
||||
// 띠는 한 줄만 그리므로 최신 몇 건만 받는다.
|
||||
builder: (context, eclipseState) => LiveStreamBuilder<PlanetVisualState>(
|
||||
key: ValueKey('live-planet-$fandomId'),
|
||||
create: () => fandomId.isEmpty
|
||||
? Stream.value(const <LiveEvent>[])
|
||||
: eclipse.liveEvents(fandomId: fandomId, limit: 5),
|
||||
builder: (context, events) {
|
||||
final event = (events == null || events.isEmpty) ? null : events.first;
|
||||
final started = eclipseState?.startAt;
|
||||
// 발생 소식은 그 뒤에 온 이벤트(방어 참여 등)가 없을 때 띠에 오른다.
|
||||
final eclipseNews =
|
||||
eclipseState != null &&
|
||||
eclipseState.result == null &&
|
||||
(event == null || started == null || event.at.isBefore(started));
|
||||
final latest = eclipseNews
|
||||
? context.l10n.eclipseLiveOccurred(eclipseState.fandomName)
|
||||
: event == null
|
||||
? fallback
|
||||
: liveEventMessage(event);
|
||||
if (latest == null) return const SizedBox.shrink();
|
||||
return LiveTicker(text: latest);
|
||||
},
|
||||
? const Stream<PlanetVisualState>.empty()
|
||||
: watchPlanet(fandomId),
|
||||
builder: (context, planet) => LiveStreamBuilder<List<LiveEvent>>(
|
||||
key: ValueKey('live-$fandomId'),
|
||||
// 띠는 한 줄만 그리므로 최신 몇 건만 받는다.
|
||||
create: () => fandomId.isEmpty
|
||||
? Stream.value(const <LiveEvent>[])
|
||||
: eclipse.liveEvents(fandomId: fandomId, limit: 5),
|
||||
builder: (context, events) {
|
||||
final line = tickerLine(
|
||||
eclipse: eclipseState,
|
||||
heroTime: planet?.isHeroTime == true,
|
||||
heroNicknames: planet?.heroNicknames ?? const [],
|
||||
events: events ?? const [],
|
||||
fandomName: fandomName,
|
||||
now: DateTime.now(),
|
||||
fallback: fallback,
|
||||
);
|
||||
if (line == null) return const SizedBox.shrink();
|
||||
final hero = line.hero && onHero != null;
|
||||
return LiveTicker(
|
||||
text: line.text,
|
||||
action: hero ? context.l10n.heroParticipate : null,
|
||||
onTap: hero ? onHero : null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -532,6 +550,9 @@ class _HomeBody extends StatelessWidget {
|
||||
eclipse: eclipse,
|
||||
fandomId: data.fandomId,
|
||||
fallback: data.liveTicker,
|
||||
fandomName: data.growth.fandomName,
|
||||
watchPlanet: watchPlanet,
|
||||
onHero: _heroParticipate(context),
|
||||
),
|
||||
onClose: () => onMap(false),
|
||||
onSearch: () => _openSearch(context),
|
||||
@@ -547,6 +568,9 @@ class _HomeBody extends StatelessWidget {
|
||||
eclipse: eclipse,
|
||||
fandomId: data.fandomId,
|
||||
fallback: data.liveTicker,
|
||||
fandomName: data.growth.fandomName,
|
||||
watchPlanet: watchPlanet,
|
||||
onHero: _heroParticipate(context),
|
||||
),
|
||||
growth: growth ?? data.growth,
|
||||
fandomId: data.fandomId,
|
||||
|
||||
@@ -8,7 +8,6 @@ import '../../../core/ui/hero_time_status.dart';
|
||||
import 'growth_conditions.dart';
|
||||
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/services.dart' show HapticFeedback;
|
||||
import 'dart:ui' show ImageFilter;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -21,6 +20,7 @@ import '../../../core/data/planet_visual_state.dart';
|
||||
import '../home_data.dart';
|
||||
import '../home_screen.dart' show formatPop;
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
import '../../../core/ui/fx_intensity.dart';
|
||||
|
||||
/// 홈 상단 히어로 — Figma 사본 `252:159354` 홈의 `Frame 1984081117`.
|
||||
///
|
||||
@@ -1073,7 +1073,7 @@ class _TapShakeState extends State<_TapShake> with SingleTickerProviderStateMixi
|
||||
Future<void> _tap() async {
|
||||
final onTap = widget.onTap;
|
||||
if (onTap == null || _shake.isAnimating) return;
|
||||
HapticFeedback.lightImpact();
|
||||
fxHaptic(FxIntensity.micro);
|
||||
if (!MediaQuery.disableAnimationsOf(context)) await _shake.forward(from: 0);
|
||||
if (mounted) onTap();
|
||||
}
|
||||
|
||||
@@ -10,13 +10,59 @@ import '../../../core/theme/tokens.dart';
|
||||
///
|
||||
/// 명세 `516:87664` *동작: 이벤트 발생 시 텍스트 우측에서 좌측으로 1회 슬라이드* — 문구가
|
||||
/// 바뀔 때마다 오른쪽 밖에서 한 번 미끄러져 들어와 멈춘다.
|
||||
///
|
||||
/// [action] 이 있으면 문구 뒤에 행동 문구를 붙이고 띠 전체를 [onTap] 으로 누를 수 있다 —
|
||||
/// UX·FX v1.5 §6 전광판 「HERO TIME 진행 중 CTA · 탭 시 해당 Planet 의 진행 중 HERO TIME 으로 이동」.
|
||||
class LiveTicker extends StatelessWidget {
|
||||
const LiveTicker({super.key, required this.text});
|
||||
const LiveTicker({super.key, required this.text, this.action, this.onTap});
|
||||
|
||||
final String text;
|
||||
final String? action;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
static const _style = TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 14 / 12,
|
||||
letterSpacing: -0.3,
|
||||
color: AppColors.onCosmic,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ClipRect(
|
||||
Widget build(BuildContext context) {
|
||||
final line = Text.rich(
|
||||
TextSpan(
|
||||
text: text,
|
||||
children: [
|
||||
if (action case final cta?)
|
||||
TextSpan(
|
||||
text: ' $cta',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.cosmicAccent,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: _style,
|
||||
);
|
||||
final ticker = _slide(line);
|
||||
return onTap == null
|
||||
? ticker
|
||||
: Semantics(
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: ticker,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _slide(Widget line) => ClipRect(
|
||||
child: Container(
|
||||
height: 22,
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
|
||||
@@ -33,19 +79,7 @@ class LiveTicker extends StatelessWidget {
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 14 / 12,
|
||||
letterSpacing: -0.3,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
child: line,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user