444 lines
15 KiB
Dart
444 lines
15 KiB
Dart
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),
|
||
),
|
||
],
|
||
),
|
||
);
|