HOME · GALAXY · 행성·성운 상세 · 팬덤 라운지 · 이클립스 화면
This commit is contained in:
@@ -0,0 +1,422 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/data/backend.dart' show BackendException;
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/app_icons.dart';
|
||||
import '../../core/ui/planet_25d.dart' show planetColors;
|
||||
import 'home_data.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 행성 생성 — Figma 사본 「홈_성장 완료/행성 생성」(`344:214421`) → 「행성 색 선택」
|
||||
/// (`2066:17600`) → 생성 확인(`373:358639`).
|
||||
///
|
||||
/// 정책 원장 C-50 — 생성 때 고르는 것은 **최초 기본 모델의 형태와 컬러 두 가지**뿐이고,
|
||||
/// 생성 뒤 사용자가 외형을 바꾸는 입구는 없다. 그래서 사본의 「오브젝트 색 선택」 줄과
|
||||
/// 직접 색 고르기(스포이트) 칩은 두지 않는다.
|
||||
///
|
||||
/// 형태 칸은 사본에서도 모두 같은 자리표시 이미지다(개별 형태 에셋은 S08 확인 대상).
|
||||
class PlanetCreateScreen extends StatefulWidget {
|
||||
const PlanetCreateScreen({
|
||||
super.key,
|
||||
this.nebulaName = 'BTS',
|
||||
this.fandomId = '',
|
||||
this.repository = const SampleHomeRepository(),
|
||||
});
|
||||
|
||||
final String nebulaName;
|
||||
|
||||
/// 탄생시킬 성운(팬덤) ID.
|
||||
final String fandomId;
|
||||
|
||||
/// 생성 경로(`fn-planet-create`). 성공하면 팬덤 ID 를 들고 이 화면을 닫는다.
|
||||
final HomeRepository repository;
|
||||
|
||||
/// 형태 키. 사본 칸에 이름이 없어 순번으로 둔다(에셋 확정 때 이름을 붙인다).
|
||||
static const shapes = [
|
||||
'shape1',
|
||||
'shape2',
|
||||
'shape3',
|
||||
'shape4',
|
||||
'shape5',
|
||||
'shape6',
|
||||
];
|
||||
|
||||
/// 색 키와 칩 색 — 렌더링과 같은 표([planetColors]).
|
||||
static const colors = planetColors;
|
||||
|
||||
@override
|
||||
State<PlanetCreateScreen> createState() => _PlanetCreateScreenState();
|
||||
}
|
||||
|
||||
class _PlanetCreateScreenState extends State<PlanetCreateScreen> {
|
||||
int _shape = 0;
|
||||
int _color = 0;
|
||||
bool _colorStep = false;
|
||||
|
||||
static const _placeholder = AssetImage(
|
||||
'assets/figma/planet-shape-placeholder.png',
|
||||
);
|
||||
|
||||
static const _heading = TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 24 / 18,
|
||||
color: AppColors.onCosmic,
|
||||
);
|
||||
|
||||
Widget _preview() => Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 212,
|
||||
child: _colorStep
|
||||
? ColorFiltered(
|
||||
colorFilter: ColorFilter.mode(
|
||||
PlanetCreateScreen.colors[_color].$2,
|
||||
BlendMode.modulate,
|
||||
),
|
||||
child: const Image(image: _placeholder),
|
||||
)
|
||||
: const Image(image: _placeholder),
|
||||
),
|
||||
);
|
||||
|
||||
/// 사본 `Frame 1984081334` — #2C2C2C r12 칸, 고른 칸은 분홍 2 테두리.
|
||||
Widget _tile(int i, double size) => GestureDetector(
|
||||
onTap: () => setState(() => _shape = i),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
selected: _shape == i,
|
||||
label: context.l10n.planetShape('${i + 1}'),
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2C2C2C),
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
border: _shape == i
|
||||
? Border.all(color: AppColors.cosmicAccent, width: 2)
|
||||
: null,
|
||||
),
|
||||
// 사본 칸 그림(렌더에서 4 안쪽을 자른 것). 6번 칸은 사본에서 버튼에 가려
|
||||
// 모양이 보이지 않아 자리표시를 둔다.
|
||||
padding: i < 5 ? const EdgeInsets.all(4) : null,
|
||||
child: Image.asset(
|
||||
i < 5
|
||||
? 'assets/figma/planet-shape-${i + 1}.png'
|
||||
: 'assets/figma/planet-shape-placeholder.png',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _shapes() => LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
final third = (box.maxWidth - 16) / 3;
|
||||
final half = (box.maxWidth - 8) / 2;
|
||||
// 사본 배치 — 3칸 · 2칸 · 1칸(아래는 버튼 뒤로 스크롤).
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
for (final i in [0, 1, 2]) ...[
|
||||
if (i > 0) const SizedBox(width: 8),
|
||||
_tile(i, third),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpace.s5),
|
||||
Row(
|
||||
children: [
|
||||
_tile(3, half),
|
||||
const SizedBox(width: 8),
|
||||
_tile(4, half),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpace.s5),
|
||||
_tile(5, box.maxWidth),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/// 사본 `Frame 1984081340` — 28 원 · 간격 8 · 고른 칩 분홍 4 테두리, 나머지 흰 2.
|
||||
Widget _colors() => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(context.l10n.planetBgColor, style: _heading),
|
||||
const SizedBox(height: AppSpace.s4),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (var i = 0; i < PlanetCreateScreen.colors.length; i++)
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _color = i),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
selected: _color == i,
|
||||
label: PlanetCreateScreen.colors[i].$1,
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: PlanetCreateScreen.colors[i].$2,
|
||||
border: Border.all(
|
||||
color: _color == i
|
||||
? AppColors.cosmicAccent
|
||||
: AppColors.onCosmic,
|
||||
width: _color == i ? 4 : 2,
|
||||
strokeAlign: BorderSide.strokeAlignInside,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
_TopBar(title: _colorStep
|
||||
? context.l10n.planetColorSelect
|
||||
: context.l10n.planetTypeSelect),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
// 사본 주 프레임(`344:214421`)만 본문이 21 아래서 시작한다(y125).
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
AppSpace.s4,
|
||||
AppSpace.s4 + (_colorStep ? 0 : 21),
|
||||
AppSpace.s4,
|
||||
AppSpace.s4,
|
||||
),
|
||||
children: [
|
||||
_preview(),
|
||||
const SizedBox(height: AppSpace.s5),
|
||||
if (_colorStep) _colors() else _shapes(),
|
||||
],
|
||||
),
|
||||
),
|
||||
ColoredBox(
|
||||
color: AppColors.cosmicVoid,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: _colorStep
|
||||
// 사본 `2066:17600` — 반폭 두 버튼(이전 검정 · 완료 분홍), 간격 10.
|
||||
? Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _Button(
|
||||
label: context.l10n.commonPrev,
|
||||
fill: Colors.black,
|
||||
ink: AppColors.onCosmic,
|
||||
onTap: () => setState(() => _colorStep = false),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _Button(label: context.l10n.commonDone, onTap: _confirm),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpace.s4,
|
||||
),
|
||||
child: _Button(
|
||||
label: context.l10n.commonNext,
|
||||
onTap: () => setState(() => _colorStep = true),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> _confirm() async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierColor: const Color(0xB3000000),
|
||||
builder: (context) => const _ConfirmDialog(),
|
||||
);
|
||||
if (ok != true || !mounted || _busy) return;
|
||||
_busy = true;
|
||||
try {
|
||||
await widget.repository.createPlanet(
|
||||
fandomId: widget.fandomId,
|
||||
planetType: PlanetCreateScreen.shapes[_shape],
|
||||
colorKey: PlanetCreateScreen.colors[_color].$1,
|
||||
);
|
||||
if (mounted) Navigator.of(context).pop(widget.fandomId);
|
||||
} on BackendException catch (e) {
|
||||
_fail(e.message);
|
||||
} catch (_) {
|
||||
_fail(context.l10n.planetCreateFailed);
|
||||
} finally {
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 생성 요청 중 — 연타로 두 번 부르지 않는다.
|
||||
bool _busy = false;
|
||||
|
||||
void _fail(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 사본 `Top Navigation` — 왼쪽 X(24) · 가운데 제목(16 SemiBold) · 오른쪽 빈 자리(「완료」 투명도 0).
|
||||
class _TopBar extends StatelessWidget {
|
||||
const _TopBar({required this.title});
|
||||
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SizedBox(
|
||||
height: 56,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
constraints: const BoxConstraints.tightFor(width: 56, height: 56),
|
||||
icon: const ImageIcon(
|
||||
AppIcons.x,
|
||||
size: 24,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
tooltip: context.l10n.commonClose,
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.5,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 사본의 오른쪽 「완료」는 투명도 0 이라 보이지 않는다. 제목 가운데 정렬용 자리만 둔다.
|
||||
const SizedBox(width: 56),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 사본 `Button` — 56 높이 · r8 · 16 SemiBold.
|
||||
class _Button extends StatelessWidget {
|
||||
const _Button({
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.fill = AppColors.cosmicAccent,
|
||||
this.ink = AppColors.onCosmicAccent,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final Color fill;
|
||||
final Color ink;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => FilledButton(
|
||||
onPressed: onTap,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: fill,
|
||||
foregroundColor: ink,
|
||||
minimumSize: const Size.fromHeight(56),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
child: Text(label),
|
||||
);
|
||||
}
|
||||
|
||||
/// 사본 `Frame 1984080943` — 312 폭 #282828 r12 · 패딩 24/16 · 취소(흰 1 테두리) / 생성(분홍).
|
||||
class _ConfirmDialog extends StatelessWidget {
|
||||
const _ConfirmDialog();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Dialog(
|
||||
backgroundColor: const Color(0xFF282828),
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 31),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.planetCreateConfirm,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.5,
|
||||
color: Color(0xFFF8F8F8),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.onCosmic,
|
||||
side: const BorderSide(color: AppColors.onCosmic),
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.commonCancel),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.commonCreate),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user