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 createState() => _InsideWriteScreenState(); } class _InsideWriteScreenState extends State { final _form = GlobalKey(); 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 _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( 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), ), ), ], ), ); }