Files
fanuzu/app/lib/features/home/planet_create_screen.dart
T

423 lines
13 KiB
Dart

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),
),
),
],
),
],
),
),
);
}