import 'package:flutter/material.dart'; import '../../core/theme/tokens.dart'; import '../../core/ui/app_radio.dart'; import '../../core/i18n/app_locale.dart'; /// 신고·차단 — Figma 명세를 공용으로 뺐다. /// /// 성운 상세 · 행성 상세 · 글 상세 · 팬덤 라운지 네 곳에서 같은 흐름이 반복된다 /// (화면 대조 원장 F3 의 4 · 5 · 8 · 9 · 86번). 화면마다 따로 만들면 문구와 /// 동작이 갈라지므로 하나로 둔다. /// /// Figma 노드 /// - `373:442007` Galaxy/성운 상세/신고하기 · `373:442417` Galaxy/성운 상세/차단하기 /// - `252:162674` 글 상세/신고하기 · `252:163306` 글 상세/차단하기 /// /// 명세 /// - 신고 정보 영역 — *작성자 닉네임 · 게시물 제목* /// - 신고 사유 선택 — ***어드민에 등록된 신고 사유 항목*** · *디폴트 미선택 · 단수 선택* /// - 신고 안내 모달 — *화면 중앙 오버레이 · 취소/확인* /// - 차단 안내 모달 — *확인 시 차단 처리 후 HOT ARCHIVE 화면 이동* /// 신고 사유. 명세가 **어드민 등록 항목**이라 하므로 상수로 박지 않고 주입받는다. /// 정책 설계서 §P-11 — 신고 처리는 전건 `AdminActionLog` 에 남는다. /// 기본값은 Figma 사본 `373:442007` 의 네 항목이다. List get defaultReportReasons => [ appL10n.reportDefamation, appL10n.reportInappropriate, appL10n.reportSpam, appL10n.reportPrivacy, ]; /// 신고 사유 → `reports.reasonCode`. 규칙(`firestore.rules` reports)이 이 네 코드만 /// 받는다. 화면 문구가 바뀌어도 Admin 이 집계하는 코드는 그대로다. Map get reportReasonCodes => { appL10n.reportDefamation: 'defamation', appL10n.reportInappropriate: 'inappropriate', appL10n.reportSpam: 'spam', appL10n.reportPrivacy: 'privacy', }; /// 신고 대상. class ModerationTarget { const ModerationTarget({required this.authorName, required this.title}); final String authorName; /// 게시물 제목. 프로필을 신고할 때는 닉네임을 그대로 쓴다. final String title; } /// 신고하기 화면. class ReportScreen extends StatefulWidget { const ReportScreen({super.key, required this.target, this.reasons}); final ModerationTarget target; final List? reasons; @override State createState() => _ReportScreenState(); } class _ReportScreenState extends State { String? _reason; static TextStyle _t( double size, Color color, { FontWeight w = FontWeight.w600, }) => TextStyle( fontFamily: AppFonts.body, fontSize: size, fontWeight: w, height: 1, letterSpacing: size * -0.025, color: color, ); // 시안 `373:442007` — 닫기 X · 「신고하기」 · 오른쪽 「신고」(미선택 #474747), // 작성자(#ee0c2c)/제목 줄, 「신고 사유」 띠 #232325, 사유 줄 48(라디오 20 · 16/600 · 밑줄 #313131). @override Widget build(BuildContext context) => Scaffold( appBar: AppBar( leading: const CloseButton(), title: Text(context.l10n.reportTitle), actions: [ TextButton( onPressed: _reason == null ? null : _submit, style: TextButton.styleFrom( foregroundColor: AppColors.onCosmic, disabledForegroundColor: const Color(0xFF474747), textStyle: _t(14, AppColors.onCosmic), minimumSize: const Size(56, 56), ), child: Text(context.l10n.reportSubmit), ), ], ), body: ListView( padding: EdgeInsets.zero, children: [ // 신고 정보 영역 — 작성자 닉네임 · 게시물 제목 Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Column( children: [ _info( context.l10n.reportAuthor, widget.target.authorName, const Color(0xFFEE0C2C), ), const SizedBox(height: 16), const Divider(height: 1, thickness: 1, color: Color(0xFF313131)), const SizedBox(height: 16), _info( context.l10n.commonTitle, widget.target.title, const Color(0xFFD1D1D2), ), ], ), ), Container( color: const Color(0xFF232325), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Text( context.l10n.reportReasonLabel, style: _t(12, const Color(0xFF909091)), ), ), // 디폴트 미선택 · 단수 선택. for (final r in widget.reasons ?? defaultReportReasons) InkWell( onTap: () => setState(() => _reason = r), child: Container( height: 48, margin: const EdgeInsets.symmetric(horizontal: 16), decoration: const BoxDecoration( border: Border(bottom: BorderSide(color: Color(0xFF313131))), ), child: Row( children: [ AppRadio(selected: _reason == r), const SizedBox(width: 8), Text(r, style: _t(16, const Color(0xFFD1D1D2))), ], ), ), ), ], ), ); Widget _info(String label, String value, Color color) => Row( children: [ SizedBox( width: 36, child: Text( label, textAlign: TextAlign.center, style: _t(14, const Color(0xFF59595B)), ), ), const SizedBox(width: 20), Expanded( child: Text( value, maxLines: 1, overflow: TextOverflow.ellipsis, style: _t(14, color), ), ), ], ); Future _submit() async { final ok = await confirmOverlay(context, context.l10n.reportConfirm); if (!ok || !mounted) return; Navigator.pop(context, _reason); } } /// 차단 안내 모달. 확인하면 차단 처리 후 목록으로 돌아간다. /// /// 문구는 시안 그대로 — 글·회원은 「이 회원의 모든 게시물을 차단할까요?」(`252:163306`), /// 성운은 「이 성운을 차단할까요?」(`373:442417`). [nickname] 은 부르는 쪽 호환용이다. Future confirmBlock( BuildContext context, String nickname, { String? message, }) => confirmOverlay(context, message ?? context.l10n.blockMemberConfirm); /// 화면 중앙 오버레이 — 시안 `Frame 1984081031`(256 폭 · #2d2d2d r12 · 문구 14/400 · /// 취소/확인 42 · 16/400 #1895ff · 가르는 선 #636463). Future confirmOverlay(BuildContext context, String message) async { const line = BorderSide(color: Color(0xFF636463), width: 0.5); Widget button(BuildContext c, String label, bool value) => Expanded( child: InkWell( onTap: () => Navigator.pop(c, value), child: SizedBox( height: 42, child: Center( child: Text( label, style: const TextStyle( fontFamily: AppFonts.body, fontSize: 16, fontWeight: FontWeight.w400, color: Color(0xFF1895FF), ), ), ), ), ), ); final r = await showDialog( context: context, barrierColor: Colors.black.withValues(alpha: 0.6), builder: (c) => Dialog( backgroundColor: AppColors.cosmicDialog, insetPadding: const EdgeInsets.symmetric(horizontal: 59.5), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), clipBehavior: Clip.antiAlias, child: Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 12), child: Text( message, textAlign: TextAlign.center, style: const TextStyle( fontFamily: AppFonts.body, fontSize: 14, fontWeight: FontWeight.w400, color: AppColors.onCosmic, ), ), ), Container( decoration: const BoxDecoration(border: Border(top: line)), child: Row( children: [ button(c, c.l10n.commonCancel, false), Container( width: 0.5, height: 42, color: const Color(0xFF636463), ), button(c, c.l10n.commonConfirm, true), ], ), ), ], ), ), ); return r ?? false; }