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

427 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<NebulaCreateScreen> createState() => _NebulaCreateScreenState();
}
class _NebulaCreateScreenState extends State<NebulaCreateScreen> {
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),
// 한 줄 자리(17)는 비어 있어도 잡아 두되, 두 줄 문구는 잘리지 않게 늘어난다
// (10/6 실기기 「…연속/앞뒤 공」 잘림).
ConstrainedBox(
constraints: const BoxConstraints(minHeight: 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<void> _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(' ');