앱 공통 계층 — 데이터 저장소 · 서버 시계 · 푸시 · 결제·광고 · 다국어 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
+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;
}