HOME · GALAXY · 행성·성운 상세 · 팬덤 라운지 · 이클립스 화면

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent c4e9a9430e
commit 5de04ac281
44 changed files with 14898 additions and 16 deletions
@@ -0,0 +1,443 @@
import 'package:flutter/material.dart';
import '../../core/theme/tokens.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일** 안에 채우면 정식
/// 행성이 된다. 목표가 고정값이라 사본의 목표 슬라이더 자리에는 조작할 수 없는 고정
/// 표시를 둔다(사본 슬라이더 ↔ 원장 고정값, 해석 확인 문항). 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;
@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),
const SizedBox(height: 6),
SizedBox(
height: 17,
child: _taken
? Text(
context.l10n.nebulaNameTaken,
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 ? 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,
),
),
),
],
);
}
/// 취소 / 변경 두 갈래.
Future<bool> showConfirmDialog(BuildContext context, String message) async {
final r = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.commonCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.commonChange),
),
],
),
);
return r ?? false;
}
/// 확인 하나뿐인 알림.
Future<void> showAlertDialog(BuildContext context, String message) => showDialog<void>(
context: context,
builder: (context) => AlertDialog(
content: Text(message),
actions: [
FilledButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.commonConfirm),
),
],
),
);