452 lines
15 KiB
Dart
452 lines
15 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';
|
|
|
|
/// 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: 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: 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),
|
|
);
|
|
}
|