Files
fanuzu/app/lib/features/my/support_screens.dart
T

453 lines
16 KiB
Dart

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';
import '../../core/ui/bottom_inset.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<InquiryScreen> createState() => _InquiryScreenState();
}
class _InquiryScreenState extends State<InquiryScreen> {
List<InquiryItem>? _items;
@override
void initState() {
super.initState();
_load();
}
Future<void> _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<void>(
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<void> _compose() async {
final sent = await Navigator.of(context, rootNavigator: true).push<bool>(
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: withBottomInset(context, 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<void> _edit(BuildContext context) async {
final saved = await Navigator.of(context, rootNavigator: true).push<bool>(
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<InquiryComposeScreen> createState() => _InquiryComposeScreenState();
}
class _InquiryComposeScreenState extends State<InquiryComposeScreen> {
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: withBottomInset(context, 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<void> _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<BlockedScreen> createState() => _BlockedScreenState();
}
class _BlockedScreenState extends State<BlockedScreen> {
List<BlockedUser>? _blocked;
@override
void initState() {
super.initState();
_load();
}
Future<void> _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<void> _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),
);
}