UX·FX v1.5 — 햅틱 단계 · 라이브 띠 · HERO 전광판 · Push 우선순위 · Shield · 이클립스 성공·실패 연출

This commit is contained in:
Sn-Kinos
2026-10-06 20:12:44 +09:00
parent a04626b003
commit 62b9637db9
30 changed files with 851 additions and 118 deletions
@@ -22,6 +22,7 @@ import 'widgets/campaign_list_card.dart' show CampaignAuthor;
import 'widgets/campaign_rank.dart';
import 'widgets/campaign_share_dialog.dart';
import 'widgets/campaign_style.dart';
import '../../core/ui/fx_intensity.dart';
/// 캠페인 상세 — 화면 대조 원장 F5 · Figma `272:245738`.
///
@@ -998,6 +999,7 @@ class _CampaignDetailScreenState extends State<CampaignDetailScreen> {
return;
}
if (!mounted) return;
final before = _raised;
setState(() {
_spent += applied;
_raised += applied;
@@ -1010,6 +1012,8 @@ class _CampaignDetailScreenState extends State<CampaignDetailScreen> {
_detail = fresh;
_raised = fresh.card.raisedPop;
});
// v1.5 §1 Legendary 「Campaign 100%」 — 서버가 다시 준 모금액으로 이번 후원이 목표를 넘긴 때만 1회.
if (campaignJustCompleted(before, fresh.card.raisedPop, fresh.card.goalPop)) fxHaptic(FxIntensity.legendary);
}
} on Object catch (_) {
// 다시 읽기 실패는 후원 결과와 무관하다 — 앞 화면을 그대로 둔다.
@@ -1301,3 +1305,7 @@ class _Round extends StatelessWidget {
),
);
}
/// 이번 후원 전 모금액 [before] 가 목표 [goal] 미만이었고 서버 확정 모금액 [raised] 가 목표에 닿았는가.
bool campaignJustCompleted(int before, int raised, int goal) =>
goal > 0 && before < goal && raised >= goal;
@@ -15,6 +15,7 @@ import '../quest/quest_evidence.dart' show ShakeProbe, deviceShakes;
import 'eclipse_data.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/ui/sheet_route.dart';
import '../../core/ui/fx_intensity.dart';
/// 「흔들어 방어」 — 팬덤 공동 Shake 참여 화면.
///
@@ -67,6 +68,9 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
/// 서버가 받아들인 내 누적 참여량.
int _mine = 0;
/// 받아들인 흔들기 일련번호 — 바뀔 때마다 Shield Pulse 한 번(v1.5 §5 「유효 Shake 마다 Shield Pulse / 원형 파동」).
int _pulse = 0;
/// 화면에 보이는 내 횟수 — 서버 확정값에 아직 보내지 않았거나 보내는 중인 흔들기를 더한다.
/// 흔드는 동안 서버 왕복(약 0.7초 묶음)을 기다리면 숫자가 늦게 올라 「얼마나 잘 흔들고
/// 있는지」 알 수 없었다. 확정값이 오면 그것으로 맞춰진다.
@@ -127,10 +131,13 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
return;
final qualify = _current?.qualifyShakes ?? 0;
final before = _shown;
setState(() => _pending += 1);
setState(() {
_pending += 1;
_pulse += 1;
});
// 화면을 보지 않고 흔들어도 알 수 있게 — 한 번 셀 때마다 짧게, 인정 기준을 넘는 순간 길게.
if (qualify > 0 && before < qualify && _shown >= qualify) {
HapticFeedback.heavyImpact();
fxHaptic(FxIntensity.large);
} else {
HapticFeedback.selectionClick();
}
@@ -444,6 +451,7 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
child: _DefendPlanet(
remaining: remaining,
pending: _pending,
pulse: _pulse,
image: _imageOf(widget.nebula),
),
),
@@ -704,8 +712,12 @@ class _DefendPlanet extends StatelessWidget {
required this.remaining,
required this.pending,
required this.image,
this.pulse = 0,
});
/// 흔들기 일련번호. 0 이면 아직 파동이 없다.
final int pulse;
final AssetImage image;
/// 남은 시간 비율 1 → 0.
@@ -755,6 +767,21 @@ class _DefendPlanet extends StatelessWidget {
CustomPaint(painter: _RemainingRing(remaining)),
),
_at(67.5, 300, 240, 240, Image(image: image)),
// Shield Pulse — 흔들기마다 행성 가장자리에서 원형 파동이 한 번 퍼진다. 동작 줄이기면 그리지 않는다.
if (pulse > 0 && !MediaQuery.disableAnimationsOf(context))
_at(
48,
280,
280,
280,
TweenAnimationBuilder<double>(
key: ValueKey('shield-pulse-$pulse'),
tween: Tween(begin: 0, end: 1),
duration: shieldPulseDuration,
builder: (context, t, _) =>
CustomPaint(painter: ShieldPulsePainter(t)),
),
),
// 좌상단 운석(`Group 573`)은 내보낸 자산이 없어 `Group 575` 를 뒤집어 쓴다.
_meteor(
38,
@@ -923,3 +950,31 @@ class _OutcomeDisc extends CustomPainter {
bool shouldRepaint(_OutcomeDisc old) =>
old.color != color || old.check != check;
}
/// Shield Pulse 한 번의 길이. 원문에 수치가 없어 ZAP 에너지 이동(380ms)과 같은 짧은 길이를 쓴다(해석 확인 항목).
const shieldPulseDuration = Duration(milliseconds: 380);
/// 행성 가장자리(반경 240/280)에서 바깥으로 퍼지며 사라지는 원형 파동. [t] 0 → 1.
class ShieldPulsePainter extends CustomPainter {
const ShieldPulsePainter(this.t);
final double t;
@override
void paint(Canvas canvas, Size size) {
if (t >= 1) return;
final r = size.shortestSide / 2;
canvas.drawCircle(
size.center(Offset.zero),
r * (240 / 280 + t * (1 - 240 / 280 + 0.1)),
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 3 * (1 - t) + 1
..color = AppColors.neonEmerald.withValues(alpha: 0.8 * (1 - t)),
);
}
@override
bool shouldRepaint(ShieldPulsePainter old) => old.t != t;
}
@@ -14,13 +14,57 @@ import '../../../core/i18n/app_locale.dart';
/// 별첨 B 「Global Ticker」로 Eclipse 외의 종류가 뒤에 들어오므로 종류마다 한 줄씩
/// 늘려 간다. 모르는 종류는 공지로 읽어 티커가 멈추지 않게 한다.
String liveEventMessage(LiveEvent event) => switch (event.kind) {
LiveEventKind.eclipseDefense =>
appL10n.liveEclipseDefense(event.nickname),
LiveEventKind.artistHotTime =>
appL10n.liveHotTime(event.nickname),
LiveEventKind.heroGift =>
appL10n.liveHeroGift(event.nickname),
LiveEventKind.campaign =>
appL10n.liveCampaign(event.nickname),
LiveEventKind.eclipseDefense => appL10n.liveEclipseDefense(event.nickname),
LiveEventKind.artistHotTime => appL10n.liveHotTime(event.nickname),
LiveEventKind.heroGift => appL10n.liveHeroGift(event.nickname),
LiveEventKind.campaign => appL10n.liveCampaign(event.nickname),
LiveEventKind.notice => '[LIVE] ${event.nickname}',
};
/// 홈 라이브 띠(전광판)에 올릴 한 줄. [hero] 면 띠가 HERO TIME 참여 진입이 된다.
typedef TickerLine = ({String text, bool hero});
/// 띠 한 줄 고르기 — 원장 C-78 순서(위기 → 성취)대로 핵심 이벤트가 먼저 오른다.
///
/// 1. 이클립스 발생 소식 — 그 뒤 이벤트(방어 참여 등)가 아직 없을 때.
/// 2. 방어 중 집약 — UX·FX v1.5 §1.1 「개인 이벤트를 연속 티커로 쌓기보다 집단 상태로 집약」 ·
/// §5.1 「참여자 수 변화 중심」 · 기능명세 §2.10~§2.12 「지금 N 명이 함께 방어 중」. N 은 서버가
/// 인정한 고유 참여자 수([EclipseState.defense])이며 2명 이상일 때 집약한다(「다수」 해석 — 해석 확인 항목).
/// 3. HERO TIME — v1.5 §6 전광판 「공개 닉네임 + 대상 팬덤 + 진행 중 CTA」.
/// 4. 최신 이벤트 한 건, 없으면 [fallback].
TickerLine? tickerLine({
required EclipseState? eclipse,
required bool heroTime,
required List<String> heroNicknames,
required List<LiveEvent> events,
required String fandomName,
required DateTime now,
String? fallback,
}) {
final event = events.isEmpty ? null : events.first;
if (eclipse != null && eclipse.result == null) {
final started = eclipse.startAt;
if (event == null || started == null || event.at.isBefore(started)) {
return (
text: appL10n.eclipseLiveOccurred(eclipse.fandomName),
hero: false,
);
}
if (eclipse.canShake(now) && eclipse.defense >= 2) {
return (
text: appL10n.liveEclipseDefending('${eclipse.defense}'),
hero: false,
);
}
}
if (heroTime) {
return (
text: heroNicknames.isEmpty
? appL10n.liveHeroTime(fandomName)
: appL10n.liveHeroTimeBy(fandomName, heroNicknames.join(' · ')),
hero: true,
);
}
if (event != null) return (text: liveEventMessage(event), hero: false);
return fallback == null ? null : (text: fallback, hero: false);
}
+2 -2
View File
@@ -1,9 +1,9 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import '../../core/data/backend.dart';
import 'galaxy_data.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/ui/fx_intensity.dart';
/// HOME과 Galaxy의 동일한 기여 거래: 연타 합산, 확정 결과, 동일 키 재확인.
class ZapController extends ChangeNotifier {
@@ -22,7 +22,7 @@ class ZapController extends ChangeNotifier {
void tap(String fandomId) {
if (_disposed || sending || _batch != null || fandomId.isEmpty) return;
if (pending == 0) HapticFeedback.lightImpact();
if (pending == 0) fxHaptic(FxIntensity.small);
pending++;
touchSerial++;
notifyListeners();
+45 -21
View File
@@ -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,
+2 -2
View File
@@ -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();
}
+49 -15
View File
@@ -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,
),
),
);
@@ -1,6 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sensors_plus/sensors_plus.dart';
import '../../core/data/planet_visual_state.dart';
import '../../core/data/backend.dart';
@@ -8,6 +7,7 @@ import '../../core/ui/live_stream.dart';
import 'planet_data.dart';
import '../../core/i18n/app_locale.dart';
import '../quest/quest_evidence.dart' show ShakeDetector;
import '../../core/ui/fx_intensity.dart';
/// 수량·상한·중복 여부는 서버가 결정한다. 단말은 참여 의사와 결과만 전달한다.
class HeroParticipationSheet extends StatefulWidget {
@@ -99,7 +99,7 @@ class _HeroParticipationSheetState extends State<HeroParticipationSheet>
_received[id] = receipt;
_pendingId = null;
});
if (_foreground) HapticFeedback.lightImpact();
if (_foreground) fxHaptic(FxIntensity.small);
} catch (error) {
if (!mounted) return;
final code = error is BackendException ? error.code : null;
@@ -4,7 +4,6 @@ import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
@@ -13,6 +12,7 @@ import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import 'quest_data.dart';
import 'quest_evidence.dart';
import '../../core/ui/fx_intensity.dart';
/// 플레이어 상태 한 번 — 재생 중인가 · 영상 시각 · 끝났는가.
typedef ShakePlayerEvent = ({bool playing, Duration position, bool ended});
@@ -145,7 +145,7 @@ class _MusicShakePlayScreenState extends State<MusicShakePlayScreen> {
if (_done || !mounted) return;
_done = true;
// 완료 햅틱 Medium — UX·FX 정렬 기준 v1.5 「Music/Location Quest 완료」.
HapticFeedback.mediumImpact();
fxHaptic(FxIntensity.medium);
Navigator.of(context).pop(_judge);
}