HOME · GALAXY · 행성·성운 상세 · 팬덤 라운지 · 이클립스 화면
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
|
||||
/// iOS 식 하단 시트 한 줄.
|
||||
class ActionSheetItem {
|
||||
const ActionSheetItem(this.label, this.onTap, {this.destructive = true});
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// 시안의 신고 · 숨기기는 모두 #ff203b.
|
||||
final bool destructive;
|
||||
}
|
||||
|
||||
/// 시안 `Frame 1984081055` — #2c2c2e r16 묶음(줄 58, 사이선 #6a6864) + 취소 줄 54,
|
||||
/// 42dot Sans 20/500. 파괴 동작 #ff203b, 취소 #1895ff.
|
||||
Future<void> showActionSheet(
|
||||
BuildContext context,
|
||||
List<ActionSheetItem> items,
|
||||
) {
|
||||
TextStyle style(Color c) => TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.3,
|
||||
letterSpacing: -0.5,
|
||||
color: c,
|
||||
);
|
||||
Widget cell(String label, Color color, double height, VoidCallback onTap) =>
|
||||
InkWell(
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
height: height,
|
||||
width: double.infinity,
|
||||
child: Center(child: Text(label, style: style(color))),
|
||||
),
|
||||
);
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
barrierColor: Colors.black.withValues(alpha: 0.6),
|
||||
builder: (sheet) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Material(
|
||||
color: AppColors.cosmicSheet,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
for (final (i, item) in items.indexed) ...[
|
||||
if (i > 0)
|
||||
const Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: Color(0xFF6A6864),
|
||||
),
|
||||
cell(
|
||||
item.label,
|
||||
item.destructive
|
||||
? const Color(0xFFFF203B)
|
||||
: AppColors.onCosmic,
|
||||
58,
|
||||
() {
|
||||
Navigator.of(sheet).pop();
|
||||
item.onTap();
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Material(
|
||||
color: AppColors.cosmicSheet,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: cell(
|
||||
context.l10n.commonCancel,
|
||||
const Color(0xFF1895FF),
|
||||
54,
|
||||
() => Navigator.of(sheet).pop(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../planet_data.dart';
|
||||
|
||||
/// TOTAL FPI 오각 그래프 — Figma 명세 「TOTAL FPI 영역」 · 시안 `Frame 1984081147`(232×140).
|
||||
///
|
||||
/// *데이터 : TOTAL FPI 값 · 육각형 그래프 · 규칙 : 실시간 데이터 기준 노출*
|
||||
///
|
||||
/// 시안 — 다각형 114×108(외곽 #2c2c2e 2 + 안쪽 3단 1), 데이터 면 #5bd4de 20% · 선 1,
|
||||
/// 꼭짓점 6 #64eff9. 축 이름 42dot Sans 10/500 #dde3e4 는 위 · 좌우 · 아래 두 칸에 놓는다.
|
||||
/// 지표가 다섯이 아니면 축 이름을 꼭짓점 바깥에 방사형으로 놓는다.
|
||||
class FpiHexagon extends StatelessWidget {
|
||||
const FpiHexagon({super.key, required this.scores, required this.max});
|
||||
|
||||
final List<FpiScore> scores;
|
||||
final int max;
|
||||
|
||||
static const size = Size(232, 140);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SizedBox.fromSize(
|
||||
size: size,
|
||||
child: CustomPaint(
|
||||
painter: _HexPainter(
|
||||
scores: scores,
|
||||
max: max,
|
||||
labelStyle: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1,
|
||||
color: Color(0xFFDDE3E4),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _HexPainter extends CustomPainter {
|
||||
_HexPainter({
|
||||
required this.scores,
|
||||
required this.max,
|
||||
required this.labelStyle,
|
||||
});
|
||||
|
||||
final List<FpiScore> scores;
|
||||
final int max;
|
||||
final TextStyle labelStyle;
|
||||
|
||||
/// 격자 단 비율 — 시안 Polygon 3·4·5(93.9 · 63.9 · 32 / 114).
|
||||
static const rings = [1.0, 0.82, 0.56, 0.28];
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (scores.isEmpty) return;
|
||||
final n = scores.length;
|
||||
// 외접원 반지름 — 오각형 높이 108 = R(1 + cos 36°).
|
||||
final radius = 108 / (1 + math.cos(math.pi / 5)) * size.height / 140;
|
||||
final center = Offset(size.width / 2, size.height * 16 / 140 + radius);
|
||||
|
||||
Offset at(int i, double r) {
|
||||
// 12시 방향에서 시작해 시계 방향.
|
||||
final a = -math.pi / 2 + (2 * math.pi * i) / n;
|
||||
return center + Offset(math.cos(a) * r, math.sin(a) * r);
|
||||
}
|
||||
|
||||
Path poly(double r) {
|
||||
final p = Path();
|
||||
for (var i = 0; i < n; i++) {
|
||||
final o = at(i, r);
|
||||
i == 0 ? p.moveTo(o.dx, o.dy) : p.lineTo(o.dx, o.dy);
|
||||
}
|
||||
return p..close();
|
||||
}
|
||||
|
||||
for (final (i, k) in rings.indexed) {
|
||||
canvas.drawPath(
|
||||
poly(radius * k),
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = i == 0 ? 2 : 1
|
||||
..color = AppColors.cosmicOutline,
|
||||
);
|
||||
}
|
||||
|
||||
// 데이터 다각형.
|
||||
final data = Path();
|
||||
for (var i = 0; i < n; i++) {
|
||||
final o = at(i, radius * scores[i].normalized(max));
|
||||
i == 0 ? data.moveTo(o.dx, o.dy) : data.lineTo(o.dx, o.dy);
|
||||
}
|
||||
data.close();
|
||||
const line = Color(0xFF5BD4DE);
|
||||
canvas
|
||||
..drawPath(data, Paint()..color = line.withValues(alpha: 0.2))
|
||||
..drawPath(
|
||||
data,
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1
|
||||
..color = line,
|
||||
);
|
||||
|
||||
// 시안 좌표(232×140 기준) — 위 · 오른쪽 · 오른쪽 아래 · 왼쪽 아래 · 왼쪽.
|
||||
const anchors = [
|
||||
Offset(116, 5),
|
||||
Offset(201, 70),
|
||||
Offset(179.5, 135),
|
||||
Offset(59, 135),
|
||||
Offset(25, 70),
|
||||
];
|
||||
final sx = size.width / 232, sy = size.height / 140;
|
||||
for (var i = 0; i < n; i++) {
|
||||
canvas.drawCircle(
|
||||
at(i, radius * scores[i].normalized(max)),
|
||||
3,
|
||||
Paint()..color = AppColors.neonCyan,
|
||||
);
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(text: scores[i].metric.label, style: labelStyle),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
final o = n == 5
|
||||
? Offset(anchors[i].dx * sx, anchors[i].dy * sy)
|
||||
: at(i, radius + 14);
|
||||
tp.paint(canvas, o - Offset(tp.width / 2, tp.height / 2));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_HexPainter old) => old.scores != scores || old.max != max;
|
||||
}
|
||||
|
||||
/// 그리기 비용 측정용 진입점. 사유는 [galaxyFieldPainterForTest] 와 같다.
|
||||
@visibleForTesting
|
||||
CustomPainter fpiHexagonPainterForTest({
|
||||
required List<FpiScore> scores,
|
||||
required int max,
|
||||
TextStyle labelStyle = const TextStyle(fontSize: 10),
|
||||
}) => _HexPainter(scores: scores, max: max, labelStyle: labelStyle);
|
||||
@@ -0,0 +1,258 @@
|
||||
import '../../../core/ui/hero_time_status.dart';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../../../core/ui/live_planet.dart';
|
||||
import '../../../core/data/planet_visual_state.dart';
|
||||
import '../../home/home_screen.dart' show formatPop;
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 행성 상세 히어로 — 시안 `373:441117` 의 `Frame 1984081117`(375×543).
|
||||
///
|
||||
/// 우주 배경 사진(`373:441126` IMAGE 채움) 위, 상단 104(상태 표시줄 48 + 내비 56)
|
||||
/// 아래 영역에 개설자(아바타 26 · ABeeZee 16), 행성 일러스트 200, 규모 칩과 멤버 수,
|
||||
/// 행성 이름 ABeeZee 46, 「FPI : n | 1인당 화력 : n」, 「가입중」 알약을 25 · 12 간격으로
|
||||
/// 쌓는다. 뒤로가기 · 더보기는 화면의 앱 바가 그린다(스크롤하면 앱 바만 남는다).
|
||||
class PlanetDetailHero extends StatelessWidget {
|
||||
const PlanetDetailHero({
|
||||
super.key,
|
||||
required this.name,
|
||||
required this.members,
|
||||
required this.totalFpi,
|
||||
required this.joined,
|
||||
this.nebula = false,
|
||||
this.onHeroParticipate,
|
||||
this.onJoin,
|
||||
this.onOpenHistory,
|
||||
this.fandomId = '',
|
||||
this.watchPlanet = noPlanetWatch,
|
||||
this.stage,
|
||||
this.ownerName,
|
||||
this.ownerPhoto,
|
||||
this.sizeLabel,
|
||||
});
|
||||
|
||||
final VoidCallback? onOpenHistory;
|
||||
final String fandomId;
|
||||
final PlanetWatch watchPlanet;
|
||||
final int? stage;
|
||||
final String name;
|
||||
final int members;
|
||||
final int totalFpi;
|
||||
final bool joined;
|
||||
final VoidCallback? onHeroParticipate;
|
||||
|
||||
/// 「가입하기」 알약을 눌렀을 때. 이미 가입했으면 null 이고 알약은 상태만 보인다.
|
||||
final VoidCallback? onJoin;
|
||||
|
||||
/// 성운 상세(`373:442708`)는 규모 칩과 FPI 줄이 없다.
|
||||
final bool nebula;
|
||||
|
||||
/// 개설자 표시명. 서버에 읽을 자리가 없으면 null 이고 줄을 그리지 않는다 —
|
||||
/// 예전에는 시안의 `BTS_Maker` 가 모든 행성에 박혀 있었다(인계 결함).
|
||||
final String? ownerName;
|
||||
|
||||
/// 개설자 사진(URL 또는 에셋). 없으면 회색 원.
|
||||
final String? ownerPhoto;
|
||||
|
||||
/// 팬덤 규모 라벨. 정책 §P-08 등급명이 붙기 전까지 시안 문구를 쓴다.
|
||||
final String? sizeLabel;
|
||||
|
||||
static const double designHeight = 543;
|
||||
|
||||
/// 시안의 상태 표시줄 + 내비 높이. 본문은 그 아래 439 안에 가운데 놓인다.
|
||||
static const double topBar = 104;
|
||||
|
||||
static TextStyle _body(double size, Color color) => TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: size,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1,
|
||||
color: color,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 1인당 화력 — 시안 「1.44」. 총 FPI 를 멤버 수로 나눈 값이며 정책 확정 전 표시용이다.
|
||||
final perHead = members == 0 ? 0.0 : totalFpi / members;
|
||||
const info = Color(0xFFDEDEDE);
|
||||
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
LivePlanetLighting(
|
||||
fandomId: fandomId,
|
||||
watch: watchPlanet,
|
||||
child: Image.asset(
|
||||
'assets/figma/bg-space-detail.png',
|
||||
fit: BoxFit.cover,
|
||||
alignment: Alignment.topCenter,
|
||||
),
|
||||
),
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: [0, 0.7, 1],
|
||||
colors: [
|
||||
Color(0x00141414),
|
||||
Color(0x22141414),
|
||||
AppColors.cosmicVoid,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, topBar, 16, 0),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (ownerName case final owner?) ...[
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 13,
|
||||
backgroundColor: const Color(0xFFD9D9D9),
|
||||
backgroundImage: switch (ownerPhoto) {
|
||||
final p? when p.startsWith('http') => NetworkImage(p),
|
||||
final p? => AssetImage(p),
|
||||
null => null,
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
Text(
|
||||
owner,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 16,
|
||||
height: 1.3,
|
||||
letterSpacing: -0.4,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 25),
|
||||
],
|
||||
LivePlanet25D(
|
||||
onOpenHistory: onOpenHistory,
|
||||
fandomId: fandomId,
|
||||
watch: watchPlanet,
|
||||
image: 'assets/figma/planet-detail-hero.png',
|
||||
size: 200,
|
||||
glow: AppColors.neonViolet,
|
||||
stage: stage,
|
||||
),
|
||||
HeroTimeStatus(
|
||||
fandomId: fandomId,
|
||||
watch: watchPlanet,
|
||||
onParticipate: onHeroParticipate,
|
||||
),
|
||||
const SizedBox(height: 25),
|
||||
// 규모 칩 + 멤버 수
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!nebula) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x1A54C07D),
|
||||
border: Border.all(color: const Color(0xFF54C07D)),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
sizeLabel ?? context.l10n.planetSizeMedium,
|
||||
style: _body(12, const Color(0xFF54C07D)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Image.asset(
|
||||
'assets/figma/icon-person.png',
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
formatPop(members),
|
||||
style: _body(14, AppColors.onCosmic),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 46,
|
||||
height: 1,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (!nebula) ...[
|
||||
// 값이 커지면(백만 단위 FPI) 한 줄을 줄여 맞춘다.
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'FPI : ${formatPop(totalFpi)}',
|
||||
style: _body(14, info),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text('|', style: _body(14, info)),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
context.l10n.planetPerHead(perHead.toStringAsFixed(2)),
|
||||
style: _body(14, info),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
Semantics(
|
||||
button: onJoin != null,
|
||||
child: GestureDetector(
|
||||
onTap: onJoin,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
child: Text(
|
||||
joined ? context.l10n.planetJoined : context.l10n.planetJoin,
|
||||
style: _body(
|
||||
14,
|
||||
const Color(0xFFD5D7D9),
|
||||
).copyWith(letterSpacing: -0.35),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user