INSIDE · STATION · 참여형 번역 · 알림 · MY 화면

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent 18afc9cb98
commit 3aca678072
31 changed files with 10046 additions and 25 deletions
@@ -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),
),
),
],
),
);
}