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 createState() => _PlanetCreateScreenState(); } class _PlanetCreateScreenState extends State { 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 _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), ); }