import 'package:flutter/material.dart'; import '../../core/theme/tokens.dart'; import '../../core/ui/app_dialog.dart'; import '../../core/ui/app_icons.dart'; import '../../core/data/backend.dart' show BackendException; import 'home_data.dart'; import 'home_screen.dart' show formatPop; import '../../core/i18n/app_locale.dart'; /// 성운 생성 — Figma 사본 「홈/성운 생성」(`340:75466`) · 이름 입력 후(`340:75644`). /// /// 틀은 사본(이름 입력 → 성운 → 목표 영역 → 「성운 생성」)이고 숫자는 정책 원장이다. /// B-10 — 성운은 **10명 이상 + 1,000 POP 이상**을 기본 목표 **7일** 안에 채우면 정식 /// 행성이 된다. 목표가 고정값이라 사본의 목표 슬라이더 자리에는 조작할 수 없는 고정 /// 표시를 둔다. 근거는 발주사 10/3 통합회신(원장 C-112) 「성운 생성은 10 명 + 1,000 POP + /// 기본 목표 7 일, 사용자 목표 POP 조작 없음」 — Figma `516:87407` 목표 POP · 창립멤버 핸들러를 /// 이 회신이 대체한다. B-19 — 진화 참여자에게는 /// 기념 배지만 지급하므로 사본의 「1T 등급 · 거버넌스」 문장은 그 내용으로 바꾼다. /// /// 사본이 함께 그린 다이얼로그 두 개를 그대로 쓴다. /// - 「선택한 행성을 기본 행성으로 변경할까요?」 취소 / 변경 /// - 「이미 오늘 성운을 생성하셨습니다.」 확인 ← **일 1회 제한** class NebulaCreateScreen extends StatefulWidget { const NebulaCreateScreen({ super.key, this.alreadyCreatedToday = false, this.myPop = 0, this.repository = const SampleHomeRepository(), }); /// 생성 경로(`fn-nebula-create`). 성공하면 만든 팬덤 ID 를 들고 이 화면을 닫는다. final HomeRepository repository; /// 오늘 이미 만들었는가. Figma 의 「생성 횟수 초과」 상태. final bool alreadyCreatedToday; /// 「나의 보유 POP」 — 여는 쪽이 지갑의 활동 POP 을 넘긴다. final int myPop; @override State createState() => _NebulaCreateScreenState(); } class _NebulaCreateScreenState extends State { final _name = TextEditingController(); String get _value => _name.text.trim(); /// 서버와 같은 `config/policy` 운영값. 읽기 전 · 실패 시 원장 기본값. NebulaTargets _targets = defaultNebulaTargets; /// 서버가 이미 쓰인다고 답한 이름(E-FDM-01). 중복 판정은 서버만 한다 — /// `fandomNames` 는 클라이언트가 읽지 못한다. String? _takenName; bool get _taken => _takenName != null && _takenName == _value; /// 설명 프레임 `516:87397` 「성운명 입력 필드 - 유효성 검증 오류 메시지」 — 서버 /// `isValidNebulaName` 과 같은 규칙을 입력 칸에서 먼저 보인다(중복 · 금칙어는 서버 판정). bool get _invalid => _name.text.isNotEmpty && !isValidNebulaName(_name.text); @override void initState() { super.initState(); _name.addListener(() => setState(() {})); widget.repository.nebulaTargets().then((t) { if (mounted) setState(() => _targets = t); }, onError: (_) {}); } @override void dispose() { _name.dispose(); super.dispose(); } static const _muted = Color(0xFFCDCDCD); @override Widget build(BuildContext context) => Scaffold( body: SafeArea( bottom: false, child: Column( children: [ // 사본 `Top Navigation` — 왼쪽 X 만. SizedBox( height: 56, child: Align( alignment: Alignment.centerLeft, child: 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: ListView( padding: const EdgeInsets.all(AppSpace.s4), children: [ Text( context.l10n.nebulaHeadline, textAlign: TextAlign.center, style: TextStyle( fontFamily: AppFonts.display, fontSize: 20, fontWeight: FontWeight.w400, height: 28 / 20, letterSpacing: -0.5, color: AppColors.onCosmic, ), ), const SizedBox(height: 20), // 사본 문단 상자는 84(4줄) — 줄 수가 달라도 아래 배치가 흔들리지 않게 높이를 잡는다. SizedBox( height: 84, child: Text( context.l10n.nebulaBody('${_targets.days}'), textAlign: TextAlign.center, style: TextStyle( fontFamily: AppFonts.display, fontSize: 15, fontWeight: FontWeight.w400, height: 1.4, letterSpacing: -0.5, color: _muted, ), ), ), const SizedBox(height: AppSpace.s5), _NameInput(controller: _name, error: _taken || _invalid), const SizedBox(height: 6), SizedBox( height: 17, child: _taken || _invalid ? Text( _taken ? context.l10n.nebulaNameTaken : context.l10n.nebulaNameInvalid, textAlign: TextAlign.center, style: TextStyle( fontFamily: AppFonts.display, fontSize: 12, height: 1.4, color: Color(0xFFEC6D6D), ), ) : null, ), const SizedBox(height: AppSpace.s5), Center( child: Image.asset( 'assets/figma/nebula-home.png', width: 212, height: 212, ), ), const SizedBox(height: AppSpace.s5), _Target(myPop: widget.myPop, targets: _targets), ], ), ), // 이름을 넣은 뒤(`340:75644`)에만 「성운 생성」이 나온다. if (_value.isNotEmpty) SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB( AppSpace.s4, 0, AppSpace.s4, 0, ), child: FilledButton( onPressed: _taken || _invalid ? null : _confirm, style: FilledButton.styleFrom( 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(context.l10n.nebulaCreate), ), ), ) else SizedBox(height: MediaQuery.paddingOf(context).bottom), ], ), ), ); Future _confirm() async { // 일 1회 제한이 걸려 있으면 확인 다이얼로그를 띄우지 않고 사유를 알린다. if (widget.alreadyCreatedToday) { await showAlertDialog(context, context.l10n.nebulaAlreadyToday); return; } final ok = await showConfirmDialog( context, context.l10n.galaxySetPrimaryConfirm, ); if (!ok || !mounted || _busy) return; _busy = true; final name = _value; try { final created = await widget.repository.createNebula(name); if (!mounted) return; // C-15 — 대표 변경 30일 대기 중이면 성운만 생기고 대표는 그대로다. 알리고 닫는다. if (!created.primaryChanged) { await showAlertDialog( context, context.l10n.nebulaCreatedKeepPrimary, ); if (!mounted) return; } Navigator.of(context).pop(created.fandomId); } on BackendException catch (e) { if (!mounted) return; // 이름 중복은 사본의 입력칸 오류 상태로, 나머지(일 1회 E-FDM-02 · 이름 규칙)는 알림으로. if (e.code == 'E-FDM-01') { setState(() => _takenName = name); } else { await showAlertDialog(context, e.message); } } catch (_) { if (mounted) await showAlertDialog( context, context.l10n.nebulaCreateFailed, ); } finally { _busy = false; } } /// 생성 요청 중 — 연타로 두 번 부르지 않는다. bool _busy = false; } /// 사본 `Input` — 271×58 · 분홍 10% · r8 · 패딩 12/16 · 42dot Medium 18 가운데. /// 이름이 들어가면 분홍 1 테두리(`340:75650`). class _NameInput extends StatelessWidget { const _NameInput({required this.controller, required this.error}); final TextEditingController controller; final bool error; @override Widget build(BuildContext context) { OutlineInputBorder border(Color c) => OutlineInputBorder( borderRadius: BorderRadius.circular(AppRadius.sm), borderSide: BorderSide(color: c), ); final filled = controller.text.trim().isNotEmpty; return Padding( padding: const EdgeInsets.symmetric(horizontal: 36), child: TextField( controller: controller, textAlign: TextAlign.center, style: const TextStyle( fontFamily: AppFonts.body, fontSize: 18, fontWeight: FontWeight.w500, height: 1, letterSpacing: -0.45, color: AppColors.onCosmic, ), decoration: InputDecoration( hintText: context.l10n.nebulaNameHint, hintStyle: const TextStyle( fontFamily: AppFonts.body, fontSize: 18, fontWeight: FontWeight.w500, color: AppColors.onCosmicMuted, ), filled: true, fillColor: AppColors.cosmicAccent.withValues(alpha: 0.1), contentPadding: const EdgeInsets.symmetric( horizontal: AppSpace.s4, vertical: 20, ), enabledBorder: border( filled ? AppColors.cosmicAccent : Colors.transparent, ), focusedBorder: border(AppColors.cosmicAccent), ), ), ); } } /// 목표 영역 — 사본 `Frame 1984081264`. 슬라이더 자리는 원장 고정값을 보여 주기만 한다. class _Target extends StatelessWidget { const _Target({required this.myPop, required this.targets}); final int myPop; final NebulaTargets targets; static const _scale = TextStyle( fontFamily: AppFonts.display, fontSize: 12, height: 1, color: Color(0xFFCDCDCD), ); @override Widget build(BuildContext context) => Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 2), child: Row( children: [ // 사본은 ABeeZee Italic 이지만 한글은 기울지 않는다 — 라틴 글자만 기울인다. Expanded( child: Text.rich( TextSpan( text: context.l10n.nebulaGoal, children: [ TextSpan( text: 'POP', style: TextStyle(fontStyle: FontStyle.italic), ), TextSpan(text: context.l10n.nebulaFounders), ], ), style: TextStyle( fontFamily: AppFonts.display, fontSize: 18, fontWeight: FontWeight.w400, height: 28 / 18, letterSpacing: -0.5, color: AppColors.onCosmic, ), ), ), Text( '${formatPop(targets.pop)}P / ' '${targets.members}', style: const TextStyle( fontFamily: AppFonts.body, fontSize: 18, fontWeight: FontWeight.w500, height: 1, letterSpacing: -0.45, color: AppColors.cosmicAccent, ), ), ], ), ), const SizedBox(height: AppSpace.s3), // 고정 목표 — 트랙 전체를 채우고 손잡이는 두지 않는다(끌 수 없다는 표시). Semantics( label: context.l10n.nebulaGoalSemantics( formatPop(targets.pop), '${targets.members}', '${targets.days}', ), child: Padding( padding: const EdgeInsets.symmetric(vertical: 11), child: Container( height: 6, decoration: BoxDecoration( color: AppColors.cosmicAccent, borderRadius: BorderRadius.circular(AppRadius.full), ), ), ), ), const SizedBox(height: 6), Row( children: [ const Text('0', style: _scale), const Spacer(), Text(formatPop(targets.pop), style: _scale), ], ), const SizedBox(height: AppSpace.s3), Padding( padding: const EdgeInsets.symmetric(horizontal: 2), child: Text.rich( TextSpan( text: context.l10n.commonMyPop, children: [ TextSpan( text: formatPop(myPop), style: const TextStyle(color: AppColors.onCosmic), ), ], ), style: const TextStyle( fontFamily: AppFonts.body, fontSize: 14, fontWeight: FontWeight.w500, height: 1, letterSpacing: -0.35, color: AppColors.onCosmicVariant, ), ), ), ], ); } /// 성운명 규칙 — 서버 `isValidNebulaName`(`account.ts`)과 같다: 2~20자 · 한글/영문/숫자/공백 · /// 앞뒤 공백 · 연속 공백 불가. bool isValidNebulaName(String name) => RegExp(r'^[가-힣A-Za-z0-9 ]{2,20}$').hasMatch(name) && name == name.trim() && !name.contains(' ');