Files
fanuzu/app/lib/features/inside/inside_write_screen.dart

360 lines
12 KiB
Dart
Raw Permalink 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/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),
),
),
],
),
);
}