HOME · GALAXY · 행성·성운 상세 · 팬덤 라운지 · 이클립스 화면
This commit is contained in:
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user