INSIDE · STATION · 참여형 번역 · 알림 · MY 화면
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user