372 lines
12 KiB
Dart
372 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../../core/data/backend.dart' show BackendException;
|
|
import '../../core/theme/tokens.dart';
|
|
import '../../core/ui/app_dialog.dart';
|
|
import '../../core/ui/app_icons.dart';
|
|
import '../../core/ui/planet_25d.dart' show planetColors, planetShapeImage;
|
|
import 'home_data.dart';
|
|
import '../../core/i18n/app_locale.dart';
|
|
|
|
/// 행성 생성 — Figma 사본 「홈_성장 완료/행성 생성」(`344:214421`) → 「행성 색 선택」
|
|
/// (`2066:17600`) → 생성 확인(`373:358639`).
|
|
///
|
|
/// 정책 원장 C-50 — 생성 때 고르는 것은 **최초 기본 모델의 형태와 컬러 두 가지**뿐이고,
|
|
/// 생성 뒤 사용자가 외형을 바꾸는 입구는 없다. 그래서 사본의 「오브젝트 색 선택」 줄과
|
|
/// 직접 색 고르기(스포이트) 칩은 두지 않는다. 사용자화(컬러 팔레트)를 빼는 근거는 발주사
|
|
/// 10/3 통합회신 #6(정책 원장 C-115) 「사용자 자유 HEX/RGB 컬러피커는 적용하지 않음」 — Figma
|
|
/// `2066:17910` 의 「사용자화」를 이 회신이 대체한다. 「오브젝트 색」 줄은 Q-A14 회신 대기.
|
|
///
|
|
/// 형태 칸 · 미리보기는 행성 형태 시트 그림이다([planetShapeImage] — 6번 칸만 자리표시, Q-A14).
|
|
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',
|
|
];
|
|
|
|
/// 기본 색 키와 칩 색(Figma `2066:17910`). 화면은 컬러 마스터(`config/planetColors`, 10/3 #6)를
|
|
/// [HomeRepository.planetColorOptions] 로 읽어 덮는다 — 발주사가 더한 색도 고를 수 있다.
|
|
static const colors = planetColors;
|
|
|
|
@override
|
|
State<PlanetCreateScreen> createState() => _PlanetCreateScreenState();
|
|
}
|
|
|
|
class _PlanetCreateScreenState extends State<PlanetCreateScreen> {
|
|
int _shape = 0;
|
|
int _color = 0;
|
|
bool _colorStep = false;
|
|
List<(String, Color)> _colors = PlanetCreateScreen.colors;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
widget.repository.planetColorOptions().then((options) {
|
|
if (!mounted || options.isEmpty) return;
|
|
setState(() {
|
|
_colors = options;
|
|
_color = _color.clamp(0, options.length - 1);
|
|
});
|
|
});
|
|
}
|
|
|
|
static const _heading = TextStyle(
|
|
fontFamily: AppFonts.body,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w600,
|
|
height: 24 / 18,
|
|
color: AppColors.onCosmic,
|
|
);
|
|
|
|
/// 고른 형태 그림 — HOME · GALAXY · 행성 상세 · MY 가 그리는 그림과 같은 표([planetShapeImage]).
|
|
Image get _shapeImage =>
|
|
Image.asset(planetShapeImage(PlanetCreateScreen.shapes[_shape])!);
|
|
|
|
Widget _preview() => Center(
|
|
child: SizedBox.square(
|
|
dimension: 212,
|
|
child: _colorStep
|
|
? ColorFiltered(
|
|
colorFilter: ColorFilter.mode(
|
|
_colors[_color].$2,
|
|
BlendMode.modulate,
|
|
),
|
|
child: _shapeImage,
|
|
)
|
|
: _shapeImage,
|
|
),
|
|
);
|
|
|
|
/// 사본 `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,
|
|
),
|
|
// 사본 칸 그림 — 행성 형태 시트에서 잘라 낸 투명 그림([planetShapeImage]). 6번 칸은
|
|
// 사본에서 버튼에 가려 모양이 보이지 않아 자리표시를 둔다(Q-A14).
|
|
padding: const EdgeInsets.all(4),
|
|
child: Image.asset(planetShapeImage(PlanetCreateScreen.shapes[i])!),
|
|
),
|
|
),
|
|
);
|
|
|
|
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 _colorPicker() => 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 < _colors.length; i++)
|
|
GestureDetector(
|
|
onTap: () => setState(() => _color = i),
|
|
child: Semantics(
|
|
button: true,
|
|
selected: _color == i,
|
|
label: _colors[i].$1,
|
|
child: Container(
|
|
width: 28,
|
|
height: 28,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: _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) _colorPicker() 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 showConfirmDialog(
|
|
context,
|
|
context.l10n.planetCreateConfirm,
|
|
confirmLabel: context.l10n.commonCreate,
|
|
);
|
|
if (!ok || !mounted || _busy) return;
|
|
_busy = true;
|
|
try {
|
|
await widget.repository.createPlanet(
|
|
fandomId: widget.fandomId,
|
|
planetType: PlanetCreateScreen.shapes[_shape],
|
|
colorKey: _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),
|
|
);
|
|
}
|
|
|