import 'package:flutter/material.dart'; import '../../core/theme/tokens.dart'; import '../../core/ui/relative_time.dart'; import 'my_data.dart'; import 'widgets/my_cards.dart'; import '../../core/i18n/app_locale.dart'; /// 1:1 문의 목록 — 화면 대조 원장 F6 · Figma `373:634916` 「1:1 문의 접수」. /// /// Figma 가 목록 · 상세 · 작성 세 프레임으로 나눠 두었다. /// - 목록 `373:634916` — 제목 · 상태(`접수`·`처리중`·`답변 완료`) · 날짜 · 「문의 작성」 /// - 상세 `373:635153` — 제목 · 날짜 · 본문 · 답변(`376:14163`~`376:14165`) /// - 작성 `373:635052` — *제목을 입력해주세요.* · *문의할 내용을 작성해 주세요.* /// /// 종전 구현은 분류 칩과 본문 한 칸이었는데, 그 분류는 Figma 에 없는 항목이었다. /// 공통 기준 2항이 화면 구조·UI 의 기준을 Figma 로 두었으므로 제목·본문으로 맞췄다. class InquiryScreen extends StatefulWidget { const InquiryScreen({super.key, required this.repository}); final MyRepository repository; @override State createState() => _InquiryScreenState(); } class _InquiryScreenState extends State { List? _items; @override void initState() { super.initState(); _load(); } Future _load() async { final items = await widget.repository.myInquiries(); if (mounted) setState(() => _items = items); } @override Widget build(BuildContext context) { final items = _items; if (items == null) { return const Scaffold(body: Center(child: CircularProgressIndicator())); } // 시안 `373:634845` — 카드 #19191c · 모서리 16 · 안쪽 20/16 · 간격 12 · 제목 15/600 · // 상태 칩 #222222 모서리 4 · 날짜 12/600 #777 · 아래 「문의 작성」 주 버튼. return Scaffold( appBar: AppBar(title: Text(context.l10n.inquiryListTitle)), body: items.isEmpty ? Center( child: Text(context.l10n.inquiryEmpty, style: myText(14, FontWeight.w500)), ) : ListView.separated( padding: const EdgeInsets.all(16), itemCount: items.length, separatorBuilder: (_, _) => const SizedBox(height: 12), itemBuilder: (context, i) { final item = items[i]; return GestureDetector( onTap: () => Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (_) => InquiryDetailScreen( inquiry: item, repository: widget.repository, onChanged: _load, ), ), ), child: Container( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 20, ), decoration: BoxDecoration( color: MyColors.card, borderRadius: BorderRadius.circular(AppRadius.lg), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( height: 24, child: Row( children: [ Expanded( child: Text( item.title, overflow: TextOverflow.ellipsis, style: const TextStyle( fontFamily: AppFonts.body, fontSize: 15, fontWeight: FontWeight.w600, height: 1, color: AppColors.onCosmic, ), ), ), const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 6, ), decoration: BoxDecoration( color: const Color(0xFF222222), borderRadius: BorderRadius.circular( AppRadius.xs, ), ), child: Text( item.status.label, style: const TextStyle( fontFamily: AppFonts.body, fontSize: 12, fontWeight: FontWeight.w500, height: 1, color: AppColors.onCosmic, ), ), ), ], ), ), const SizedBox(height: 12), Text( '${item.at.year}.${item.at.month.toString().padLeft(2, '0')}.${item.at.day.toString().padLeft(2, '0')}', style: const TextStyle( fontFamily: AppFonts.body, fontSize: 12, fontWeight: FontWeight.w600, height: 1, color: MyColors.muted, ), ), ], ), ), ); }, ), // 시안 — 버튼 아래 16 을 홈 인디케이터 34 위에 둔다(minimum 은 인셋과 겹쳐 16 이 사라진다). bottomNavigationBar: SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), child: FilledButton( onPressed: _compose, style: FilledButton.styleFrom( backgroundColor: AppColors.cosmicAccent, foregroundColor: Colors.black, ), child: Text(context.l10n.inquiryWrite), ), ), ), ); } Future _compose() async { final sent = await Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (_) => InquiryComposeScreen(repository: widget.repository), ), ); if (sent == true) await _load(); } } /// 문의 상세 — Figma `373:635153`. /// /// 「수정」은 접수 상태에서만 뜬다. 규칙이 `pending` 인 본인 문의의 제목·본문만 /// 허용하므로(`firestore.rules` 의 `inquiries`), 답변이 올라간 뒤에 입구를 두면 /// 누를 수는 있고 저장은 실패하는 화면이 된다. class InquiryDetailScreen extends StatelessWidget { const InquiryDetailScreen({ super.key, required this.inquiry, required this.repository, this.onChanged, }); final InquiryItem inquiry; final MyRepository repository; /// 수정이 끝나면 목록을 다시 읽게 알린다. final VoidCallback? onChanged; @override Widget build(BuildContext context) { final t = Theme.of(context).textTheme; return Scaffold( appBar: AppBar( title: Text(context.l10n.inquiryDetail), actions: [ if (inquiry.status == InquiryStatus.pending) TextButton( onPressed: () => _edit(context), child: Text(context.l10n.commonEdit), ), ], ), body: ListView( padding: const EdgeInsets.all(AppSpace.s4), children: [ Text(inquiry.title, style: t.titleMedium), const SizedBox(height: AppSpace.s2), Text( '${inquiry.status.label} · ${relativeTime(inquiry.at)}', style: t.labelSmall, ), const SizedBox(height: AppSpace.s4), Text(inquiry.body, style: t.bodyMedium), if (inquiry.answerBody case final answer?) ...[ const Divider(height: AppSpace.s6), Text(context.l10n.inquiryAnswer, style: t.titleSmall), if (inquiry.answeredAt case final at?) Text(relativeTime(at), style: t.labelSmall), const SizedBox(height: AppSpace.s2), Text(answer, style: t.bodyMedium), ], ], ), ); } Future _edit(BuildContext context) async { final saved = await Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (_) => InquiryComposeScreen(repository: repository, edit: inquiry), ), ); if (saved != true) return; onChanged?.call(); if (context.mounted) Navigator.of(context).pop(); } } /// 새 문의 작성 — Figma `373:635052` · 문의 수정 `554:24330`. /// /// 두 프레임의 구조가 제목·본문·완료로 같아서 화면을 하나만 둔다. [edit] 이 /// 있으면 수정이고, 없으면 새 문의다. 두 벌로 적으면 열 글자 규칙 같은 것이 /// 한쪽에서만 고쳐진다. class InquiryComposeScreen extends StatefulWidget { const InquiryComposeScreen({super.key, required this.repository, this.edit}); final MyRepository repository; /// 고칠 문의. 접수 상태의 본인 문의만 넘어온다. final InquiryItem? edit; @override State createState() => _InquiryComposeScreenState(); } class _InquiryComposeScreenState extends State { late final _title = TextEditingController(text: widget.edit?.title ?? ''); late final _body = TextEditingController(text: widget.edit?.body ?? ''); bool _sending = false; @override void initState() { super.initState(); _title.addListener(() => setState(() {})); _body.addListener(() => setState(() {})); } @override void dispose() { _title.dispose(); _body.dispose(); super.dispose(); } bool get _ready => _title.text.trim().isNotEmpty && _body.text.trim().length >= 10; @override Widget build(BuildContext context) => Scaffold( appBar: AppBar( title: Text( widget.edit == null ? context.l10n.inquiryNew : context.l10n.inquiryEdit, ), actions: [ TextButton( onPressed: _ready && !_sending ? _send : null, child: Text(context.l10n.commonDone), ), ], ), body: ListView( padding: const EdgeInsets.all(AppSpace.s4), children: [ TextField( controller: _title, decoration: InputDecoration( labelText: context.l10n.commonTitle, hintText: context.l10n.commonTitleHint, ), ), const SizedBox(height: AppSpace.s4), TextField( controller: _body, maxLines: 8, decoration: InputDecoration( labelText: context.l10n.inquiryBody, hintText: context.l10n.inquiryBodyHint, // 너무 짧으면 되물어야 해서 처리가 오래 걸린다. helperText: context.l10n.inquiryBodyHelper, ), ), ], ), ); Future _send() async { final edit = widget.edit; setState(() => _sending = true); try { if (edit == null) { await widget.repository.submitInquiry( _title.text.trim(), _body.text.trim(), ); } else { await widget.repository.updateInquiry( edit.id, _title.text.trim(), _body.text.trim(), ); } } on Exception catch (error) { if (!mounted) return; setState(() => _sending = false); _toast( context, edit == null ? context.l10n.inquirySubmitFailed(_reason(error)) : context.l10n.inquiryEditFailed(_reason(error)), ); return; } if (!mounted) return; _toast( context, edit == null ? context.l10n.inquirySubmitted : context.l10n.inquiryEdited, ); Navigator.of(context).pop(true); } } /// 차단 설정 — 명세 `554:24090` · Figma `357:223694` *차단한 프로필이 표시됩니다.* /// /// 화면 대조 원장 F6 의 21건에는 별도 행이 없으나, 설정의 「차단 설정」이 /// 가리키는 화면이라 함께 만든다. 링크만 두고 화면이 없으면 막다른 길이 된다. /// /// 목록과 해제만 있는 것이 Figma 그대로다. 차단을 **추가**하는 입구는 게시물· /// 댓글·프로필의 「신고/차단 바텀시트」(`527:18638`)이며 저장소의 /// [MyRepository.block] 이 그 자리에서 불린다. class BlockedScreen extends StatefulWidget { const BlockedScreen({super.key, required this.repository}); final MyRepository repository; @override State createState() => _BlockedScreenState(); } class _BlockedScreenState extends State { List? _blocked; @override void initState() { super.initState(); _load(); } Future _load() async { final blocked = await widget.repository.blockedUsers(); if (mounted) setState(() => _blocked = blocked); } @override Widget build(BuildContext context) { final blocked = _blocked; if (blocked == null) { return const Scaffold(body: Center(child: CircularProgressIndicator())); } final t = Theme.of(context).textTheme; return Scaffold( appBar: AppBar(title: Text(context.l10n.settingsBlocked)), body: blocked.isEmpty ? Center(child: Text(context.l10n.blockedEmpty, style: t.bodyMedium)) : ListView.builder( padding: const EdgeInsets.all(AppSpace.s4), itemCount: blocked.length, itemBuilder: (context, i) { final user = blocked[i]; return ListTile( contentPadding: EdgeInsets.zero, leading: CircleAvatar( backgroundColor: AppColors.cosmicSurfaceRaised, child: Text(user.nickname.characters.first), ), title: Text(user.nickname), trailing: TextButton( onPressed: () => _unblock(user), child: Text(context.l10n.unblock), ), ); }, ), ); } Future _unblock(BlockedUser user) async { try { await widget.repository.unblock(user.uid); } on Exception catch (error) { if (!mounted) return; _toast(context, context.l10n.unblockFailed(_reason(error))); return; } await _load(); } } /// 실패 사유 한 줄. 저장소가 던지는 예외를 화면 문구로 바꾼다. /// /// 내부 예외 문자열을 그대로 띄우지 않는 것은 경로·스택이 새기 때문이다 /// (README 「목 모드에서의 로그인」과 같은 이유). String _reason(Object error) => switch (error) { PhotoRejected(:final message) => message, _ => appL10n.commonTryLater, }; void _toast(BuildContext context, String message) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), ); }