앱 공통 계층 — 데이터 저장소 · 서버 시계 · 푸시 · 결제·광고 · 다국어 5종 · 행성 표현 · 공통 UI · 화면 자산

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent cbfb72efe0
commit c4e9a9430e
165 changed files with 29991 additions and 54 deletions
@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
/// 0=원본, 1=입자/광량 축소, 2=Blur·텍스처까지 축소. 상태/결과는 그대로 둔다.
class PlanetEffectsQuality extends InheritedWidget {
const PlanetEffectsQuality({
super.key,
required this.level,
required super.child,
});
final int level;
static int of(BuildContext context) =>
context
.dependOnInheritedWidgetOfExactType<PlanetEffectsQuality>()
?.level ??
0;
@override
bool updateShouldNotify(PlanetEffectsQuality old) => old.level != level;
}
class AdaptivePlanetEffects extends StatefulWidget {
const AdaptivePlanetEffects({super.key, required this.child});
final Widget child;
@override
State<AdaptivePlanetEffects> createState() => _AdaptivePlanetEffectsState();
}
class _AdaptivePlanetEffectsState extends State<AdaptivePlanetEffects>
with WidgetsBindingObserver {
int _level = 0;
int _warmup = 60;
int _frames = 0;
int _slow = 0;
double _budgetMicros = 1000000 / 60;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
SchedulerBinding.instance.addTimingsCallback(_measure);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final hz = View.of(context).display.refreshRate;
_budgetMicros = 1000000 / (hz > 0 ? hz.clamp(30, 120) : 60);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_warmup = 60;
_frames = _slow = 0;
}
}
void _measure(List<FrameTiming> frames) {
if (!mounted || _level == 2) return;
final lifecycle = WidgetsBinding.instance.lifecycleState;
if (lifecycle != null && lifecycle != AppLifecycleState.resumed) return;
for (final frame in frames) {
if (_warmup > 0) {
_warmup--;
continue;
}
_frames++;
if (frame.buildDuration.inMicroseconds > _budgetMicros ||
frame.rasterDuration.inMicroseconds > _budgetMicros)
_slow++;
if (_frames < 120) continue;
final downgrade = _slow >= 30;
_frames = _slow = 0;
if (downgrade) {
setState(() => _level++);
// ponytail: 한 앱 실행에서는 하향만 한다. 복구 시 품질 진동을 피하며,
// 실기기 측정으로 안정 구간을 확보하면 장시간 회복 히스테리시스를 추가한다.
if (_level == 2) break;
}
}
}
@override
void dispose() {
SchedulerBinding.instance.removeTimingsCallback(_measure);
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
Widget build(BuildContext context) =>
PlanetEffectsQuality(level: _level, child: widget.child);
}
+16
View File
@@ -19,4 +19,20 @@ abstract final class AppIcons {
static const x = AssetImage('assets/icons/x.png');
static const search = AssetImage('assets/icons/search.png');
static const share = AssetImage('assets/icons/share_2.png');
// INSIDE 시안에서 쓰던 아이콘(그룹 E) — 다른 화면도 같은 Iconset 이라 여기로 올렸다.
// heart · message_circle · eye · bell · corner_down_right 는 재추출본(2026-09-27, reextract/map.json).
static const heart = AssetImage('assets/icons/heart.png');
static const messageCircle = AssetImage('assets/icons/message_circle.png');
static const eye = AssetImage('assets/icons/eye.png');
static const bell = AssetImage('assets/icons/bell.png');
static const clock = AssetImage('assets/icons/clock_8.png');
static const bolt = AssetImage('assets/icons/bolt.png');
static const cornerDownRight = AssetImage(
'assets/icons/corner_down_right.png',
);
static const globe = AssetImage('assets/icons/globe.png');
static const pencil = AssetImage('assets/icons/pencil.png');
static const send = AssetImage('assets/icons/send.png');
static const camera = AssetImage('assets/icons/camera.png');
static const image = AssetImage('assets/icons/image.png');
}
+37
View File
@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
/// 사본 `Radio` 컴포넌트(20) — 미선택은 #2A2A2C 가는 테두리, 선택은 #D1D1D2 원 안의 어두운 점.
///
/// Material [Radio] 는 2px 링을 그려 사본보다 굵고 밝다. 신고 사유 · 환불 사유가 같이 쓴다.
class AppRadio extends StatelessWidget {
const AppRadio({super.key, required this.selected});
final bool selected;
@override
Widget build(BuildContext context) => Semantics(
checked: selected,
inMutuallyExclusiveGroup: true,
child: Container(
width: 20,
height: 20,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: selected ? const Color(0xFFD1D1D2) : null,
border: selected
? null
: Border.all(color: const Color(0xFF2A2A2C), width: 1.5),
),
child: selected
? const DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Color(0xFF141414),
),
child: SizedBox.square(dimension: 8),
)
: null,
),
);
}
+26
View File
@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import '../theme/tokens.dart';
/// 시안의 CONTRIBUTION · 누적 기여 POP 아이콘 — 파란 그라디언트 원에 흰 번개.
/// 다른 3D 아이콘과 달리 Figma 에서 벡터라 이미지로 내보낼 수 없어 위젯으로 그린다.
class BoltBadge extends StatelessWidget {
const BoltBadge({super.key, this.size = 40});
final double size;
@override
Widget build(BuildContext context) => Container(
width: size,
height: size,
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.neonCyan, AppColors.cosmicBlue],
),
),
child: Icon(Icons.bolt, size: size * 0.6, color: AppColors.onCosmic),
);
}
+108
View File
@@ -0,0 +1,108 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../data/planet_visual_state.dart';
import 'live_stream.dart';
import '../i18n/app_locale.dart';
/// 서버 종료시각을 기준으로 표시한다. 재진입해도 10분으로 다시 시작하지 않는다.
class HeroTimeStatus extends StatelessWidget {
const HeroTimeStatus({super.key, required this.fandomId, required this.watch, this.onParticipate});
final String fandomId;
final PlanetWatch watch;
final VoidCallback? onParticipate;
@override
Widget build(BuildContext context) => LiveStreamBuilder<PlanetVisualState>(
key: ValueKey(('hero-time', fandomId)),
create: () => watch(fandomId),
builder: (context, state) => state?.isHeroTime == true && state?.heroEndsAt != null
? Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_RemainingTime(endsAt: state!.heroEndsAt!, nicknames: state.heroNicknames, claims: state.heroClaimCount),
if (onParticipate != null) TextButton(onPressed: onParticipate, child: Text(context.l10n.heroParticipate)),
])
: const SizedBox.shrink(),
);
}
class _RemainingTime extends StatefulWidget {
const _RemainingTime({required this.endsAt, required this.nicknames, this.claims});
final List<String> nicknames;
final int? claims;
final DateTime endsAt;
@override
State<_RemainingTime> createState() => _RemainingTimeState();
}
class _RemainingTimeState extends State<_RemainingTime> {
Timer? _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final seconds = (widget.endsAt.difference(DateTime.now()).inMilliseconds / 1000).ceil();
if (seconds <= 0) return const SizedBox.shrink();
final minutes = seconds ~/ 60;
final remainder = (seconds % 60).toString().padLeft(2, '0');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Semantics(
label: context.l10n.heroRemainingSemantics('$minutes', '${seconds % 60}'),
excludeSemantics: true,
child: Text(context.l10n.heroRemaining('$minutes:$remainder'),
style: Theme.of(context).textTheme.labelMedium),
),
if (widget.nicknames.isNotEmpty)
Text('by ${widget.nicknames.join(' · ')}', maxLines: 1,
overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall),
if (widget.claims != null)
Text(context.l10n.heroClaims('${widget.claims}'), style: Theme.of(context).textTheme.bodySmall),
],
);
}
}
/// 선택한 팬덤의 진행 중 이벤트에서 해당 행성으로 진입한다.
class HeroTimeBillboard extends StatelessWidget {
const HeroTimeBillboard({super.key, required this.fandomId,
required this.fandomName, required this.watch, required this.onOpen});
final String fandomId;
final String fandomName;
final PlanetWatch watch;
final VoidCallback onOpen;
@override
Widget build(BuildContext context) => LiveStreamBuilder<PlanetVisualState>(
key: ValueKey(('hero-billboard', fandomId)),
create: () => watch(fandomId),
builder: (context, state) {
if (state?.isHeroTime != true || state?.heroEndsAt == null ||
!state!.heroEndsAt!.isAfter(DateTime.now())) return const SizedBox.shrink();
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_RemainingTime(endsAt: state.heroEndsAt!,
nicknames: state.heroNicknames, claims: state.heroClaimCount),
TextButton(onPressed: onOpen,
child: Text(context.l10n.heroInProgress(fandomName))),
],
),
),
);
},
);
}
+104
View File
@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import '../data/planet_visual_state.dart';
import '../theme/tokens.dart';
import 'live_stream.dart';
import 'planet_25d.dart';
/// 첫 서버 스냅샷은 기준점. 과거 캐시와의 차이를 새 진화로 오인하지 않는다.
class LivePlanet25D extends StatelessWidget {
const LivePlanet25D({
super.key,
required this.fandomId,
required this.watch,
required this.image,
required this.size,
this.stage,
this.onOpenHistory,
this.glow = AppColors.neonViolet,
this.animate = true,
this.lowEffects = false,
});
final VoidCallback? onOpenHistory;
final String fandomId;
final PlanetWatch watch;
final String image;
final double size;
final int? stage;
final Color glow;
final bool animate;
final bool lowEffects;
@override
Widget build(BuildContext context) => LiveStreamBuilder<PlanetVisualState>(
key: ValueKey(('planet-state', fandomId)),
create: () => watch(fandomId),
builder: (context, state) => Planet25D(
key: ValueKey((fandomId, state != null)),
image: image,
onOpenHistory: onOpenHistory,
size: size,
glow: glow,
tint: planetColorOf(state?.colorKey),
stage: state == null ? stage : state.stage,
level: state?.level,
isEclipsed: state?.isEclipsed ?? false,
isHibernating: state?.isHibernating ?? false,
isHeroTime: state?.isHeroTime ?? false,
heroEventIds: state?.heroEventIds,
animate: animate,
lowEffects: lowEffects,
),
);
}
/// 배경 광량만 바꿔 콘텐츠의 가독성과 화면 구조는 유지한다.
class LivePlanetLighting extends StatelessWidget {
const LivePlanetLighting({
super.key,
required this.fandomId,
required this.watch,
required this.child,
});
final String fandomId;
final PlanetWatch watch;
final Widget child;
@override
Widget build(BuildContext context) => LiveStreamBuilder<PlanetVisualState>(
key: ValueKey(('planet-light', fandomId)),
create: () => watch(fandomId),
builder: (context, state) => AnimatedOpacity(
opacity: state?.isHibernating == true ? 0.35 : state?.isEclipsed == true ? 0.45 : 1,
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(seconds: 2),
child: Stack(
fit: StackFit.passthrough,
children: [
child,
Positioned.fill(
child: IgnorePointer(
child: AnimatedOpacity(
opacity: state?.isHeroTime == true && state?.isEclipsed != true && state?.isHibernating != true
? 1
: 0,
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(seconds: 2),
child: const DecoratedBox(
decoration: BoxDecoration(
gradient: RadialGradient(
colors: [Color(0x26FFD34D), Color(0x00FFD34D)],
radius: 0.85,
),
),
),
),
),
),
],
),
),
);
}
+64
View File
@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
/// 실시간 구독 한 자리 — [StreamBuilder] 에 **백그라운드 복귀 재구독**을 더한 것.
///
/// 왜 필요한가. Firestore 리스너는 단말이 오래 잠들면 서버와 끊긴 채로 남을 수
/// 있고, 그러면 깨어난 화면이 잠들기 전 값을 그대로 그린다. 별첨 1 ① 예외 처리가
/// 「백그라운드 복귀·화면 회전 시 렌더링 정상 재개」를 판정 항목으로 두었으므로
/// 복귀 시 스트림을 새로 열어 첫 스냅샷을 다시 받는다.
///
/// [create] 는 **구독할 때마다 새 스트림을 만드는 함수**다. 이미 만들어진 스트림을
/// 넘기면 재구독이 같은(이미 닫힌) 스트림을 다시 듣게 되므로 값이 오지 않는다.
///
/// **구독 대상이 바뀌면 [key] 를 바꿔 준다.** [create] 는 빌드마다 새로 만들어지는
/// 클로저라 같은 대상인지 비교할 수 없다. 비교해서 다시 열면 프레임마다 재구독이
/// 돌고, 비교 없이 두면 대상이 바뀌어도 옛 구독이 남는다. 그래서 갈아타기는
/// 위젯 키(예: `ValueKey(fandomId)`)로만 한다.
///
/// 위젯이 사라지면 [StreamBuilder] 가 구독을 끊는다 — 별도 해제 코드가 없는 이유다.
class LiveStreamBuilder<T> extends StatefulWidget {
const LiveStreamBuilder({
super.key,
required this.create,
required this.builder,
});
final Stream<T> Function() create;
/// 값이 아직 없으면 null 로 부른다. 첫 프레임에 빈 자리를 그릴지 로딩을 그릴지는
/// 화면이 정한다 — 배너는 빈 자리가 맞고 목록은 로딩이 맞다.
final Widget Function(BuildContext context, T? value) builder;
@override
State<LiveStreamBuilder<T>> createState() => _LiveStreamBuilderState<T>();
}
class _LiveStreamBuilderState<T> extends State<LiveStreamBuilder<T>>
with WidgetsBindingObserver {
late Stream<T> _stream = widget.create();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
setState(() => _stream = widget.create());
}
}
@override
Widget build(BuildContext context) => StreamBuilder<T>(
stream: _stream,
builder: (context, snap) => widget.builder(context, snap.data),
);
}
+39
View File
@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import '../theme/tokens.dart';
/// 표본 데이터 알림(G-08) — 본문을 밀지 않도록 앱바 오른쪽에 작은 「MOCK」 알약으로 둔다.
/// 사유 문구는 길게 눌렀을 때와 스크린리더로 읽힌다.
class MockTag extends StatelessWidget {
const MockTag({super.key, required this.note});
final String note;
@override
Widget build(BuildContext context) => Tooltip(
message: note,
child: Semantics(
label: 'MOCK · $note',
excludeSemantics: true,
child: Container(
margin: const EdgeInsets.only(right: 16),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.sm),
border: Border.all(color: AppColors.neonViolet),
),
child: Text(
'MOCK',
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 10,
fontWeight: FontWeight.w600,
height: 1.2,
letterSpacing: -0.25,
color: AppColors.neonViolet,
),
),
),
),
);
}
+720
View File
@@ -0,0 +1,720 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../theme/tokens.dart';
import 'planet_evolution.dart';
import 'adaptive_planet_effects.dart';
import '../i18n/app_locale.dart';
/// 행성 컬러 팔레트 — 사본 「행성 색 선택」 `373:358685` ~ `373:358692` 순서.
/// 생성 화면이 고르는 값과 렌더링이 읽는 값이 **같은 표**여야 하므로 여기 한 곳에 둔다(C-50).
/// 흰 = 색 선택 명세(`2066:17910`)의 #FFFFFF — 해석 확인 요청 Q-PN-01 권고 B안(칩의 #D9D9D9 는
/// Figma 기본 채움). 저장값은 키(`white`)라 값만 바꾸면 기존 행성도 따라온다.
const planetColors = <(String, Color)>[
('white', Color(0xFFFFFFFF)),
('black', Color(0xFF000000)),
('blue', Color(0xFF009CFE)),
('green', Color(0xFF00C60C)),
('gold', Color(0xFFFFC500)),
('orange', Color(0xFFFF7D00)),
('red', Color(0xFFFF0049)),
('purple', Color(0xFFC300C8)),
];
/// 서버 `colorKey` → 칩 색. 모르는 키(서버 기본값 `violet` 포함)는 null — 원래 일러스트 그대로.
Color? planetColorOf(String? key) =>
planetColors.where((c) => c.$1 == key).firstOrNull?.$2;
/// Figma 본체에 UX/FX v1.5 §2의 Stage별 레이어를 조합한다.
/// Stage는 서버 확정값이며, null은 단계 데이터 미제공 상태다.
class Planet25D extends StatefulWidget {
const Planet25D({
super.key,
required this.image,
required this.size,
this.glow = AppColors.neonViolet,
this.tint,
this.stage,
this.level,
this.isEclipsed = false,
this.isHibernating = false,
this.isHeroTime = false,
this.heroEventIds,
this.onOpenHistory,
this.lowEffects = false,
this.animate = true,
});
/// 행성 일러스트 자산 경로(`assets/figma/planet-*.png`).
final VoidCallback? onOpenHistory;
final String image;
/// 한 변 길이. 글로우와 링은 이 바깥으로 조금 번진다.
final double size;
/// 아우라 색.
final Color glow;
/// 생성 때 고른 컬러([planetColorOf]). 생성 화면 미리보기와 같은 곱하기 합성으로 본체에 입힌다.
final Color? tint;
final int? stage;
final int? level;
final bool isEclipsed;
final bool isHibernating;
final bool isHeroTime;
final Set<String>? heroEventIds;
/// 저사양에서는 상태를 유지하면서 입자·Blur만 축소한다.
final bool lowEffects;
/// 정지 화면(목록 카드 등)에서는 끈다.
final bool animate;
/// 전역 스위치. 위젯 테스트는 `pumpAndSettle` 이 끝나야 하므로 끈다
/// (`test/flutter_test_config.dart`). 앱에서는 항상 켜져 있다.
static bool animationsEnabled = true;
@override
State<Planet25D> createState() => _Planet25DState();
}
class _Planet25DState extends State<Planet25D>
with SingleTickerProviderStateMixin {
int get _quality => widget.lowEffects ? 2 : PlanetEffectsQuality.of(context);
bool get _lowEffects => _quality >= 2;
bool get _celebrating =>
widget.isHeroTime && !widget.isEclipsed && !widget.isHibernating;
int get _period => widget.isHibernating
? 192
: widget.isEclipsed
? 96
: _celebrating
? 32
: 48;
double get _light => widget.isHibernating
? 0.35
: widget.isEclipsed
? 0.5
: _celebrating
? 1.3
: 1;
late final AnimationController _c = AnimationController(
vsync: this,
duration: Duration(seconds: _period),
);
bool get _motionEnabled =>
widget.animate &&
Planet25D.animationsEnabled &&
!MediaQuery.disableAnimationsOf(context);
void _syncMotion() {
if (_motionEnabled && !_c.isAnimating) _c.repeat();
if (!_motionEnabled) _c.stop();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_syncMotion();
}
@override
void didUpdateWidget(Planet25D old) {
super.didUpdateWidget(old);
if (old.isHibernating != widget.isHibernating ||
old.isEclipsed != widget.isEclipsed ||
old.isHeroTime != widget.isHeroTime) {
_c.duration = Duration(seconds: _period);
if (_motionEnabled) _c.repeat();
}
_syncMotion();
}
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => TweenAnimationBuilder<double>(
tween: Tween(begin: _light, end: _light),
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(seconds: 2),
builder: (context, light, _) => PlanetEvolution(
stage: widget.stage,
onOpenHistory: widget.onOpenHistory,
level: widget.level,
heroEventIds: widget.heroEventIds,
heroEnabled: !widget.isEclipsed && !widget.isHibernating,
animate: widget.animate,
lowEffects: _lowEffects,
builder: (stage) => Opacity(
key: const ValueKey('planet-surface-light'),
opacity: light.clamp(0.0, 1.0),
child: _planet(context, stage, light),
),
),
);
Widget _tinted(Widget body) => switch (widget.tint) {
final tint? => ColorFiltered(
colorFilter: ColorFilter.mode(tint, BlendMode.modulate),
child: body,
),
null => body,
};
Widget _planet(BuildContext context, int? stage, double light) {
final s = widget.size;
final validStage = stage != null && stage >= 0 && stage <= 6;
final l10n = context.l10n;
final names = l10n.stageNames.split(',');
final particleCount = stage == 0
? 24
: stage == 1
? 6
: stage == 5
? 40
: stage == 6
? 18
: 14;
final glowStrength = _quality == 0
? 1.0
: _quality == 1
? 0.8
: 0.6;
final ring = validStage && stage >= 4;
final glow = Color.lerp(
widget.glow,
AppColors.gold,
((light - 1) / 0.3).clamp(0.0, 1.0),
)!;
return Semantics(
label:
'${validStage ? 'Stage $stage · ${names[stage]}' : l10n.planetStageUnknown}${widget.isHibernating
? l10n.planetHibernatingSuffix
: widget.isEclipsed
? l10n.planetEclipseSuffix
: _celebrating
? l10n.planetHeroSuffix
: ''}',
image: true,
child: RepaintBoundary(
child: SizedBox(
width: s,
height: s,
child: AnimatedBuilder(
animation: _c,
builder: (context, _) {
final t = _motionEnabled ? _c.value : 0.0; // 0~1
final wave = math.sin(t * 2 * math.pi); // -1~1
return Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
// 2. 글로우 아우라 — 은은하게 커졌다 작아진다.
Positioned.fill(
child: Transform.scale(
scale:
(stage == 5 ? 1.42 : 1.25) +
(stage == 6 ? 0 : wave * 0.05) +
(light - 1).clamp(0.0, 0.3),
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
glow.withValues(
alpha:
((stage == 1
? 0.22
: stage == 5
? 0.55
: 0.42) +
wave * (stage == 6 ? 0.02 : 0.05)) *
glowStrength,
),
glow.withValues(alpha: 0.10 * glowStrength),
glow.withValues(alpha: 0),
],
stops: const [0.45, 0.72, 1],
),
),
),
),
),
if (stage == 6)
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: _SustainedAura(
color: glow,
lowEffects: _lowEffects,
),
),
),
),
// 6. 링 — 뒤쪽 절반. 본체 뒤에 깔린다.
if (ring)
Positioned.fill(
child: _RingLayer(
compact: stage == 4,
strength: stage == 5
? 0.58
: stage == 6
? 0.52
: 0.48,
t: t,
color: glow,
lowEffects: _lowEffects,
front: false,
),
),
if (stage == 0)
Transform.rotate(
angle: t * 2 * math.pi,
child: Opacity(
opacity: 0.85,
child: Image.asset(
'assets/fx/nebula-figma.png',
width: s,
height: s,
fit: BoxFit.contain,
),
),
)
else
Transform.translate(
offset: Offset(0, wave * s * 0.025),
// 임시조치: 본체 이미지를 평면 회전시키지 않는다.
// 실제 구체 자전은 별도 표면/모델 기반 구현이 필요하다.
child: _tinted(
Image.asset(
widget.image,
width: s,
height: s,
fit: BoxFit.contain,
),
),
),
if (validStage && stage >= 2)
Positioned.fill(
child: IgnorePointer(
child: Transform.rotate(
angle: -t * 2 * math.pi,
child: Opacity(
opacity: stage == 2 ? 0.65 : 0.8,
child: Image.asset(
'assets/fx/clouds.png',
fit: BoxFit.contain,
cacheWidth: _lowEffects ? 256 : 512,
),
),
),
),
),
if (validStage && stage >= 3)
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: _OrbitPainter(
t: t,
stage: stage,
color: glow,
lowEffects: _lowEffects,
),
),
),
),
// 6. 링 — 앞쪽 절반. 본체를 가려 깊이를 만든다.
if (ring)
Positioned.fill(
child: _RingLayer(
compact: stage == 4,
strength: stage == 5
? 0.58
: stage == 6
? 0.52
: 0.48,
t: t,
color: glow,
lowEffects: _lowEffects,
front: true,
),
),
// 7. 입자 — 자리마다 위상이 달라 서로 다른 박자로 반짝인다.
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: _ParticlePainter(
t: t,
color: glow,
steady: stage == 6,
count:
(particleCount *
light *
(_quality == 2
? 0.3
: _quality == 1
? 0.6
: 1))
.round()
.clamp(2, 60),
lowEffects: _lowEffects,
),
),
),
),
],
);
},
),
),
),
);
}
}
/// 원본 보드의 다중 고리/잔해 질감. 뒤→본체→앞으로 합성한다.
class _RingLayer extends StatelessWidget {
const _RingLayer({
required this.strength,
required this.compact,
required this.t,
required this.color,
required this.front,
required this.lowEffects,
});
final double strength;
final bool compact;
final double t;
final Color color;
final bool front;
final bool lowEffects;
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, box) {
final s = box.maxWidth;
return IgnorePointer(
child: OverflowBox(
maxWidth: s * 1.7,
maxHeight: s * 1.7,
child: Transform.rotate(
angle: -0.32,
child: SizedBox.square(
dimension: s * 1.7,
child: ClipPath(
clipper: _RingHalf(front, compact: compact),
child: Stack(
fit: StackFit.expand,
children: [
Opacity(
opacity: strength * (front ? 1 : 0.7),
child: Image.asset(
'assets/fx/planet-rings.png',
fit: BoxFit.contain,
cacheWidth: lowEffects ? 512 : 1024,
),
),
CustomPaint(
painter: _RingDust(
t: t,
color: color,
lowEffects: lowEffects,
),
),
],
),
),
),
),
),
);
},
);
}
class _RingHalf extends CustomClipper<Path> {
const _RingHalf(this.front, {this.compact = false});
final bool compact;
final bool front;
@override
Path getClip(Size size) {
var half = Path()
..addRect(
Rect.fromLTWH(
0,
front ? size.height / 2 : 0,
size.width,
size.height / 2,
),
);
if (compact) {
half = Path.combine(
PathOperation.intersect,
half,
Path()..addOval(
Rect.fromCenter(
center: size.center(Offset.zero),
width: size.width * 0.80,
height: size.height * 0.32,
),
),
);
}
if (front) return half;
// 본체 에셋의 가장자리 알파에도 뒤 고리가 비치지 않도록 구면 안쪽을 차폐.
final body = Path()
..addOval(
Rect.fromCircle(
center: size.center(Offset.zero),
radius: size.width / 1.7 * 0.44,
),
);
return Path.combine(PathOperation.difference, half, body);
}
@override
bool shouldReclip(_RingHalf old) =>
old.front != front || old.compact != compact;
}
/// 정적 질감 위를 실제로 공전하는 빛가루. 저사양은 수만 줄인다.
class _RingDust extends CustomPainter {
const _RingDust({
required this.t,
required this.color,
required this.lowEffects,
});
final double t;
final Color color;
final bool lowEffects;
@override
void paint(Canvas canvas, Size size) {
final random = math.Random(421);
final c = size.center(Offset.zero);
for (var i = 0; i < (lowEffects ? 6 : 18); i++) {
final radius = 0.34 + random.nextDouble() * 0.10;
final a = random.nextDouble() * math.pi * 2 + t * math.pi * 2;
final p =
c +
Offset(
math.cos(a) * size.width * radius,
math.sin(a) * size.height * radius * 0.52,
);
final r = size.width * (0.0008 + random.nextDouble() * 0.002);
canvas.drawCircle(
p,
r,
Paint()
..color = Color.lerp(
color,
Colors.white,
random.nextDouble(),
)!.withValues(alpha: 0.2 + 0.2 * math.sin(a).abs()),
);
}
}
@override
bool shouldRepaint(_RingDust old) =>
old.t != t || old.color != color || old.lowEffects != lowEffects;
}
/// 행성 둘레의 반짝이. 자리는 고정 시드, 밝기는 자리별 위상으로 흔든다.
class _ParticlePainter extends CustomPainter {
const _ParticlePainter({
required this.t,
required this.color,
required this.count,
this.steady = false,
required this.lowEffects,
});
final int count;
final bool steady;
final bool lowEffects;
final double t;
final Color color;
@override
void paint(Canvas canvas, Size size) {
final rnd = math.Random(2026);
final c = size.center(Offset.zero);
for (var i = 0; i < count; i++) {
final ang = rnd.nextDouble() * 2 * math.pi;
final dist = size.width * (0.38 + rnd.nextDouble() * 0.28);
final phase = rnd.nextDouble();
final blink = steady
? 0.55
: (math.sin((t + phase) * 2 * math.pi) + 1) / 2; // 0~1
final p = Offset(
c.dx + dist * math.cos(ang),
c.dy + dist * math.sin(ang) * 0.8,
);
final r = size.width * (0.003 + 0.006 * blink);
if (!lowEffects)
canvas.drawCircle(
p,
r * 2.5,
Paint()
..color = color.withValues(alpha: 0.35 * blink)
..maskFilter = lowEffects
? null
: const MaskFilter.blur(BlurStyle.normal, 4),
);
canvas.drawCircle(
p,
r,
Paint()
..color = AppColors.onCosmic.withValues(alpha: 0.4 + 0.6 * blink),
);
// 몇 개에는 가는 십자 광선.
if (!steady && i % 5 == 0) {
final ray = Paint()
..color = AppColors.onCosmic.withValues(alpha: 0.6 * blink)
..strokeWidth = 0.8;
canvas.drawLine(p - Offset(r * 3, 0), p + Offset(r * 3, 0), ray);
canvas.drawLine(p - Offset(0, r * 3), p + Offset(0, r * 3), ray);
}
}
}
@override
bool shouldRepaint(_ParticlePainter old) =>
old.t != t ||
old.color != color ||
old.count != count ||
old.steady != steady ||
old.lowEffects != lowEffects;
}
/// Stage 3 공동 에너지 궤도, Stage 4+ 지속 위성.
class _OrbitPainter extends CustomPainter {
const _OrbitPainter({
required this.t,
required this.stage,
required this.color,
required this.lowEffects,
});
final double t;
final int stage;
final Color color;
final bool lowEffects;
@override
void paint(Canvas canvas, Size size) {
final c = size.center(Offset.zero);
final s = size.shortestSide;
final orbit = Rect.fromCenter(center: c, width: s * 1.18, height: s * 0.52);
canvas.save();
canvas.translate(c.dx, c.dy);
canvas.rotate(-0.35);
canvas.translate(-c.dx, -c.dy);
if (stage == 3) {
// 공유 에너지 세 무리: 표면 구름(Stage 2)과 다른 주변 구조이며 고체 위성은 아니다.
final energyOrbit = Rect.fromCenter(
center: c,
width: s * 1.32,
height: s * 1.04,
);
for (var group = 0; group < 3; group++) {
final a = (t + group / 3) * math.pi * 2 - 0.4;
for (var j = 0; j < (lowEffects ? 3 : 6); j++) {
final angle = a - j * 0.07;
final p =
c +
Offset(
math.cos(angle) * energyOrbit.width / 2,
math.sin(angle) * energyOrbit.height / 2,
);
final radius = s * (0.024 - j * 0.0025);
canvas.drawCircle(
p,
radius * 2.3,
Paint()
..shader = RadialGradient(
colors: [
color.withValues(alpha: 0.65 - j * 0.07),
color.withValues(alpha: 0),
],
).createShader(Rect.fromCircle(center: p, radius: radius * 2.3)),
);
if (j == 0)
canvas.drawCircle(
p,
radius * 0.48,
Paint()
..color = Color.lerp(
color,
Colors.white,
0.7,
)!.withValues(alpha: 0.9 - j * 0.1),
);
}
}
} else {
final a = t * math.pi * 2;
final p =
c +
Offset(math.cos(a) * orbit.width / 2, math.sin(a) * orbit.height / 2);
final r = s * 0.065;
canvas.drawCircle(
p,
r,
Paint()
..shader = RadialGradient(
colors: [Colors.white, color, const Color(0xFF251744)],
stops: const [0, 0.55, 1],
center: const Alignment(-0.4, -0.4),
).createShader(Rect.fromCircle(center: p, radius: r)),
);
}
canvas.restore();
}
@override
bool shouldRepaint(_OrbitPainter old) =>
old.t != t ||
old.stage != stage ||
old.color != color ||
old.lowEffects != lowEffects;
}
/// Stage 6: 폭죽 대신 구면 주위에 머무는 넓고 일정한 대기광.
class _SustainedAura extends CustomPainter {
const _SustainedAura({required this.color, required this.lowEffects});
final Color color;
final bool lowEffects;
@override
void paint(Canvas canvas, Size size) {
final c = size.center(Offset.zero);
final r = size.shortestSide * 0.82;
canvas.drawCircle(
c,
r,
Paint()
..shader = RadialGradient(
colors: [
Colors.transparent,
Colors.transparent,
const Color(0xFFC1B9FF).withValues(alpha: lowEffects ? 0.19 : 0.3),
color.withValues(alpha: lowEffects ? 0.1 : 0.17),
Colors.transparent,
],
stops: const [0, 0.50, 0.62, 0.76, 1],
).createShader(Rect.fromCircle(center: c, radius: r)),
);
}
@override
bool shouldRepaint(_SustainedAura old) =>
old.color != color || old.lowEffects != lowEffects;
}
+509
View File
@@ -0,0 +1,509 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../theme/tokens.dart';
import 'planet_fx_scope.dart';
import '../i18n/app_locale.dart';
/// v1.5 §2.1/6. 진화·HERO 발동은 하나의 타임라인을 쓰며 첫 조회는 기준점이다.
class PlanetEvolution extends StatefulWidget {
const PlanetEvolution({
super.key,
required this.stage,
required this.level,
required this.animate,
required this.lowEffects,
required this.builder,
this.heroEventIds,
this.onOpenHistory,
this.heroEnabled = true,
});
final VoidCallback? onOpenHistory;
final Set<String>? heroEventIds;
final bool heroEnabled;
final int? stage;
final int? level;
final bool animate;
final bool lowEffects;
final Widget Function(int? stage) builder;
@override
State<PlanetEvolution> createState() => _PlanetEvolutionState();
}
class _PlanetEvolutionState extends State<PlanetEvolution>
with SingleTickerProviderStateMixin {
late final _fx = AnimationController(vsync: this)
..addStatusListener((status) {
if (status == AnimationStatus.completed)
setState(() {
if (_evolution && !_hero) _completedEvolution = true;
_active = false;
_shownStage = widget.stage;
if (_pendingHero.isNotEmpty &&
_canHero &&
widget.animate &&
TickerMode.valuesOf(context).enabled) {
_pendingHero.clear();
_start(false, hero: true);
} else if (_pendingHeroEnd &&
!_canHero &&
widget.heroEnabled &&
widget.animate &&
TickerMode.valuesOf(context).enabled) {
_pendingHeroEnd = false;
_start(false, hero: true, ending: true);
} else {
_activity?.setActive(this, false);
}
});
});
PlanetFxActivity? _activity;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_active && !TickerMode.valuesOf(context).enabled) {
_fx.stop();
_pendingHero.clear();
_pendingHeroEnd = false;
_activity?.setActive(this, false);
_active = false;
_shownStage = widget.stage;
}
final next = PlanetFxScope.of(context);
if (!identical(next, _activity)) {
_activity?.setActive(this, false);
_activity = next;
_activity?.setActive(this, _active && (_evolution || _hero));
}
}
int? _highestStage;
int? _highestLevel;
int? _shownStage;
@override
void initState() {
super.initState();
_highestStage = widget.stage;
_highestLevel = widget.level;
_shownStage = widget.stage;
_heroReady = widget.heroEventIds != null;
_seenHeroIds.addAll(widget.heroEventIds ?? const {});
}
final _seenHeroIds = <String>{};
bool _heroReady = false;
bool _hero = false;
bool _heroEnding = false;
bool _pendingHeroEnd = false;
final _pendingHero = <String>{};
bool get _canHero =>
widget.heroEnabled && (widget.heroEventIds?.isNotEmpty ?? false);
bool _active = false;
bool _evolution = false;
bool _completedEvolution = false;
bool get _reduced => MediaQuery.disableAnimationsOf(context);
@override
void didUpdateWidget(PlanetEvolution old) {
super.didUpdateWidget(old);
final stage = widget.stage;
final level = widget.level;
final evolved =
stage != null &&
old.stage != null &&
_highestStage != null &&
stage > _highestStage!;
final levelled =
level != null &&
old.level != null &&
_highestLevel != null &&
level > _highestLevel!;
if (stage != null) _highestStage = math.max(stage, _highestStage ?? stage);
if (level != null) _highestLevel = math.max(level, _highestLevel ?? level);
if (old.stage != null && (stage == null || stage < old.stage!)) {
_completedEvolution = false;
_fx.stop();
_activity?.setActive(this, false);
_active = false;
_shownStage = stage;
}
final ids = widget.heroEventIds;
final heroEnded =
old.heroEventIds?.isNotEmpty == true && ids?.isEmpty == true;
if (_canHero || !widget.heroEnabled) _pendingHeroEnd = false;
if (_active && _heroEnding && (_canHero || !widget.heroEnabled)) {
_fx.stop();
_activity?.setActive(this, false);
_active = false;
}
final newHeroIds = (ids ?? const <String>{}).difference(_seenHeroIds);
_pendingHero.retainAll(ids ?? const {});
final newHero = ids != null && _heroReady && newHeroIds.isNotEmpty;
if (ids != null) {
_seenHeroIds.addAll(ids);
_heroReady = true;
}
if (!_canHero) {
_pendingHero.clear();
if (_active && _hero && !_heroEnding) {
_fx.stop();
_activity?.setActive(this, false);
_active = false;
}
}
final visible = widget.animate && TickerMode.valuesOf(context).enabled;
if (!visible) {
_fx.stop();
_activity?.setActive(this, false);
_active = false;
}
if (newHero && visible && _canHero) _pendingHero.addAll(newHeroIds);
if (!visible) {
_pendingHero.clear();
_pendingHeroEnd = false;
}
if (heroEnded && visible && widget.heroEnabled) _pendingHeroEnd = true;
if (evolved && visible) {
// 같은 화면에서 Evolution 중 추가 승급은 최종 Stage로 합쳐 강한 FX를 겹치지 않는다.
if (!_active || !_evolution) _start(true);
} else if (_pendingHeroEnd && visible && !(_active && _evolution)) {
_pendingHeroEnd = false;
_start(false, hero: true, ending: true);
} else if (_pendingHero.isNotEmpty &&
visible &&
!(_active && (_evolution || _hero))) {
_pendingHero.clear();
_start(false, hero: true);
} else if (levelled && visible && !(_active && (_evolution || _hero))) {
_start(false);
}
if (!_active) _shownStage = stage;
}
void _start(bool evolution, {bool hero = false, bool ending = false}) {
if (evolution) _completedEvolution = false;
_hero = hero;
_heroEnding = ending;
_activity?.setActive(this, evolution || hero);
_evolution = evolution;
_active = true;
_fx.duration = Duration(
milliseconds: _reduced
? 1200
: ending
? 1100
: hero
? 2400
: evolution
? 3200
: 850,
);
_fx.forward(from: 0);
if (ending) return;
if (evolution || hero) {
HapticFeedback.heavyImpact();
} else {
HapticFeedback.mediumImpact();
}
}
@override
void dispose() {
_activity?.setActive(this, false);
_fx.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => AnimatedBuilder(
animation: _fx,
builder: (context, _) {
final p = _fx.value;
final stage = !_active || !_evolution || _reduced || p >= 0.48
? widget.stage
: _shownStage;
final l10n = context.l10n;
final names = l10n.stageNames.split(',');
final stageLabel =
widget.stage != null && widget.stage! >= 0 && widget.stage! <= 6
? 'Stage ${widget.stage} · ${names[widget.stage!]}'
: l10n.evoPlanetGrowth;
final label = _heroEnding
? l10n.evoHeroEnd
: _hero
? (_reduced || p >= 0.5 ? 'HERO TIME' : l10n.evoAwakening)
: !_evolution
? 'Lv.${widget.level}'
: _reduced
? stageLabel
: p < 0.16
? l10n.evoAnomaly
: p < 0.36
? l10n.evoEnergy
: p < 0.48
? l10n.evoTransition
: p < 0.70
? stageLabel
: l10n.evoComplete;
return Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
Opacity(
opacity: _active && _hero && !_heroEnding && !_reduced
? (p < 0.18
? 1 - p / 0.18 * 0.3
: p < 0.5
? 0.7 + (p - 0.18) / 0.32 * 0.3
: 1)
: 1,
child: widget.builder(stage),
),
if (_active && !_reduced)
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: _hero
? _HeroPainter(
progress: p,
lowEffects: widget.lowEffects,
ending: _heroEnding,
)
: _EvolutionPainter(
progress: p,
evolution: _evolution,
lowEffects: widget.lowEffects,
),
),
),
),
if (!_active && _completedEvolution && widget.onOpenHistory != null)
Positioned(bottom: 0, left: 0, right: 0,
child: Center(child: TextButton(
onPressed: () {
setState(() => _completedEvolution = false);
widget.onOpenHistory!();
},
child: Text(l10n.evoOpenHistory),
)),
),
if (_active)
Positioned(
bottom: 0,
left: -30,
right: -30,
child: IgnorePointer(
child: Semantics(
liveRegion: true,
child: Center(
child: DecoratedBox(
decoration: BoxDecoration(
color: AppColors.cosmicSurface.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(10),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
child: Text(
label,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelMedium
?.copyWith(color: AppColors.onCosmic),
),
),
),
),
),
),
),
],
);
},
);
}
class _EvolutionPainter extends CustomPainter {
const _EvolutionPainter({
required this.progress,
required this.evolution,
required this.lowEffects,
});
final double progress;
final bool evolution;
final bool lowEffects;
@override
void paint(Canvas canvas, Size size) {
final c = size.center(Offset.zero);
final radius = size.shortestSide * 0.42;
const color = AppColors.cosmicAccent;
final p = progress;
if (!evolution) {
canvas.drawCircle(
c,
radius * (1 + p * 0.5),
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2
..color = color.withValues(alpha: (1 - p) * 0.8),
);
canvas.drawCircle(
c,
radius * 1.4,
Paint()
..shader = RadialGradient(
colors: [
color.withValues(alpha: (1 - p) * 0.25),
color.withValues(alpha: 0),
],
).createShader(Rect.fromCircle(center: c, radius: radius * 1.4)),
);
return;
}
if (p < 0.16) return;
if (p < 0.48) {
final focus = ((p - 0.16) / 0.20).clamp(0.0, 1.0);
final r = radius * (1.5 - focus * 1.3);
canvas.drawCircle(
c,
r,
Paint()
..shader = RadialGradient(
colors: [
Colors.white.withValues(alpha: 0.85 * focus),
color.withValues(alpha: 0.4),
color.withValues(alpha: 0),
],
).createShader(Rect.fromCircle(center: c, radius: r)),
);
for (var i = 0; i < (lowEffects ? 6 : 16); i++) {
final a = i * 2.4 + p * 5;
canvas.drawCircle(
c + Offset(math.cos(a), math.sin(a)) * r,
2,
Paint()..color = color.withValues(alpha: 0.8),
);
}
} else if (p >= 0.70) {
final burst = (p - 0.70) / 0.30;
for (var i = 0; i < (lowEffects ? 8 : 28); i++) {
final x = ((i * 0.618) % 1 - 0.5) * radius * 3;
final y = -radius + ((i * 0.37) % 1 + burst) * radius * 1.3;
canvas.drawCircle(
c + Offset(x, y),
1.5 + (i % 3) * 0.5,
Paint()
..color = Color.lerp(
color,
Colors.white,
(i % 3) / 2,
)!.withValues(alpha: 1 - burst),
);
}
}
}
@override
bool shouldRepaint(_EvolutionPainter old) =>
old.progress != progress ||
old.evolution != evolution ||
old.lowEffects != lowEffects;
}
/// 짧은 광원 집중→코어 이동→Ring Wave. 본체 반경 안팎에서 끝낸다.
class _HeroPainter extends CustomPainter {
const _HeroPainter({
required this.progress,
required this.lowEffects,
this.ending = false,
});
final double progress;
final bool ending;
final bool lowEffects;
@override
void paint(Canvas canvas, Size size) {
final c = size.center(Offset.zero);
final r = size.shortestSide * 0.46;
const color = AppColors.gold;
final p = progress;
if (ending) {
final remaining = 1 - Curves.easeInCubic.transform(p);
final count = lowEffects ? 8 : 24;
for (var i = 0; i < count; i++) {
final a = i * 2.399 + p * 0.4;
final reach = r * remaining * (0.7 + (i % 4) * 0.15);
canvas.drawCircle(
c + Offset(math.cos(a), math.sin(a) * 0.7) * reach,
1.2 + i % 3 * 0.4,
Paint()..color = color.withValues(alpha: (1 - p) * 0.65),
);
}
return;
}
if (p < 0.5) {
final travel = ((p - 0.18) / 0.32).clamp(0.0, 1.0);
final point = Offset.lerp(
c + Offset(-r * 0.7, -r),
c,
Curves.easeInCubic.transform(travel),
)!;
final radius = r * (0.10 + travel * 0.10);
canvas.drawCircle(
point,
radius * 2,
Paint()
..shader = RadialGradient(
colors: [
Colors.white.withValues(alpha: 0.85),
color.withValues(alpha: 0.6),
color.withValues(alpha: 0),
],
stops: const [0, 0.2, 1],
).createShader(Rect.fromCircle(center: point, radius: radius * 2)),
);
} else {
final burst = (p - 0.5) * 2;
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2 * (1 - burst) + 0.5
..color = color.withValues(alpha: (1 - burst) * 0.65);
canvas.save();
canvas.translate(c.dx, c.dy);
canvas.rotate(-0.32);
for (var i = 0; i < 2; i++) {
canvas.drawOval(
Rect.fromCenter(
center: Offset.zero,
width: r * (0.8 + burst * 2.2 - i * 0.15),
height: r * (0.3 + burst - i * 0.05),
),
paint,
);
}
canvas.restore();
final count = lowEffects ? 8 : 24;
for (var i = 0; i < count; i++) {
final a = i * 2.399;
final reach = r * (0.3 + burst * (0.8 + (i % 3) * 0.12));
canvas.drawCircle(
c + Offset(math.cos(a), math.sin(a) * 0.7) * reach,
1 + i % 3 * 0.4,
Paint()..color = color.withValues(alpha: (1 - burst) * 0.8),
);
}
}
}
@override
bool shouldRepaint(_HeroPainter old) =>
old.progress != progress ||
old.lowEffects != lowEffects ||
old.ending != ending;
}
+55
View File
@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
/// 같은 화면의 강한 행성 연출을 소유자별로 추적한다. 결과 문구는 숨기지 않는다.
class PlanetFxActivity extends ChangeNotifier {
final _owners = <Object>{};
bool _scheduled = false;
bool _disposed = false;
bool get busy => _owners.isNotEmpty;
void setActive(Object owner, bool active) {
final wasBusy = busy;
active ? _owners.add(owner) : _owners.remove(owner);
if (wasBusy == busy || _scheduled || _disposed) return;
_scheduled = true;
// 자식의 didUpdateWidget/dispose에서도 호출되므로 빌드 중 알림은 피한다.
WidgetsBinding.instance.addPostFrameCallback((_) {
_scheduled = false;
if (!_disposed) notifyListeners();
});
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
class PlanetFxScope extends StatefulWidget {
const PlanetFxScope({super.key, required this.child});
final Widget child;
static PlanetFxActivity? of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_FxScope>()?.notifier;
@override
State<PlanetFxScope> createState() => _PlanetFxScopeState();
}
class _PlanetFxScopeState extends State<PlanetFxScope> {
final activity = PlanetFxActivity();
@override
void dispose() {
activity.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) =>
_FxScope(notifier: activity, child: widget.child);
}
class _FxScope extends InheritedNotifier<PlanetFxActivity> {
const _FxScope({required super.notifier, required super.child});
}
+78
View File
@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import '../theme/tokens.dart';
import '../i18n/app_locale.dart';
/// 시안 검색 화면 상단 배너(`Frame 1984081037`) — #dcdcdc r10 · 오른쪽 핑크 로고 칸 62.
/// GALAXY 검색 · INSIDE 검색이 같이 쓴다.
class PlayBanner extends StatelessWidget {
const PlayBanner({super.key});
static TextStyle _t(double size, double height) => TextStyle(
fontFamily: AppFonts.body,
fontSize: size,
fontWeight: FontWeight.w500,
height: height,
letterSpacing: 0,
color: const Color(0xFF737373),
);
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xFFDCDCDC),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(context.l10n.playNow, style: _t(10, 1.4)),
Text(
context.l10n.playHeadline,
style: const TextStyle(
fontFamily: AppFonts.display,
fontFamilyFallback: [AppFonts.body],
fontWeight: FontWeight.w400,
fontSize: 16,
height: 20 / 16,
letterSpacing: 0,
color: Color(0xFF444444),
),
),
Text(
context.l10n.playBody,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: _t(12, 20 / 12),
),
],
),
),
const SizedBox(width: 11),
Container(
width: 62,
height: 62,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.cosmicAccent,
borderRadius: BorderRadius.circular(10),
),
child: const Text(
'F',
style: TextStyle(
fontFamily: AppFonts.body,
fontSize: 34,
fontWeight: FontWeight.w900,
height: 1,
color: Colors.black,
),
),
),
],
),
);
}
+39
View File
@@ -0,0 +1,39 @@
/// 상대 시각 표기.
///
/// 명세 `523:18399`(캠페인 카드)가 규칙 전부를 정했다 —
/// *~1분 : 방금 전 · ~1시간 : n분 전 · ~24시간 : n시간 전 · ~7일 : n일 전 ·*
/// *이후 : 날짜(2026.01.01)*
///
/// 같은 규칙이 `548:19137`(게시글 카드) · `554:22611` 에도 사본 주석으로 있다. 사본 표본의
/// 「1달 전」은 이 규칙과 어긋나는 표본 문구라 따르지 않는다.
///
/// 화면마다 따로 만들면 「3일 전」과 「2026.01.01」이 같은 목록에서 섞인다.
/// 한 곳에 두고 전 화면이 쓴다.
library;
import '../i18n/app_locale.dart';
String relativeTime(DateTime at, {DateTime? now}) {
final d = (now ?? DateTime.now()).difference(at);
if (d.inMinutes < 1) return appL10n.timeJustNow;
if (d.inMinutes < 60) return appL10n.timeMinutesAgo('${d.inMinutes}');
if (d.inHours < 24) return appL10n.timeHoursAgo('${d.inHours}');
if (d.inDays < 7) return appL10n.timeDaysAgo('${d.inDays}');
// 일주일이 넘으면 「37일 전」보다 날짜가 읽기 쉽다.
final y = at.year.toString().padLeft(4, '0');
final m = at.month.toString().padLeft(2, '0');
final day = at.day.toString().padLeft(2, '0');
return '$y.$m.$day';
}
/// 반응 수치 표기. 명세 — *반응 수치는 K 단위 활용*.
///
/// POP 은 천 단위 구분자를 쓰므로([formatPop]) 다른 함수다. 좋아요 1,200 은
/// 「1.2K」로 줄이는 편이 카드 안에서 읽히지만, POP 잔액을 줄이면 금액이 흐려진다.
String formatReaction(int value) {
if (value < 1000) return '$value';
final k = value / 1000;
// 시안은 「3K」 「1K」 처럼 소수 0 을 떼어 쓴다.
final text = k >= 10 ? '${k.round()}' : k.toStringAsFixed(1);
return '${text.endsWith('.0') ? text.substring(0, text.length - 2) : text}K';
}
+57
View File
@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import '../theme/tokens.dart';
import '../i18n/app_locale.dart';
/// 섹션 제목 + 「더 보기」.
///
/// Figma 홈의 `TODAY'S QUEST` · `AI Daily Briefing` · `HOT ARCHIVE` ·
/// `인기 K-INSIDE` 가 전부 같은 모양이라 하나로 뺐다.
class SectionHeader extends StatelessWidget {
const SectionHeader({
super.key,
required this.title,
this.onMore,
this.moreLabel,
});
final String title;
/// 「더 보기」 자리의 다른 문구. 브리핑은 이 자리가 「공유」다(Figma `266:90529`).
final String? moreLabel;
/// 없으면 「더 보기」를 그리지 않는다.
final VoidCallback? onMore;
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
title,
style: text.titleLarge?.copyWith(fontWeight: FontWeight.w600),
),
),
if (onMore != null)
TextButton(
onPressed: onMore,
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s2),
minimumSize: const Size(0, AppSizing.touchMinApp),
foregroundColor: AppColors.onCosmicVariant,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(moreLabel ?? context.l10n.commonMore, style: text.bodySmall),
const Icon(Icons.chevron_right, size: 16),
],
),
),
],
);
}
}