360 lines
12 KiB
Dart
360 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
||
|
||
import '../../core/i18n/app_locale.dart';
|
||
import '../../core/theme/tokens.dart';
|
||
import '../../core/ui/app_icons.dart';
|
||
import '../home/home_screen.dart' show formatPop;
|
||
import 'inside_data.dart';
|
||
import 'inside_ui.dart';
|
||
|
||
/// 인사이드 작성 — Figma `199:44733`(빈 상태) · `200:47747` 등(입력 상태).
|
||
///
|
||
/// 현상금을 걸면 POP 이 예치된다. **예치는 서버가 한다** — 별첨 1 ② 「클라이언트 단독
|
||
/// 지급 0건」이라 화면은 금액만 받아 `fn-question-create` 로 보낸다. 등록이 끝나면
|
||
/// `true` 로 닫혀 목록이 다시 읽는다.
|
||
class InsideWriteScreen extends StatefulWidget {
|
||
const InsideWriteScreen({
|
||
super.key,
|
||
this.repository = const SampleInsideRepository(),
|
||
this.myPop,
|
||
});
|
||
|
||
final InsideRepository repository;
|
||
|
||
/// 보유 POP(일반 + 기여). 현상금 상한을 여기에 맞춘다. 비우면 [repository] 에서 읽는다.
|
||
final int? myPop;
|
||
|
||
@override
|
||
State<InsideWriteScreen> createState() => _InsideWriteScreenState();
|
||
}
|
||
|
||
class _InsideWriteScreenState extends State<InsideWriteScreen> {
|
||
final _form = GlobalKey<FormState>();
|
||
final _title = TextEditingController();
|
||
final _body = TextEditingController();
|
||
final _bounty = TextEditingController();
|
||
late int _myPop = widget.myPop ?? 0;
|
||
|
||
/// 현상금 하한. 서버 운영값을 읽기 전에는 기본값 100 이다.
|
||
int _minBounty = 100;
|
||
bool _saving = false;
|
||
|
||
/// 등록 재시도 번호. 이 화면이 열려 있는 동안 하나를 유지해 실패 뒤 재시도가 같은
|
||
/// 번호로 나간다 — 서버가 두 번 예치하지 않는다. 성공한 뒤에만 새로 뽑는다.
|
||
String _clientSeq = _newSeq();
|
||
|
||
static String _newSeq() => '${DateTime.now().microsecondsSinceEpoch}';
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
if (widget.myPop == null) {
|
||
widget.repository.balance().then((v) {
|
||
if (mounted) setState(() => _myPop = v);
|
||
}, onError: (_) {});
|
||
}
|
||
widget.repository.minBounty().then((v) {
|
||
if (mounted) setState(() => _minBounty = v);
|
||
}, onError: (_) {});
|
||
// 「완료」 색이 입력에 따라 바뀐다(시안: 비었을 때 #474747, 채우면 흰색).
|
||
for (final c in [_title, _body, _bounty]) {
|
||
c.addListener(() => setState(() {}));
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_title.dispose();
|
||
_body.dispose();
|
||
_bounty.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
bool get _filled =>
|
||
_title.text.trim().isNotEmpty &&
|
||
_body.text.trim().isNotEmpty &&
|
||
_bounty.text.trim().isNotEmpty;
|
||
|
||
Future<void> _submit() async {
|
||
if (_saving || !(_form.currentState?.validate() ?? false)) return;
|
||
setState(() => _saving = true);
|
||
try {
|
||
await widget.repository.create(
|
||
title: _title.text.trim(),
|
||
body: _body.text.trim(),
|
||
bountyPop: int.parse(_bounty.text.trim()),
|
||
clientSeq: _clientSeq,
|
||
);
|
||
_clientSeq = _newSeq();
|
||
} catch (error) {
|
||
if (!mounted) return;
|
||
setState(() => _saving = false);
|
||
_notice(insideErrorMessage(error));
|
||
return;
|
||
}
|
||
if (mounted) Navigator.of(context).pop(true);
|
||
}
|
||
|
||
void _notice(String message) => ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(SnackBar(content: Text(message)));
|
||
|
||
InputDecoration _plain(String hint, TextStyle style) => InputDecoration(
|
||
isCollapsed: true,
|
||
border: InputBorder.none,
|
||
enabledBorder: InputBorder.none,
|
||
focusedBorder: InputBorder.none,
|
||
errorBorder: InputBorder.none,
|
||
focusedErrorBorder: InputBorder.none,
|
||
hintText: hint,
|
||
hintStyle: style.copyWith(color: InsideColors.hint),
|
||
);
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final titleStyle = insideText(20, weight: FontWeight.w600, height: 1);
|
||
final bodyStyle = insideText(15, height: 1.3);
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
leading: const CloseButton(),
|
||
title: Text(context.l10n.insideWrite),
|
||
actions: [
|
||
GestureDetector(
|
||
onTap: _submit,
|
||
behavior: HitTestBehavior.opaque,
|
||
child: SizedBox(
|
||
width: 56,
|
||
child: Center(
|
||
child: Text(
|
||
context.l10n.commonDone,
|
||
style: insideText(
|
||
14,
|
||
weight: FontWeight.w600,
|
||
height: 1,
|
||
color: _filled
|
||
? AppColors.onCosmic
|
||
: const Color(0xFF474747),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
body: SafeArea(
|
||
top: false,
|
||
child: Form(
|
||
key: _form,
|
||
child: Column(
|
||
children: [
|
||
Expanded(
|
||
child: ListView(
|
||
padding: const EdgeInsets.all(AppSpace.s4),
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||
child: TextFormField(
|
||
controller: _title,
|
||
maxLength: 60,
|
||
style: titleStyle,
|
||
decoration: _plain(
|
||
context.l10n.commonTitleHint,
|
||
titleStyle,
|
||
).copyWith(counterText: ''),
|
||
validator: (v) => (v ?? '').trim().isEmpty
|
||
? context.l10n.insideTitleRequired
|
||
: null,
|
||
),
|
||
),
|
||
const SizedBox(height: AppSpace.s3),
|
||
const Divider(
|
||
height: 1,
|
||
thickness: 1,
|
||
color: Color(0xFF313131),
|
||
),
|
||
const SizedBox(height: AppSpace.s3),
|
||
SizedBox(
|
||
height: 180,
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||
child: TextFormField(
|
||
controller: _body,
|
||
maxLines: null,
|
||
expands: true,
|
||
maxLength: 2000,
|
||
textAlignVertical: TextAlignVertical.top,
|
||
style: bodyStyle,
|
||
decoration: _plain(
|
||
context.l10n.insideBodyHint,
|
||
bodyStyle,
|
||
).copyWith(counterText: ''),
|
||
validator: (v) => (v ?? '').trim().isEmpty
|
||
? context.l10n.insideBodyRequired
|
||
: null,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: AppSpace.s5),
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||
child: Text(
|
||
context.l10n.insideBountySet,
|
||
style: insideText(
|
||
16,
|
||
weight: FontWeight.w600,
|
||
height: 1,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: AppSpace.s3),
|
||
_BountyField(
|
||
controller: _bounty,
|
||
style: bodyStyle,
|
||
decoration: _plain(
|
||
context.l10n.insideBountyHint('$_minBounty'),
|
||
bodyStyle,
|
||
),
|
||
myPop: _myPop,
|
||
minBounty: _minBounty,
|
||
),
|
||
const SizedBox(height: AppSpace.s2),
|
||
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.cosmicBlue,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
style: insideText(
|
||
14,
|
||
color: AppColors.onCosmicVariant,
|
||
height: 1,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
// 사진 · 앨범 — 시안 28 칸 아이콘 둘. 첨부 업로드 경로가 아직 없다.
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: AppSpace.s4,
|
||
vertical: AppSpace.s2,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
// 두 칸 모두 28. image.png 는 여백이 커서(84 중 46) 28 로 그려야 시안 글리프 15 가 된다.
|
||
for (final (icon, size) in [
|
||
(AppIcons.camera, 20.0),
|
||
(AppIcons.image, 28.0),
|
||
])
|
||
Padding(
|
||
padding: const EdgeInsets.only(right: 20),
|
||
child: GestureDetector(
|
||
onTap: () => _notice(context.l10n.insideAttachLater),
|
||
child: SizedBox.square(
|
||
dimension: 28,
|
||
child: Center(
|
||
child: ImageIcon(
|
||
icon,
|
||
size: size,
|
||
color: AppColors.onCosmic,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 현상금 칸 — 시안 343×48 · #545454 획 · 모서리 8 · 오른쪽 「POP」.
|
||
class _BountyField extends StatelessWidget {
|
||
const _BountyField({
|
||
required this.controller,
|
||
required this.style,
|
||
required this.decoration,
|
||
required this.myPop,
|
||
required this.minBounty,
|
||
});
|
||
|
||
final TextEditingController controller;
|
||
final TextStyle style;
|
||
final InputDecoration decoration;
|
||
final int myPop;
|
||
final int minBounty;
|
||
|
||
@override
|
||
Widget build(BuildContext context) => FormField<String>(
|
||
validator: (_) {
|
||
final raw = controller.text.trim();
|
||
// 명세 2.60-1 — 완료는 제목 · 글 · 현상금이 모두 채워져야 한다.
|
||
if (raw.isEmpty) return context.l10n.insideBountyRequired;
|
||
final amount = int.tryParse(raw);
|
||
if (amount == null || amount < 0) return context.l10n.insideNumberOnly;
|
||
// 기능 명세 2.60-3 「100POP 이상」. 운영값이라 서버(fn-question-create)와 같은
|
||
// `config/policy.qnaMinBountyPop` 을 읽는다.
|
||
if (amount < minBounty) return context.l10n.insideBountyMin('$minBounty');
|
||
if (amount > myPop) return context.l10n.insideBountyOverBalance;
|
||
return null;
|
||
},
|
||
builder: (field) => Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Container(
|
||
height: 48,
|
||
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
|
||
foregroundDecoration: BoxDecoration(
|
||
border: Border.all(color: const Color(0xFF545454)),
|
||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: TextField(
|
||
controller: controller,
|
||
keyboardType: TextInputType.number,
|
||
style: style,
|
||
decoration: decoration,
|
||
),
|
||
),
|
||
const SizedBox(width: AppSpace.s4),
|
||
const Text(
|
||
'POP',
|
||
style: TextStyle(
|
||
fontFamily: AppFonts.display,
|
||
fontSize: 16,
|
||
height: 1.5,
|
||
color: AppColors.cosmicBlue,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (field.errorText != null)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: AppSpace.s1, left: 2),
|
||
child: Text(
|
||
field.errorText!,
|
||
style: insideText(12, color: AppColors.error),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|