INSIDE · STATION · 참여형 번역 · 알림 · MY 화면

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent 18afc9cb98
commit 3aca678072
31 changed files with 10046 additions and 25 deletions
+406
View File
@@ -0,0 +1,406 @@
/// INSIDE 탭이 그리는 값의 형태.
///
/// 근거는 Figma `03_Wireframe` 의 INSIDE 화면 10종이다. 목록 화면이 정렬 기준마다
/// 따로 그려져 있어(`Inside_최신순` · `Inside_인기순` · `Inside_POP순`) 정렬을
/// 화면이 아니라 질의 조건으로 둔다 — 같은 리스트에 정렬만 다르다.
///
/// 데이터 원장은 `questions` 컬렉션이며 답변은 `answers` 하위 컬렉션이다
/// (Firestore 설계서 §5). 현상금은 POP 원장을 지나므로 화면은 금액만 읽는다.
library;
import '../../core/data/backend.dart';
import '../../core/i18n/app_locale.dart';
/// 목록 정렬 기준. Figma 가 화면을 셋으로 나눠 둔 그 축이다.
enum InsideSort {
/// `Inside_최신순` — createdAt 내림차순.
latest,
/// `Inside_인기순` — 답변 수 내림차순.
popular,
/// `Inside_POP순` — 걸린 현상금 내림차순.
bounty,
}
extension InsideSortLabel on InsideSort {
String get label => switch (this) {
InsideSort.latest => appL10n.sortLatest,
InsideSort.popular => appL10n.sortPopular,
InsideSort.bounty => appL10n.sortPop,
};
/// Firestore 정렬 필드. 화면이 필드 이름을 알 필요는 없지만, 저장소 구현이
/// 세 곳으로 갈리는 것보다 여기 한 줄로 두는 편이 어긋날 여지가 적다.
String get field => switch (this) {
InsideSort.latest => 'createdAt',
InsideSort.popular => 'answerCount',
InsideSort.bounty => 'bountyPop',
};
}
/// 질문 하나. 목록 카드와 상세가 같은 형을 쓴다 — 상세가 더 읽을 뿐이다.
class InsideQuestion {
const InsideQuestion({
required this.id,
required this.title,
required this.body,
required this.authorName,
required this.createdAt,
this.bountyPop = 0,
this.answerCount = 0,
this.viewCount = 0,
this.likeCount = 0,
this.imageUrls = const [],
this.isMine = false,
this.isSolved = false,
this.refund = InsideRefund.none,
});
final String id;
final String title;
final String body;
final String authorName;
final DateTime createdAt;
/// 걸린 현상금. 0 이면 카드에 칩을 그리지 않는다.
final int bountyPop;
final int answerCount;
final int viewCount;
final int likeCount;
/// 첨부 이미지. 목록 카드는 첫 장을 70×70 썸네일로, 상세는 전부 그린다.
/// `assets/` 로 시작하면 번들 자산(표본), 아니면 네트워크 주소다.
final List<String> imageUrls;
/// 본인 글. Figma 가 상세를 「본인」·「타인」으로 나눠 둔 그 구분이며,
/// 수정·삭제와 신고·차단이 갈린다.
final bool isMine;
/// 답변이 채택되어 현상금이 지급된 상태.
final bool isSolved;
/// 채택 기한(원장 B-39 · 7일)이 지난 뒤의 환불 단계.
final InsideRefund refund;
/// 채택을 받을 수 있는 상태 — 채택도 만료도 아니다.
bool get isOpen => !isSolved && refund == InsideRefund.none;
InsideQuestion solvedCopy() => InsideQuestion(
id: id,
title: title,
body: body,
authorName: authorName,
createdAt: createdAt,
bountyPop: bountyPop,
answerCount: answerCount,
viewCount: viewCount,
likeCount: likeCount,
imageUrls: imageUrls,
isMine: isMine,
isSolved: true,
);
}
/// 만료 환불 단계 — 서버 `status` 의 `refunding` · `expired` · `refund_failed`.
enum InsideRefund {
none,
pending,
done,
/// 자동 환불이 멈춰 운영 확인 중. 사용자에게는 원인을 드러내지 않는다.
checking;
String get notice => switch (this) {
none => '',
pending => appL10n.insideRefundPending,
done => appL10n.insideRefundDone,
checking => appL10n.insideRefundChecking,
};
static InsideRefund fromStatus(String status) => switch (status) {
'refunding' => pending,
'expired' => done,
'refund_failed' => checking,
_ => none,
};
}
/// 답변(댓글) 하나. 시안 상세의 댓글 · 대댓글이다(`answers` 하위 컬렉션).
class InsideAnswer {
const InsideAnswer({
required this.authorName,
required this.body,
required this.createdAt,
this.id = '',
this.isMine = false,
this.isAccepted = false,
this.replies = const [],
});
/// `answers` 문서 ID. 채택 호출이 이 값을 보낸다.
final String id;
final String authorName;
final String body;
final DateTime createdAt;
/// 본인 댓글. 시안은 분홍 바탕(#ff7ddd 10%)이다.
final bool isMine;
/// 채택된 답변(`accepted`). 서버(`fn-answer-accept`)만 켠다.
final bool isAccepted;
/// 대댓글. 한 단계만 있다 — 대댓글에는 답글 버튼이 없다.
final List<InsideAnswer> replies;
}
class InsideSnapshot {
const InsideSnapshot({
required this.questions,
this.sort = InsideSort.latest,
this.canWrite = true,
});
final List<InsideQuestion> questions;
final InsideSort sort;
/// 작성 플로팅 버튼 노출 조건. 명세 「해당 팬덤에 가입된 유저에게만 노출」.
final bool canWrite;
}
abstract interface class InsideRepository {
/// 첫 장. 명세가 「최초 5개, 스크롤 시 5개씩」이라 기본값을 5로 둔다.
Future<InsideSnapshot> load({InsideSort sort = InsideSort.latest});
/// 다음 장. 더 없으면 빈 목록을 준다.
Future<List<InsideQuestion>> more({required InsideSort sort, int limit = 5});
/// 현상금을 걸 수 있는 보유 POP — 일반 + 기여. 예치는 겸용이다(B-39 · B-06, 일반 우선).
Future<int> balance();
/// 현상금 하한 — 서버와 같은 `config/policy.qnaMinBountyPop`(기본 100 · 운영값).
Future<int> minBounty();
/// 질문 등록 + 현상금 예치(`fn-question-create`). 새 질문 ID 를 준다. 실패는 던진다.
Future<String> create({
required String title,
required String body,
required int bountyPop,
required String clientSeq,
});
/// 답변 목록 — 명세 2.61-3 「최신순 소팅」.
Future<List<InsideAnswer>> answers(String questionId);
/// 답변 등록. 재화가 오가지 않아 규칙이 클라이언트 쓰기를 허용한다.
Future<void> answer(String questionId, String body);
/// 채택(`fn-answer-accept`). 현상금이 답변자에게 간다. 번복되지 않는다.
Future<void> accept(String questionId, String answerId);
}
/// 현상금 Q&A 실패를 화면 문구로 — 서버 카탈로그 코드(`E-QNA-*` 등)로 가른다.
String insideErrorMessage(Object error) => switch (error) {
BackendException(code: 'E-POP-01') => appL10n.insideErrPopShort,
BackendException(code: 'E-QNA-01') => appL10n.insideErrSelfAccept,
BackendException(code: 'E-QNA-03') => appL10n.insideErrAlreadyAccepted,
BackendException(code: 'E-PERM-01') => appL10n.insideErrNotOwner,
BackendException(code: 'E-AUTH-03') => appL10n.authLoginRequired,
BackendException(:final message) => message,
_ => appL10n.commonCheckConnection,
};
/// 서버가 붙기 전에 쓰는 표본. 문구는 Figma INSIDE 시안(199:43083 등)의 표본 그대로이며 정책 수치가 아니다.
///
/// 작성 시각은 지금을 기준으로 잡는다 — 시안의 「10분 전」·「1시간 전」이 그대로 나오게.
class SampleInsideRepository implements InsideRepository {
const SampleInsideRepository();
static List<InsideQuestion> get _all {
final now = DateTime.now();
return [
InsideQuestion(
id: 'q-0',
title: '화양연화 세계관 해석해주실 분?',
body: '이번 뮤비랑 연결되는 부분이 헷갈리네요. 타임라인 정리해주실 천재 아미 구합니다!',
authorName: '아미보이',
createdAt: now.subtract(const Duration(minutes: 10)),
bountyPop: 500,
viewCount: 3,
),
InsideQuestion(
id: 'q-1',
title: '공방 신청 꿀팁 공유합니다.',
body: '공방 신청할 때 폼 가장 빨리 제출하는 손크로 팁 알려드립! 궁금하신 분 질문 남겨주세요',
authorName: '다이브짱',
createdAt: now.subtract(const Duration(hours: 1)),
bountyPop: 200,
likeCount: 8,
answerCount: 24,
viewCount: 82,
imageUrls: const ['assets/figma/inside-sample-3.png'],
isSolved: true,
),
InsideQuestion(
id: 'q-2',
title: 'BTS 이 포즈 뭔가요?',
body: '아미 분들 이 포즈 뭔지 알려주실 수 있나요?',
authorName: '네누',
createdAt: now.subtract(const Duration(hours: 3)),
bountyPop: 100,
likeCount: 48,
answerCount: 24,
viewCount: 82,
imageUrls: const ['assets/figma/inside-sample-2.png'],
),
InsideQuestion(
id: 'q-3',
title: 'BTS 광화문 봉사 활동',
body: '광화문 봉사 활동 어떻게 신청하셨나요?\n다음 봉사 활동이 있으면 참가하고 싶어요!',
authorName: '아우라',
createdAt: now.subtract(const Duration(days: 30)),
bountyPop: 800,
likeCount: 3,
answerCount: 2,
viewCount: 32,
isSolved: true,
),
InsideQuestion(
id: 'q-4',
title: 'BTS 컴백 알려주세요!',
body: 'BTS 한국 컴백 시기가 궁금합니다!\n아시는 분들 댓글 부탁드려요ㅠㅠ',
authorName: 'BTS_Hong',
createdAt: now.subtract(const Duration(days: 40)),
bountyPop: 100,
imageUrls: const [
'assets/figma/inside-sample-4.png',
'assets/figma/inside-sample-5.png',
],
isMine: true,
),
];
}
List<InsideQuestion> _sorted(InsideSort sort) {
final list = _all;
switch (sort) {
case InsideSort.latest:
list.sort((a, b) => b.createdAt.compareTo(a.createdAt));
case InsideSort.popular:
// 기능 명세 2008 — 인기순은 좋아요 > 댓글 > 조회수.
list.sort(
(a, b) => b.likeCount != a.likeCount
? b.likeCount.compareTo(a.likeCount)
: b.answerCount != a.answerCount
? b.answerCount.compareTo(a.answerCount)
: b.viewCount.compareTo(a.viewCount),
);
case InsideSort.bounty:
list.sort((a, b) => b.bountyPop.compareTo(a.bountyPop));
}
return list;
}
@override
Future<InsideSnapshot> load({InsideSort sort = InsideSort.latest}) async =>
InsideSnapshot(questions: _sorted(sort).take(5).toList(), sort: sort);
/// 표본 모드에서 단 답변. 서버가 없으니 앱이 떠 있는 동안만 남는다.
static final Map<String, List<InsideAnswer>> _answers = {};
@override
Future<int> balance() async => 12_400;
@override
Future<int> minBounty() async => 100;
/// 표본은 원장이 없다 — 화면 동선만 태운다(캠페인 표본의 `create` 와 같다).
@override
Future<String> create({
required String title,
required String body,
required int bountyPop,
required String clientSeq,
}) async => 'q-new';
@override
Future<List<InsideAnswer>> answers(String questionId) async =>
List.of(_answers[questionId] ?? const []);
@override
Future<void> answer(String questionId, String body) async {
(_answers[questionId] ??= []).insert(
0,
InsideAnswer(
id: 'a-${DateTime.now().microsecondsSinceEpoch}',
authorName: '나',
body: body,
createdAt: DateTime.now(),
isMine: true,
),
);
}
@override
Future<void> accept(String questionId, String answerId) async {
final list = _answers[questionId] ?? const <InsideAnswer>[];
for (final (i, a) in list.indexed) {
if (a.id != answerId) continue;
if (a.isMine) {
throw BackendException('E-QNA-01', appL10n.insideErrSelfAccept);
}
_answers[questionId]![i] = InsideAnswer(
id: a.id,
authorName: a.authorName,
body: a.body,
createdAt: a.createdAt,
isAccepted: true,
);
}
}
/// 표본은 5건뿐이라 둘째 장이 비어 나온다. 화면의 「더 없음」 경로를 그대로 태운다.
@override
Future<List<InsideQuestion>> more({
required InsideSort sort,
int limit = 5,
}) async => _sorted(sort).skip(5).take(limit).toList();
}
/// 시안 「인사이드 상세(타인)」(201:49678)의 댓글 표본. 답변 목록 읽기 경로가 아직 없어
/// 화면 대조와 시연에서만 쓴다.
final List<InsideAnswer> sampleInsideAnswers = [
InsideAnswer(
authorName: 'pro_따봉',
body: '세상이 이런 꿀팁이 다 있나요,,,\n덕분에 좋은 정보 알고 갑니다 ㅎㅎㅎ',
createdAt: DateTime(2026, 4, 27, 9),
replies: [
InsideAnswer(
authorName: '뚜루뚭',
body: '이 내용 말고도 다른 내용 더 있어요!',
createdAt: DateTime(2026, 4, 27, 9),
),
InsideAnswer(
authorName: '호떡이',
body:
'위의 내용 번역해드릴게요!\nIs there really no such great tip in the world...\n'
'Thanks for the good information hahaha',
createdAt: DateTime(2026, 4, 27, 9),
isMine: true,
),
],
),
InsideAnswer(
authorName: '참참참',
body: '내가 찾던 내용인데 이제 찾았네 ㅠㅠ',
createdAt: DateTime(2026, 4, 27, 9),
replies: [
InsideAnswer(
authorName: '뚜루뚭',
body: '이 내용 말고도 다른 내용 더 있어요!',
createdAt: DateTime(2026, 4, 27, 9),
),
],
),
];
@@ -0,0 +1,527 @@
import 'package:flutter/material.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart';
import '../planet/moderation.dart';
import '../translation/translation_data.dart';
import '../translation/translation_comments.dart';
import 'inside_data.dart';
import 'inside_ui.dart';
/// 인사이드 상세 — 화면 대조 원장 F9.
///
/// Figma 노드
/// - `207:53130` Inside_인사이드 상세(본인)
/// - `201:49678` Inside/인사이드 상세(타인)
/// - `201:51871` 신고하기 · `207:52152` 차단하기
///
/// 본인 글과 타인 글이 Figma 에서 별도 화면인 이유는 더보기 동작이 갈리기 때문이다 —
/// 본인은 수정·삭제, 타인은 신고·차단. 화면 골격은 같으므로 하나로 두고 동작만 나눈다.
///
/// 댓글(답변)은 [repository] 가 있으면 `answers` 하위 컬렉션에서 읽고, 없으면 [answers]
/// 를 그대로 그린다(화면 대조 · 시연). 질문자는 남의 답변 하나를 채택할 수 있고
/// (명세 2.64 · `fn-answer-accept`), 채택은 되돌릴 수 없다.
class InsideDetailScreen extends StatefulWidget {
const InsideDetailScreen({
super.key,
required this.question,
this.answers = const [],
this.repository,
this.translation,
});
final InsideQuestion question;
final List<InsideAnswer> answers;
final InsideRepository? repository;
/// 참여형 번역(FR-I18N-02 · B-63). 있으면 댓글 입력의 지구본으로 번역 댓글을 쓰고,
/// 번역 댓글을 댓글 목록에 지구본 표시와 함께 그린다.
final TranslationRepository? translation;
@override
State<InsideDetailScreen> createState() => _InsideDetailScreenState();
}
class _InsideDetailScreenState extends State<InsideDetailScreen> {
late InsideQuestion question = widget.question;
late List<InsideAnswer> answers = widget.answers;
bool _busy = false;
/// 번역할 댓글(대댓글 버튼으로 고른다). 없으면 번역 댓글은 질문 본문의 번역이다.
TranslationReplyTarget? _replyTo;
TranslationRepository? get _translation =>
translationSurfaces.contains(TranslationSurface.inside)
? widget.translation
: null;
String get _root => 'questions/${question.id}';
@override
void initState() {
super.initState();
_reload();
}
Future<void> _reload() async {
final repo = widget.repository;
if (repo == null) return;
try {
final next = await repo.answers(question.id);
if (mounted) setState(() => answers = next);
} catch (error) {
if (mounted) _notice(insideErrorMessage(error));
}
}
void _notice(String message) => ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(message)));
/// 답변 등록. 저장이 끝난 뒤에만 목록을 다시 읽는다 — 실패를 성공처럼 그리지 않게.
Future<bool> _send(String body) async {
final repo = widget.repository;
if (repo == null) {
_notice(context.l10n.insideAnswerLater);
return false;
}
try {
await repo.answer(question.id, body);
} catch (error) {
if (mounted) _notice(insideErrorMessage(error));
return false;
}
await _reload();
return true;
}
/// 채택 가능한 답변 — 본인 질문 · 아직 열림 · 남의 답변.
bool _canAccept(InsideAnswer a) =>
widget.repository != null &&
question.isMine &&
question.isOpen &&
!a.isMine &&
a.id.isNotEmpty;
Future<void> _accept(InsideAnswer a) async {
final ok = await showDialog<bool>(
context: context,
builder: (dialog) => AlertDialog(
content: Text(
context.l10n.insideAcceptConfirm('${question.bountyPop}'),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialog).pop(false),
child: Text(context.l10n.commonCancel),
),
TextButton(
onPressed: () => Navigator.of(dialog).pop(true),
child: Text(context.l10n.insideAccept),
),
],
),
);
if (ok != true || _busy || !mounted) return;
setState(() => _busy = true);
try {
await widget.repository!.accept(question.id, a.id);
if (mounted) setState(() => question = question.solvedCopy());
} catch (error) {
if (mounted) _notice(insideErrorMessage(error));
} finally {
if (mounted) setState(() => _busy = false);
}
await _reload();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
actions: [
IconButton(
tooltip: context.l10n.insideMore,
icon: const ImageIcon(AppIcons.ellipsisVertical, size: 24),
onPressed: () => _showMore(context),
),
const SizedBox(width: AppSpace.s1),
],
),
body: SafeArea(
top: false,
child: Column(
children: [
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: [
_Post(question: question),
// 시안은 본문과 댓글 사이를 검정 8 로 끊는다.
const ColoredBox(
color: Colors.black,
child: SizedBox(
height: AppSpace.s2,
width: double.infinity,
),
),
Padding(
padding: const EdgeInsets.all(AppSpace.s4),
child: switch (_translation) {
final t? => TranslationThreads(
repository: t,
rootPath: _root,
builder: (_, byTarget) => _comments(t, byTarget),
),
null => _comments(null, const {}),
},
),
],
),
),
CommentInputBar(
hint: context.l10n.insideAnswerHint,
onComment: _send,
translation: _translation,
rootPath: _root,
replyTo: _replyTo,
onClearReply: () => setState(() => _replyTo = null),
),
],
),
),
);
}
/// 댓글 목록 — 댓글마다 그 댓글의 번역 댓글을 대댓글 자리에, 질문 본문의 번역 댓글은
/// 댓글 뒤에 붙인다(시안 `201:50554`).
Widget _comments(
TranslationRepository? t,
Map<String, List<TranslationEntry>> byTarget,
) {
final ownTranslations = byTarget[_root] ?? const [];
if (answers.isEmpty && ownTranslations.isEmpty) return const _NoAnswer();
const divider = Divider(height: 33, thickness: 1, color: InsideColors.rule);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (i, a) in answers.indexed) ...[
if (i > 0) divider,
_AnswerThread(
answer: a,
onAccept: _canAccept(a) ? () => _accept(a) : null,
onTranslate: t != null && a.id.isNotEmpty
? () => setState(
() => _replyTo = (
path: '$_root/answers/${a.id}',
name: a.authorName,
),
)
: null,
),
for (final e in byTarget['$_root/answers/${a.id}'] ?? const []) ...[
const SizedBox(height: AppSpace.s5),
TranslationCommentCard(entry: e, repository: t!, reply: true),
],
],
for (final (i, e) in ownTranslations.indexed) ...[
if (answers.isNotEmpty || i > 0) divider,
TranslationCommentCard(entry: e, repository: t!),
],
],
);
}
/// 명세대로 본인 글과 타인 글의 선택지를 갈라 준다.
void _showMore(BuildContext context) {
showModalBottomSheet<void>(
context: context,
builder: (sheet) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: question.isMine
? [
ListTile(
title: Text(context.l10n.commonEditAction),
onTap: () => Navigator.of(sheet).pop(),
),
ListTile(
title: Text(context.l10n.commonDeleteAction),
onTap: () => Navigator.of(sheet).pop(),
),
]
: [
ListTile(
title: Text(context.l10n.reportTitle),
onTap: () {
Navigator.of(sheet).pop();
Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (_) => ReportScreen(
target: ModerationTarget(
authorName: question.authorName,
title: question.title,
),
),
),
);
},
),
ListTile(
title: Text(context.l10n.commonBlockAction),
onTap: () {
Navigator.of(sheet).pop();
confirmBlock(context, question.authorName);
},
),
],
),
),
);
}
}
/// 상세 시각 — 시안 `2026.04.27 09:00`.
String insideStamp(DateTime at) {
String two(int v) => v.toString().padLeft(2, '0');
return '${at.year}.${two(at.month)}.${two(at.day)} ${two(at.hour)}:${two(at.minute)}';
}
class _Post extends StatelessWidget {
const _Post({required this.question});
final InsideQuestion question;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.all(AppSpace.s4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
InsideAuthor(
name: question.authorName,
meta: insideStamp(question.createdAt),
),
if (question.bountyPop > 0) InsideBountyChip(question: question),
],
),
if (question.refund != InsideRefund.none) ...[
const SizedBox(height: AppSpace.s2),
Text(
question.refund.notice,
style: insideText(12, color: AppColors.onCosmicMuted),
),
],
const SizedBox(height: AppSpace.s3),
Text(question.title, style: insideText(22)),
const SizedBox(height: AppSpace.s3),
Text(
question.body,
style: insideText(16, color: AppColors.onCosmicVariant),
),
for (final (i, url) in question.imageUrls.indexed) ...[
SizedBox(height: i == 0 ? AppSpace.s5 : AppSpace.s3),
ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.sm),
child: AspectRatio(
aspectRatio: 1,
child: Image(image: insideImage(url), fit: BoxFit.cover),
),
),
],
const SizedBox(height: AppSpace.s4),
InsideStats(
question: question,
size: 18,
textSize: 14,
color: InsideColors.chipLabel,
),
],
),
);
}
class _NoAnswer extends StatelessWidget {
const _NoAnswer();
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpace.s4),
child: Column(
children: [
const ImageIcon(
AppIcons.messageCircle,
size: 48,
color: InsideColors.stat,
),
const SizedBox(height: AppSpace.s4),
Text(
context.l10n.insideFirstAnswer,
style: insideText(12, color: AppColors.onCosmicVariant),
),
],
),
);
}
/// 댓글 하나와 그 대댓글 — 댓글과 대댓글 사이 24.
class _AnswerThread extends StatelessWidget {
const _AnswerThread({required this.answer, this.onAccept, this.onTranslate});
final InsideAnswer answer;
final VoidCallback? onAccept;
final VoidCallback? onTranslate;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Answer(
answer: answer,
reply: false,
onAccept: onAccept,
onTranslate: onTranslate,
),
for (final r in answer.replies) ...[
const SizedBox(height: AppSpace.s5),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ImageIcon(
AppIcons.cornerDownRight,
size: 20,
color: AppColors.onCosmicMuted,
),
const SizedBox(width: AppSpace.s3),
Expanded(
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
// 본인 댓글은 분홍 10%, 나머지는 #1a1a1c.
color: r.isMine
? AppColors.cosmicAccent.withValues(alpha: 0.1)
: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(2),
),
child: _Answer(answer: r, reply: true),
),
),
],
),
],
],
);
}
class _Answer extends StatelessWidget {
const _Answer({
required this.answer,
required this.reply,
this.onAccept,
this.onTranslate,
});
final InsideAnswer answer;
final bool reply;
/// 대댓글 버튼 — 이 댓글을 골라 번역 댓글을 단다(명세 「댓글 선택 UI 변경 후 키보드 노출」).
final VoidCallback? onTranslate;
/// 「채택하기」 — 질문자에게만, 채택 전에만 붙는다(명세 2.64-3).
final VoidCallback? onAccept;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
InsideAuthor(name: answer.authorName),
const Spacer(),
if (answer.isAccepted)
_AcceptChip(
label: context.l10n.insideAcceptDone,
color: AppColors.cosmicBlue,
)
else if (onAccept != null)
GestureDetector(
onTap: onAccept,
child: _AcceptChip(
label: context.l10n.insideAcceptAction,
color: AppColors.onCosmic,
),
),
if (answer.isAccepted || onAccept != null)
const SizedBox(width: AppSpace.s2),
// 댓글 동작 칸 — #1a1a1a · 아이콘 16 · 사이 12. 대댓글에는 답글이 없다.
Container(
color: InsideColors.rule,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
child: Row(
children: [
const ImageIcon(
AppIcons.heart,
size: 16,
color: InsideColors.stat,
),
if (!reply) ...[
const SizedBox(width: AppSpace.s3),
Semantics(
button: onTranslate != null,
label: onTranslate == null
? null
: context.l10n.translationWrite,
child: GestureDetector(
onTap: onTranslate,
child: const ImageIcon(
AppIcons.messageCircle,
size: 16,
color: InsideColors.stat,
),
),
),
],
const SizedBox(width: AppSpace.s3),
const ImageIcon(
AppIcons.ellipsisVertical,
size: 16,
color: InsideColors.stat,
),
],
),
),
],
),
const SizedBox(height: AppSpace.s3),
Text(answer.body, style: insideText(14)),
const SizedBox(height: AppSpace.s3),
Text(
insideStamp(answer.createdAt),
style: insideText(10, color: AppColors.onCosmicMuted),
),
],
);
}
/// 채택 칩 — 「채택하기」 버튼과 「채택 완료」 표시가 같은 모양을 쓴다.
class _AcceptChip extends StatelessWidget {
const _AcceptChip({required this.label, required this.color});
final String label;
final Color color;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
border: Border.all(color: color),
borderRadius: BorderRadius.circular(2),
),
child: Text(label, style: insideText(12, color: color, height: 1)),
);
}
@@ -0,0 +1,173 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import '../../core/data/backend.dart';
import 'inside_data.dart';
/// INSIDE 목록의 실 서버 구현 — 화면 대조 원장 F3.
///
/// 정렬 셋(`최신순` · `인기순` · `POP순`)이 Figma 에서는 별도 화면이지만 여기서는
/// orderBy 필드만 다른 같은 질의다. 세 화면을 따로 두면 페이지네이션 커서도 셋이
/// 되고, 한쪽만 고치는 사고가 난다.
///
/// 커서는 `startAfterDocument` 로 잡는다. `limit` 을 커서보다 먼저 걸면 둘째 장이
/// 항상 비어 돌아온다 — 라운지에서 한 번 겪은 결함이라 순서를 지킨다.
class FirestoreInsideRepository implements InsideRepository {
FirestoreInsideRepository(this._backend);
final Backend _backend;
/// 사용자 문서(소속 팬덤 · 표시명). 라우터는 이 값을 모르므로 한 번 읽어 들고
/// 있는다. 탭을 옮길 때마다 다시 읽으면 왕복이 그대로 쌓인다.
Map<String, dynamic>? _user;
Future<Map<String, dynamic>> _me() async => _user ??=
(await _backend.db.doc('users/${_backend.requireUid}').get()).data() ??
const {};
Future<String> _resolveFandomId() async => (await _me()).strOr('fandomId');
DocumentSnapshot<Map<String, dynamic>>? _cursor;
InsideSort? _cursorSort;
static const int pageSize = 5;
Query<Map<String, dynamic>> _query(InsideSort sort, String fandomId) {
Query<Map<String, dynamic>> q = _backend.db.collection('questions');
if (fandomId.isNotEmpty) {
q = q.where('fandomId', isEqualTo: fandomId);
}
return q.orderBy(sort.field, descending: true);
}
@override
Future<InsideSnapshot> load({InsideSort sort = InsideSort.latest}) async {
final fandomId = await _resolveFandomId();
final snap = await _query(sort, fandomId).limit(pageSize).get();
_cursor = snap.docs.isEmpty ? null : snap.docs.last;
_cursorSort = sort;
return InsideSnapshot(
questions: [for (final doc in snap.docs) _question(doc)],
sort: sort,
// 작성 버튼은 팬덤에 가입한 사용자에게만 보인다(명세 2.70-3).
canWrite: fandomId.isNotEmpty,
);
}
@override
Future<List<InsideQuestion>> more({
required InsideSort sort,
int limit = pageSize,
}) async {
// 정렬이 바뀌면 앞 장의 커서는 다른 순서의 위치라 의미가 없다. 처음부터 읽는다.
if (_cursorSort != sort) _cursor = null;
var query = _query(sort, await _resolveFandomId());
final cursor = _cursor;
if (cursor != null) query = query.startAfterDocument(cursor);
final snap = await query.limit(limit).get();
if (snap.docs.isNotEmpty) {
_cursor = snap.docs.last;
_cursorSort = sort;
}
return [for (final doc in snap.docs) _question(doc)];
}
InsideQuestion _question(QueryDocumentSnapshot<Map<String, dynamic>> doc) {
final data = doc.data();
return InsideQuestion(
id: doc.id,
title: data.strOr('title'),
body: data.strOr('body'),
authorName: publicName(data['authorName']),
createdAt: data.dateOr('createdAt', DateTime.now()),
bountyPop: data.intOr('bountyPop'),
answerCount: data.intOr('answerCount'),
viewCount: data.intOr('viewCount'),
// 질문 문서에 아직 없는 필드다(fn-question-create 가 쓰지 않음). 없으면 0 · 빈 목록.
likeCount: data.intOr('likeCount'),
imageUrls: [
for (final u in (data['imageUrls'] as List?) ?? const []) '$u',
],
isMine: data.strOr('authorUid') == _backend.uid,
isSolved: data.strOr('status') == 'accepted',
refund: InsideRefund.fromStatus(data.strOr('status')),
);
}
@override
Future<int> balance() async {
final wallet =
(await _backend.db
.doc('users/${_backend.requireUid}/wallet/main')
.get())
.data() ??
const {};
return wallet.intOr('activityBalance') + wallet.intOr('supportBalance');
}
@override
Future<int> minBounty() async {
final policy = await _backend.db.doc('config/policy').get();
final value = (policy.data() ?? const {}).intOr('qnaMinBountyPop');
return value > 0 ? value : 100;
}
/// 실패는 [BackendException] 으로 그대로 올린다 — 화면이 성공처럼 그리지 않게.
@override
Future<String> create({
required String title,
required String body,
required int bountyPop,
required String clientSeq,
}) async {
final result = await _backend.call('fnQuestionCreate', {
'title': title,
'body': body,
'bountyPop': bountyPop,
'clientSeq': clientSeq,
});
return result.strOr('questionId');
}
@override
Future<List<InsideAnswer>> answers(String questionId) async {
final snap = await _backend.db
.collection('questions/$questionId/answers')
.orderBy('createdAt', descending: true)
.get();
return [
for (final doc in snap.docs)
if (doc.data().strOr('visibility', 'visible') == 'visible')
InsideAnswer(
id: doc.id,
authorName: publicName(doc.data()['authorName']),
body: doc.data().strOr('body'),
createdAt: doc.data().dateOr('createdAt', DateTime.now()),
isMine: doc.data().strOr('authorUid') == _backend.uid,
isAccepted: doc.data().boolOr('accepted'),
),
];
}
/// 표시명 = 공개 닉네임(`publicName`, 없으면 「팬」). 공급자 실명(`displayName`)은
/// 쓰지 않는다(M-7). 규칙 `authorNameIsPublic` 이 같은 계산으로 대조한다.
@override
Future<void> answer(String questionId, String body) async {
final me = await _me();
await _backend.db.collection('questions/$questionId/answers').add({
'authorUid': _backend.requireUid,
'authorName': storedPublicName(me['nickname']),
'body': body,
'accepted': false,
'visibility': 'visible',
'createdAt': FieldValue.serverTimestamp(),
});
}
@override
Future<void> accept(String questionId, String answerId) => _backend.call(
'fnAnswerAccept',
{'questionId': questionId, 'answerId': answerId},
);
}
+520 -8
View File
@@ -1,15 +1,527 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/ui/tab_placeholder.dart'; import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart';
import '../../core/ui/relative_time.dart';
import '../notification/notification_screen.dart';
import '../translation/translation_data.dart';
import 'inside_data.dart';
import 'inside_detail_screen.dart';
import 'inside_search_screen.dart';
import 'inside_ui.dart';
import 'inside_write_screen.dart';
/// INSIDE 탭 루트 — S-I-LOUNGE 팬덤 라운지. /// INSIDE 탭 루트 — 화면 대조 원장 F3.
class InsideScreen extends StatelessWidget { ///
const InsideScreen({super.key}); /// Figma 노드
/// - `196:2673` Inside_최신순
/// - `199:43083` Inside_인기순
/// - `199:43964` Inside_POP순
/// - `199:44733` Inside/인사이드 작성
/// - `200:48573` Inside/검색 — 상단 돋보기
///
/// 셋은 정렬만 다른 같은 목록이라 화면 하나에 칩으로 묶었다. 화면을 셋으로 두면
/// 커서와 빈 상태 처리가 셋이 되고, 한쪽만 고치는 사고가 난다.
///
/// 명세 2.70-1 — *최신순 소팅 최초 5개, 스크롤 시 5개씩 호출*
/// 명세 2.70-3 — *우측 하단 고정 · 해당 팬덤에 가입된 유저에게만 노출*
class InsideScreen extends StatefulWidget {
const InsideScreen({
super.key,
this.repository = const SampleInsideRepository(),
this.notifications = const SampleNotificationRepository(),
this.translation,
});
final InsideRepository repository;
/// 상단 종 아이콘이 여는 알림(F0-02).
final NotificationRepository notifications;
/// 상세의 참여형 번역 영역(FR-I18N-02). null 이면 영역을 그리지 않는다.
final TranslationRepository? translation;
@override @override
Widget build(BuildContext context) => const TabPlaceholder( State<InsideScreen> createState() => _InsideScreenState();
tab: 'INSIDE', }
screenId: 'S-I-LOUNGE',
screenName: '팬덤 라운지', class _InsideScreenState extends State<InsideScreen> {
final _scroll = ScrollController();
List<InsideQuestion> _questions = const [];
InsideSort _sort = InsideSort.latest;
bool _loading = true;
bool _more = false;
bool _end = false;
bool _canWrite = true;
String? _error;
@override
void initState() {
super.initState();
_scroll.addListener(() {
if (_scroll.position.pixels > _scroll.position.maxScrollExtent - 240) {
_loadMore();
}
});
_load();
}
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
final snap = await widget.repository.load(sort: _sort);
if (!mounted) return;
setState(() {
_questions = snap.questions;
_canWrite = snap.canWrite;
_loading = false;
// 첫 장이 한 쪽을 못 채웠으면 뒤에 더 없다.
_end = snap.questions.length < 5;
});
} on Object {
if (!mounted) return;
setState(() {
_loading = false;
_error = context.l10n.insideLoadFailed;
});
}
}
Future<void> _loadMore() async {
if (_more || _end || _loading) return;
setState(() => _more = true);
try {
final next = await widget.repository.more(sort: _sort);
if (!mounted) return;
setState(() {
_questions = [..._questions, ...next];
_more = false;
_end = next.isEmpty;
});
} on Object {
if (!mounted) return;
// 다음 장 실패는 목록 전체를 지우지 않는다. 이미 본 것은 그대로 둔다.
setState(() {
_more = false;
_end = true;
});
}
}
void _changeSort(InsideSort sort) {
if (sort == _sort) return;
setState(() {
_sort = sort;
_questions = const [];
_end = false;
});
_load();
}
Future<void> _open(InsideQuestion q) async {
await Navigator.of(context, rootNavigator: true).push<void>(
MaterialPageRoute(
builder: (_) => InsideDetailScreen(
question: q,
repository: widget.repository,
translation: widget.translation,
),
),
);
}
void _search() => Navigator.of(context, rootNavigator: true).push<void>(
MaterialPageRoute(
builder: (_) => InsideSearchScreen(
repository: widget.repository,
translation: widget.translation,
),
),
);
void _notifications() =>
Navigator.of(context, rootNavigator: true).push<void>(
MaterialPageRoute(
builder: (_) => NotificationScreen(repository: widget.notifications),
),
);
Future<void> _write() async {
final created = await Navigator.of(context, rootNavigator: true).push<bool>(
MaterialPageRoute(
builder: (_) => InsideWriteScreen(repository: widget.repository),
),
);
if (created == true && mounted) await _load();
}
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Scaffold(
// 시안 Top Navigation(196:2673) — 높이 54 · 제목 ABeeZee 20 왼쪽 · 오른쪽 42 칸 아이콘 둘.
appBar: AppBar(
toolbarHeight: 54,
titleSpacing: AppSpace.s4,
centerTitle: false,
title: const Text(
'INSIDE',
style: TextStyle(
fontFamily: AppFonts.display,
fontSize: 20,
height: 1.4,
color: AppColors.onCosmic,
),
),
actions: [
_HeaderIcon(
icon: AppIcons.search,
tooltip: context.l10n.commonSearch,
onTap: _search,
),
_HeaderIcon(
icon: AppIcons.bell,
tooltip: context.l10n.homeNotificationsTooltip,
onTap: _notifications,
),
const SizedBox(width: AppSpace.s4),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(36),
child: InsideSortBar(sort: _sort, onChanged: _changeSort),
),
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
floatingActionButton: _canWrite
? Padding(
// 시안은 하단 탭 위 20 에 뜬다(기본 여백 16).
padding: const EdgeInsets.only(bottom: 4),
child: InsideWriteFab(onTap: _write),
)
: null,
body: switch ((_loading, _error, _questions.isEmpty)) {
(true, _, _) => const Center(child: CircularProgressIndicator()),
(_, final String message, _) => _Failed(
message: message,
onRetry: _load,
),
(_, _, true) => Center(
child: Text(context.l10n.insideEmpty, style: t.bodyMedium),
),
_ => RefreshIndicator(
onRefresh: _load,
child: InsideQuestionList(
controller: _scroll,
questions: _questions,
loadingMore: _more,
onOpen: _open,
),
),
},
);
}
}
class _HeaderIcon extends StatelessWidget {
const _HeaderIcon({
required this.icon,
required this.tooltip,
required this.onTap,
});
final ImageProvider icon;
final String tooltip;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => SizedBox.square(
dimension: 42,
child: IconButton(
tooltip: tooltip,
padding: EdgeInsets.zero,
onPressed: onTap,
icon: ImageIcon(icon, size: 24, color: AppColors.onCosmic),
),
); );
} }
/// 질문 카드 목록 — 시안 안쪽 16 · 카드 사이 4 + 1px #1a1a1a 줄 + 4.
class InsideQuestionList extends StatelessWidget {
const InsideQuestionList({
super.key,
required this.questions,
required this.onOpen,
this.controller,
this.loadingMore = false,
});
final List<InsideQuestion> questions;
final ValueChanged<InsideQuestion> onOpen;
final ScrollController? controller;
final bool loadingMore;
@override
Widget build(BuildContext context) => ListView.separated(
controller: controller,
padding: const EdgeInsets.fromLTRB(
AppSpace.s4,
AppSpace.s4,
AppSpace.s4,
AppSpace.s8,
),
itemCount: questions.length + (loadingMore ? 1 : 0),
separatorBuilder: (_, _) =>
const Divider(height: 9, thickness: 1, color: InsideColors.rule),
itemBuilder: (context, i) {
if (i >= questions.length) {
return const Padding(
padding: EdgeInsets.all(AppSpace.s4),
child: Center(child: CircularProgressIndicator()),
);
}
final q = questions[i];
return InsideQuestionCard(question: q, onTap: () => onOpen(q));
},
);
}
/// 작성 버튼 — 시안 130×36 알약 · #19191c · #232324 획 · 연필 16 + 「인사이드 작성」.
class InsideWriteFab extends StatelessWidget {
const InsideWriteFab({super.key, required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) => Material(
color: const Color(0xFF19191C),
shape: const StadiumBorder(
side: BorderSide(color: AppColors.cosmicSurfaceRaised),
),
child: InkWell(
onTap: onTap,
customBorder: const StadiumBorder(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Image(image: AppIcons.pencil, width: 16, height: 16),
const SizedBox(width: 6),
Text(
context.l10n.insideWrite,
style: insideText(
14,
color: AppColors.onCosmicVariant,
height: 20 / 14,
).copyWith(letterSpacing: 0),
),
],
),
),
),
);
}
/// 정렬 칩 줄 — 시안 높이 36 · 좌우 16 · 칩 사이 4.
class InsideSortBar extends StatelessWidget {
const InsideSortBar({super.key, required this.sort, required this.onChanged});
final InsideSort sort;
final ValueChanged<InsideSort> onChanged;
@override
Widget build(BuildContext context) => SizedBox(
height: 36,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4, vertical: 2),
children: [
for (final s in InsideSort.values) ...[
_SortChip(sort: s, selected: sort == s, onTap: () => onChanged(s)),
const SizedBox(width: AppSpace.s1),
],
],
),
);
}
class _Failed extends StatelessWidget {
const _Failed({required this.message, required this.onRetry});
final String message;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(message, style: t.titleMedium),
const SizedBox(height: AppSpace.s2),
Text(context.l10n.homeRetryLater, style: t.bodyMedium),
const SizedBox(height: AppSpace.s4),
FilledButton(
onPressed: onRetry,
child: Text(context.l10n.commonRetry),
),
],
),
);
}
}
/// 목록의 질문 카드 한 장 — 시안 `196:32243`.
///
/// 명세 2.70-2 — *생성자 닉네임 · 작성일시 · 좋아요 수 · 댓글 수*.
/// 작성일시는 공용 [relativeTime] 을 쓴다 — 7일이 넘으면 `2026.01.01` 로 바뀐다.
class InsideQuestionCard extends StatelessWidget {
const InsideQuestionCard({
super.key,
required this.question,
required this.onTap,
});
final InsideQuestion question;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final text = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
question.title,
style: insideText(14),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: AppSpace.s2),
Text(
question.body,
style: insideText(12, color: AppColors.onCosmicVariant),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
);
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Padding(
padding: const EdgeInsets.all(AppSpace.s4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
InsideAuthor(
name: question.authorName,
meta: relativeTime(question.createdAt),
),
if (question.bountyPop > 0)
InsideBountyChip(question: question),
],
),
const SizedBox(height: AppSpace.s3),
if (question.imageUrls.isEmpty)
text
else
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: text),
const SizedBox(width: 36),
ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Image(
image: insideImage(question.imageUrls.first),
width: 70,
height: 70,
fit: BoxFit.cover,
),
),
],
),
const SizedBox(height: AppSpace.s4),
InsideStats(question: question),
],
),
),
);
}
}
/// 정렬 칩 — 시안 Chip(196:2673): r8 · 71×32 · 아이콘 16.
/// 선택은 흰 바탕에 검정 글자, 비선택은 #1a1a1a 바탕 · #232324 획 · #d5d7d9 글자.
class _SortChip extends StatelessWidget {
const _SortChip({
required this.sort,
required this.selected,
required this.onTap,
});
final InsideSort sort;
final bool selected;
final VoidCallback onTap;
static ImageProvider _icon(InsideSort s) => switch (s) {
InsideSort.latest => AppIcons.clock,
InsideSort.popular => AppIcons.heart,
InsideSort.bounty => AppIcons.bolt,
};
@override
Widget build(BuildContext context) {
final fg = selected ? const Color(0xFF000000) : InsideColors.chipLabel;
return Material(
color: selected ? AppColors.onCosmic : const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(AppRadius.sm),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Container(
height: 32,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.sm),
border: Border.all(color: AppColors.cosmicSurfaceRaised),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 시안 POP순 칩의 번개는 16 칸이 아니라 10×10.6 벡터다.
ImageIcon(
_icon(sort),
size: sort == InsideSort.bounty ? 10.6 : 16,
color: fg,
),
const SizedBox(width: AppSpace.s1),
Text(
sort.label,
style: insideText(
12,
color: fg,
height: 20 / 12,
).copyWith(letterSpacing: 0),
),
],
),
),
),
);
}
}
@@ -0,0 +1,289 @@
import 'package:flutter/material.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart';
import '../../core/ui/play_banner.dart';
import '../translation/translation_data.dart';
import 'inside_data.dart';
import 'inside_detail_screen.dart';
import 'inside_screen.dart';
import 'inside_ui.dart';
/// INSIDE 검색 — Figma `200:48573` Inside/검색 · `201:49535` 검색 결과.
///
/// 검색어를 넣기 전에는 배너와 최근 검색어, 넣은 뒤에는 정렬 칩과 같은 카드 목록이다.
///
/// **서버 검색 경로가 없다.** `questions` 에 제목·본문 전문 검색 색인이 없어서, 지금은
/// 정렬별 첫 장을 불러와 제목·본문에 검색어가 든 것만 거른다.
/// ponytail: 첫 장 안에서만 찾는다 — 서버 검색(fn 또는 색인)이 생기면 [_run] 만 바꾼다.
class InsideSearchScreen extends StatefulWidget {
const InsideSearchScreen({
super.key,
this.repository = const SampleInsideRepository(),
this.translation,
});
final InsideRepository repository;
final TranslationRepository? translation;
@override
State<InsideSearchScreen> createState() => _InsideSearchScreenState();
}
class _InsideSearchScreenState extends State<InsideSearchScreen> {
final _controller = TextEditingController();
/// 최근 검색어. 기기 안에만 두는 값이다(갤럭시 검색과 같다).
final List<String> _recent = [];
String? _term;
InsideSort _sort = InsideSort.latest;
List<InsideQuestion>? _hits;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _submit(String q) {
final term = q.trim();
if (term.isEmpty) return;
setState(() {
_term = term;
_recent
..remove(term)
..insert(0, term);
if (_recent.length > 10) _recent.removeLast();
});
_run();
}
Future<void> _run() async {
final term = _term;
if (term == null) return;
setState(() => _hits = null);
final snap = await widget.repository.load(sort: _sort);
if (!mounted || term != _term) return;
final k = term.toLowerCase();
setState(
() => _hits = [
for (final q in snap.questions)
if (q.title.toLowerCase().contains(k) ||
q.body.toLowerCase().contains(k))
q,
],
);
}
void _clear() {
if (_controller.text.isEmpty) return;
_controller.clear();
setState(() {
_term = null;
_hits = null;
});
}
@override
Widget build(BuildContext context) {
final term = _term;
final hits = _hits;
return Scaffold(
appBar: AppBar(
toolbarHeight: 54,
leadingWidth: 58,
leading: Padding(
padding: const EdgeInsets.only(left: AppSpace.s4),
child: BackButton(onPressed: () => Navigator.of(context).maybePop()),
),
titleSpacing: 0,
title: _SearchField(
controller: _controller,
onSubmitted: _submit,
onChanged: (v) {
if (v.isEmpty) _clear();
},
),
actions: const [SizedBox(width: AppSpace.s4)],
bottom: term == null
? null
: PreferredSize(
preferredSize: const Size.fromHeight(36),
child: InsideSortBar(
sort: _sort,
onChanged: (s) {
setState(() => _sort = s);
_run();
},
),
),
),
body: term == null
? _Recent(
terms: _recent,
onClear: () => setState(_recent.clear),
onPick: (t) {
_controller.text = t;
_submit(t);
},
)
: hits == null
? const Center(child: CircularProgressIndicator())
: hits.isEmpty
? Center(
child: Text(
context.l10n.commonNoResults,
style: insideText(14, color: const Color(0xFF6E6E6E)),
),
)
: InsideQuestionList(
questions: hits,
onOpen: (q) => Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => InsideDetailScreen(
question: q,
repository: widget.repository,
translation: widget.translation,
),
),
),
),
);
}
}
/// 검색 칸 — 시안 301×42 · #282828 · 모서리 10 · 돋보기 20 · 16/500.
class _SearchField extends StatelessWidget {
const _SearchField({
required this.controller,
required this.onSubmitted,
required this.onChanged,
});
final TextEditingController controller;
final ValueChanged<String> onSubmitted;
final ValueChanged<String> onChanged;
static const _hint = Color(0xFF6E6E6E);
@override
Widget build(BuildContext context) {
final style = insideText(16, height: 1.4).copyWith(letterSpacing: 0);
return Container(
height: 42,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: const Color(0xFF282828),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
const ImageIcon(AppIcons.search, size: 20, color: _hint),
const SizedBox(width: 6),
Expanded(
child: TextField(
controller: controller,
autofocus: true,
textInputAction: TextInputAction.search,
onSubmitted: onSubmitted,
onChanged: onChanged,
style: style,
cursorColor: AppColors.cosmicAccent,
decoration: InputDecoration(
isCollapsed: true,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
hintText: context.l10n.insideSearchHint,
hintStyle: style.copyWith(color: _hint),
),
),
),
],
),
);
}
}
class _Recent extends StatelessWidget {
const _Recent({
required this.terms,
required this.onClear,
required this.onPick,
});
final List<String> terms;
final VoidCallback onClear;
final ValueChanged<String> onPick;
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.symmetric(vertical: AppSpace.s4),
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: 9),
child: PlayBanner(),
),
const SizedBox(height: AppSpace.s5),
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
context.l10n.commonRecentSearches,
style: insideText(
16,
weight: FontWeight.w600,
height: 20 / 16,
).copyWith(letterSpacing: 0),
),
GestureDetector(
onTap: onClear,
child: Text(
context.l10n.commonClearAll,
style: insideText(
14,
color: const Color(0xFFC2C2C2),
height: 20 / 14,
).copyWith(letterSpacing: 0),
),
),
],
),
),
const SizedBox(height: AppSpace.s4),
if (terms.isEmpty)
SizedBox(
height: 140,
child: Center(
child: Text(
context.l10n.commonNoRecentSearches,
style: insideText(
14,
weight: FontWeight.w600,
color: const Color(0xFF6E6E6E),
height: 20 / 14,
).copyWith(letterSpacing: 0),
),
),
)
else
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
child: Wrap(
spacing: AppSpace.s2,
runSpacing: AppSpace.s2,
children: [
for (final term in terms)
ActionChip(label: Text(term), onPressed: () => onPick(term)),
],
),
),
],
);
}
}
+154
View File
@@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart';
import '../../core/ui/relative_time.dart';
import '../home/home_screen.dart' show formatPop;
import 'inside_data.dart';
/// INSIDE 화면군이 같이 쓰는 시안 조각 — 아이콘 · 글자 · 작성자 줄 · 현상금 칩 · 반응 줄.
///
/// 아이콘은 Figma Iconset(Lucide) 을 3배 PNG 로 둔 것이다. 공용 [AppIcons] 로 올릴 때까지
/// 여기 둔다(디자인 일치 2단계 「공유 변경 요청」).
/// 시안 INSIDE 의 회색 단계.
abstract final class InsideColors {
static const stat = Color(0xFF4F4F4F);
static const avatar = Color(0xFFD9D9D9);
static const rule = Color(0xFF1A1A1A);
static const hint = Color(0xFF6B6B6B);
static const chipLabel = Color(0xFFD5D7D9);
}
/// 42dot Sans · 자간 -2.5% · 행간 130%(시안 INSIDE 본문 기본값).
TextStyle insideText(
double size, {
FontWeight weight = FontWeight.w500,
Color color = AppColors.onCosmic,
double height = 1.3,
}) => TextStyle(
fontFamily: AppFonts.body,
fontSize: size,
fontWeight: weight,
height: height,
letterSpacing: -0.025 * size,
color: color,
);
/// 작성자 닉네임 — ABeeZee 12(한글은 42dot 대체).
const insideNameStyle = TextStyle(
fontFamily: AppFonts.display,
fontFamilyFallback: [AppFonts.body],
fontSize: 12,
height: 1.3,
letterSpacing: -0.3,
color: AppColors.onCosmic,
);
/// 첨부 이미지. `assets/` 로 시작하면 번들 표본이다.
ImageProvider insideImage(String url) =>
url.startsWith('assets/') ? AssetImage(url) : NetworkImage(url);
/// 아바타 26 · 이름 · (있으면) 시각 줄.
class InsideAuthor extends StatelessWidget {
const InsideAuthor({super.key, required this.name, this.meta});
final String name;
final String? meta;
@override
Widget build(BuildContext context) => Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 26,
height: 26,
decoration: const BoxDecoration(
color: InsideColors.avatar,
shape: BoxShape.circle,
),
),
const SizedBox(width: 9),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(name, style: insideNameStyle),
if (meta != null)
Text(meta!, style: insideText(10, color: AppColors.onCosmicMuted)),
],
),
],
);
}
/// 현상금 칩 — 시안 58×24 · 모서리 2 · 번개 + 「500 P」. 채택이 끝난 질문은 회색이다.
class InsideBountyChip extends StatelessWidget {
const InsideBountyChip({super.key, required this.question});
final InsideQuestion question;
@override
Widget build(BuildContext context) {
final c = question.isSolved ? InsideColors.stat : AppColors.cosmicBlue;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: c.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(2),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ImageIcon(AppIcons.bolt, size: 10, color: c),
const SizedBox(width: 4),
Text(
'${formatPop(question.bountyPop)} P',
style: insideText(12, color: c),
),
],
),
);
}
}
/// 좋아요 · 댓글 · 조회 줄. 0 이면 숫자를 빼고 아이콘만 둔다(시안 「아미보이」 카드).
class InsideStats extends StatelessWidget {
const InsideStats({
super.key,
required this.question,
this.size = 14,
this.textSize = 12,
this.color = InsideColors.stat,
});
final InsideQuestion question;
final double size;
final double textSize;
final Color color;
@override
Widget build(BuildContext context) {
Widget stat(ImageProvider icon, int n) => Row(
mainAxisSize: MainAxisSize.min,
children: [
ImageIcon(icon, size: size, color: color),
if (n > 0) ...[
const SizedBox(width: 4),
Text(
formatReaction(n),
style: insideText(textSize, color: color, height: 1),
),
],
],
);
return Row(
children: [
stat(AppIcons.heart, question.likeCount),
const SizedBox(width: 12),
stat(AppIcons.messageCircle, question.answerCount),
const SizedBox(width: 12),
stat(AppIcons.eye, question.viewCount),
],
);
}
}
@@ -0,0 +1,359 @@
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<InsideWriteScreen> createState() => _InsideWriteScreenState();
}
class _InsideWriteScreenState extends State<InsideWriteScreen> {
final _form = GlobalKey<FormState>();
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<void> _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<String>(
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),
),
),
],
),
);
}
@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import '../auth/auth.dart';
/// SNS 연동 — 화면 대조 원장 F6.
///
/// Figma `357:222740`(Apple) · `357:222899`(Google) · `357:222671`(Kakao).
/// **화면들이 제공자만 다르고 같다** — 따로 만들면 연동 규칙이 여러 벌이 되고,
/// 규칙이 바뀔 때 한 곳만 고치는 사고가 난다.
///
/// 별첨 B v1.4 — *하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다.*
/// X(트위터)는 확정 4종에 없어 목록에서 빠졌다([linkableProviders]).
///
/// 명세 `554:22797` — *플랫폼별 연동 상태 반영 · **동일 이메일만 연동 가능***
/// 판정은 [sameAccountIdentity] 가 하고, 실제 연결은 [AccountLinker] 구현체
/// (Firebase `linkWithCredential`)가 한다.
class AccountLinkScreen extends StatefulWidget {
const AccountLinkScreen({
super.key,
required this.provider,
required this.linker,
this.linkedEmail,
});
final AuthProviderKind provider;
/// 연결 경계. Firebase 어댑터이거나 [SampleAccountLinker] 다.
final AccountLinker linker;
/// 이미 연동된 이메일. 없으면 미연동.
final String? linkedEmail;
@override
State<AccountLinkScreen> createState() => _AccountLinkScreenState();
}
class _AccountLinkScreenState extends State<AccountLinkScreen> {
late String? _linked = widget.linkedEmail;
bool _busy = false;
bool get _connected => _linked != null;
String get _name => widget.provider.shortName;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Scaffold(
appBar: AppBar(title: Text(context.l10n.linkTitle(_name))),
body: ListView(
padding: const EdgeInsets.all(AppSpace.s4),
children: [
Container(
padding: const EdgeInsets.all(AppSpace.s4),
decoration: BoxDecoration(
color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_name, style: t.titleMedium),
const SizedBox(height: AppSpace.s2),
Text(_linked ?? context.l10n.linkNotLinked, style: t.bodySmall),
],
),
),
const SizedBox(height: AppSpace.s4),
Text(context.l10n.linkSameEmailOnly, style: t.labelSmall),
const SizedBox(height: AppSpace.s5),
FilledButton(
onPressed: _busy ? null : _toggle,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(AppSizing.touchMinApp),
backgroundColor: _connected
? AppColors.cosmicSurfaceRaised
: AppColors.cosmicAccent,
foregroundColor: _connected
? AppColors.onCosmic
: AppColors.onCosmicAccent,
),
child: Text(
_connected ? context.l10n.linkUnlink : context.l10n.linkLink,
),
),
const SizedBox(height: AppSpace.s3),
// 명세 `554:23411` — 연동 해제가 계정 삭제로 오인되지 않게 한다.
Text(context.l10n.settingsUnlinkBody, style: t.labelSmall),
],
),
);
}
Future<void> _toggle() async {
final wasConnected = _connected;
setState(() => _busy = true);
final outcome = wasConnected
? await widget.linker.unlink(widget.provider)
: await widget.linker.link(widget.provider);
if (!mounted) return;
// 화면 상태를 추측하지 않고 연결 경계에 다시 물어본다 — 성공했다고 믿고
// 로컬 값을 뒤집으면 서버가 거절한 연결이 붙은 것처럼 보인다.
final accounts = widget.linker.linkedAccounts;
setState(() {
_busy = false;
_linked = accounts.containsKey(widget.provider)
// 붙었는데 제공자가 이메일을 안 준 경우(Apple 재연동)도 연결 상태다.
? accounts[widget.provider] ?? context.l10n.linkLinked
: null;
});
switch (outcome) {
case AuthFailed(:final code, :final message):
_toast('[$code] $message');
case AuthNotConfigured(:final missing):
_toast(context.l10n.settingsLinkNotReady(_name, missing.join(' · ')));
case AuthSucceeded() || AuthCancelled():
break;
}
}
void _toast(String message) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
);
}
}
File diff suppressed because it is too large Load Diff
+213
View File
@@ -0,0 +1,213 @@
import 'package:flutter/material.dart';
import '../campaign/campaign_data.dart';
import '../campaign/campaign_detail_screen.dart';
import '../campaign/widgets/campaign_list_card.dart';
import '../inside/inside_data.dart';
import '../inside/inside_detail_screen.dart';
import '../inside/inside_screen.dart' show InsideQuestionCard;
import 'widgets/my_cards.dart';
import '../../core/i18n/app_locale.dart';
/// 내 인사이드 · 내 캠페인 — 화면 대조 원장 F6.
///
/// Figma `370:228289`(내 인사이드) · `370:229665`(내 캠페인).
/// 명세 `554:22382`(게시글 리스트 영역) 계열이며 **머리글과 빈 상태만 다르고**
/// 구조가 같아 한 화면에 종류를 받아 쓴다.
enum MyListKind {
inside,
campaign;
String get title => switch (this) {
inside => appL10n.myListInside,
campaign => appL10n.myListCampaign,
};
String get emptyMessage => switch (this) {
inside => appL10n.myListInsideEmpty,
campaign => appL10n.myListCampaignEmpty,
};
}
/// 정렬 칩 — 시안 `370:228289` 「최신순 · POP순 · 채택 완료」 · `370:229665` 「최신순 · 달성 완료」.
enum _MySort { latest, pop, solved, achieved }
class MyListsScreen extends StatefulWidget {
const MyListsScreen({super.key, required this.kind, this.campaigns});
final MyListKind kind;
/// 내 캠페인의 원천. null 이면 표본(목 모드). 저장소 선택은 repositories.dart.
final CampaignRepository? campaigns;
@override
State<MyListsScreen> createState() => _MyListsScreenState();
}
class _MyListsScreenState extends State<MyListsScreen> {
_MySort _sort = _MySort.latest;
/// 표본 — 시안 `370:228289` 문구. 서버가 붙으면 INSIDE 저장소의 내 글로 옮긴다.
static final _insides = () {
final now = DateTime.now();
return <InsideQuestion>[
InsideQuestion(
id: 'i1',
title: 'BTS 컴백 알려주세요!',
body: 'BTS 한국 컴백 시기가 궁급합니다!\n아시는 분들 댓글 부탁드려요 ㅠㅠ',
authorName: 'BTS_Hong',
createdAt: now,
bountyPop: 100,
isMine: true,
),
InsideQuestion(
id: 'i2',
title: '화양연화 세계관 해석해주실 분?',
body: '이번 뮤비랑 연결되는 부분이 헷갈리네요. 타임라인 정리해주실 천재 아미 구합니다!',
authorName: 'BTS_Hong',
createdAt: now.subtract(const Duration(minutes: 10)),
bountyPop: 500,
viewCount: 3,
isMine: true,
),
InsideQuestion(
id: 'i3',
title: 'BTS 이 포즈 뭔가요?',
body: '아미 분들 이 포즈 뭔지 알려주실 수 있나요?',
authorName: 'BTS_Hong',
createdAt: now.subtract(const Duration(days: 30)),
bountyPop: 800,
answerCount: 24,
viewCount: 82,
isMine: true,
isSolved: true,
),
];
}();
List<_MySort> get _sorts => switch (widget.kind) {
MyListKind.inside => const [_MySort.latest, _MySort.pop, _MySort.solved],
MyListKind.campaign => const [_MySort.latest, _MySort.achieved],
};
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: Text(widget.kind.title),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(36),
child: Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 2, 16, 2),
child: Row(
children: [
for (final (i, sort) in _sorts.indexed) ...[
if (i > 0) const SizedBox(width: 8),
MySortChip(
label: switch (sort) {
_MySort.latest => context.l10n.sortLatest,
_MySort.pop => context.l10n.sortPop,
_MySort.solved => context.l10n.sortSolved,
_MySort.achieved => context.l10n.sortAchieved,
},
icon: switch (sort) {
_MySort.latest => MySortChip.clock,
_MySort.pop => MySortChip.bolt,
_ => MySortChip.fileCheck,
},
selected: _sort == sort,
onTap: () => setState(() => _sort = sort),
),
],
],
),
),
),
),
),
body: switch (widget.kind) {
MyListKind.inside => _insideList(context),
MyListKind.campaign => _campaignList(context),
},
);
Widget _insideList(BuildContext context) {
final items = switch (_sort) {
_MySort.pop => [
..._insides,
]..sort((a, b) => b.bountyPop.compareTo(a.bountyPop)),
_MySort.solved => _insides.where((q) => q.isSolved).toList(),
_ => _insides,
};
if (items.isEmpty) {
return Center(
child: Text(
widget.kind.emptyMessage,
style: Theme.of(context).textTheme.bodyMedium,
),
);
}
// 카드는 INSIDE 목록과 같은 것을 쓴다(시안도 같은 컴포넌트다).
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: items.length,
separatorBuilder: (_, _) => const Padding(
padding: EdgeInsets.symmetric(vertical: 2),
child: SizedBox(height: 1, child: ColoredBox(color: Color(0xFF1A1A1A))),
),
itemBuilder: (context, i) => InsideQuestionCard(
question: items[i],
onTap: () => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => InsideDetailScreen(question: items[i]),
),
),
),
);
}
Widget _campaignList(BuildContext context) =>
FutureBuilder<List<CampaignCardData>>(
future: (widget.campaigns ?? SampleCampaignRepository()).mine(),
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
// 내가 만든 것만 남긴다. 「달성 완료」는 목표를 채운 것만.
final mine = (snap.data ?? const [])
.where((c) => c.mine)
.where(
(c) =>
_sort != _MySort.achieved ||
c.status == CampaignStatus.success,
)
.toList();
if (mine.isEmpty) {
return Center(
child: Text(
widget.kind.emptyMessage,
style: Theme.of(context).textTheme.bodyMedium,
),
);
}
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: mine.length,
separatorBuilder: (_, _) => const SizedBox(height: 16),
itemBuilder: (context, i) => CampaignListCard(
card: mine[i],
mine: true,
onOpen: () => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => CampaignDetailScreen(
campaignId: mine[i].id,
repository: widget.campaigns,
),
),
),
),
);
},
);
}
@@ -0,0 +1,486 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_storage/firebase_storage.dart';
import '../../core/data/backend.dart';
import '../home/home_repository_firestore.dart' show kstDayKey;
import 'my_data.dart';
import '../../core/i18n/app_locale.dart';
/// MY 의 실 서버 구현 — 화면 대조 원장 F6.
///
/// 서버가 실제로 들고 있는 것만 덮어쓴다. 결제 내역·환불 요청도 이제 서버 문서
/// (`users/{uid}/purchases`)를 읽고 쓴다.
/// 그래서 상속으로 붙이고 필요한 것만 덮는다 — 인터페이스를 빈 껍데기로 다시
/// 쓰면 어느 것이 진짜인지 화면에서 알 수 없다.
///
/// 읽는 곳 — **서버가 실제로 쓰는 필드**를 읽는다.
/// - 프로필 `users/{uid}` (nickname · photoUrl · originNo · steps · completedQuests)
/// - 누적 기여 POP `users/{uid}.totalContributedPop`(`fn-pop-spend` 가 갱신, 설계서 §3.6)
/// - 레벨 `users/{uid}.{userExp, userLevel}` · Passport `users/{uid}.passportTier`
/// (C-18 · B-65). 필드가 없는 기존 사용자는 Lv.1 · SILVER 로 둔다.
/// - 잔액 `users/{uid}/wallet/main.activityBalance`
/// - 행성별 기여 `fandoms/{fandomId}` + `fandoms/{fandomId}/members/{uid}.contributedPop`
/// - POP 원장 `users/{uid}/popTransactions` (최신순 · 커서 `createdAt`)
/// - 공지 `notices`
/// - 차단 `users/{uid}/blocks/{targetUid}`
/// - 문의 `inquiries` (본인 것만)
/// - 결제 `users/{uid}/purchases` (`fn-iap-verify` · 환불 요청 한 전이만 본인 쓰기)
/// - 푸시 설정 `users/{uid}.notificationPrefs` (보안 규칙이 본인 쓰기를 허용한 필드)
///
/// **예전에는 `users.contributedPop` 과 `planets/{uid}.contributedPop` 을 읽었다.**
/// 서버가 쓰는 이름이 아니어서 기여가 쌓여도 화면이 0 에 머물렀다. 등급도 앱이
/// 문턱표로 다시 계산해 서버 값과 갈릴 수 있었다.
///
/// 쓰는 곳은 재화가 오가지 않는 다섯 갈래뿐이다 — 닉네임·사진 URL·푸시 설정(`users/{uid}`),
/// 차단(`users/{uid}/blocks`), 문의 접수(`inquiries`), 환불 요청(`users/{uid}/purchases`). POP 과 기여는 여기서
/// 건드리지 않는다(별첨 1 ② 「클라이언트 단독 지급 0건」).
class FirestoreMyRepository extends SampleMyRepository {
FirestoreMyRepository(this._backend);
final Backend _backend;
@override
Future<MyProfile> profile() async {
final uid = _backend.requireUid;
final db = _backend.db;
final userF = db.doc('users/$uid').get();
final walletF = db.doc('users/$uid/wallet/main').get();
final user = (await userF).data() ?? const <String, dynamic>{};
final wallet = (await walletF).data() ?? const <String, dynamic>{};
final fandomId = user.strOr('fandomId');
var tiers = const <PlanetTier>[];
if (fandomId.isNotEmpty) {
final fandomF = db.doc('fandoms/$fandomId').get();
final memberF = db.doc('fandoms/$fandomId/members/$uid').get();
final fandom = (await fandomF).data() ?? const <String, dynamic>{};
final member = (await memberF).data() ?? const <String, dynamic>{};
tiers = [
PlanetTier(
fandomName: fandom.strOr('name', fandomId),
// 팬덤별 누적 기여 POP. `fn-pop-spend` 가 기여 차감 때 함께 올린다.
contributedPop: member.intOr('contributedPop'),
isPrimary: true,
),
];
}
// 닉네임이 아직 없으면(온보딩 전) 중립 이름으로 부른다. 「— 님」은 고장처럼 보인다.
// **이메일 앞부분을 쓰지 않는다** — 프로필 수정이 그 값을 그대로
// 닉네임으로 저장해 홈 기여자명으로 퍼졌다. 이메일은 설정 「계정 정보」
// (명세 `554:22634` *닉네임 · 가입 이메일 값*)에만 둔다.
final email = _backend.email ?? '';
final photoUrl = user.strOr('photoUrl');
return MyProfile(
nickname: publicName(user['nickname']),
email: email,
contributedPop: user.intOr('totalContributedPop'),
userExp: user.intOr('userExp'),
userLevel: user.intOr('userLevel').clamp(1, 1 << 31),
passportTier: PassportTier.parse(user.strOr('passportTier')),
balancePop: wallet.intOr('activityBalance'),
steps: user.intOr('steps'),
completedQuests: user.intOr('completedQuests'),
planetTiers: tiers,
photoUrl: photoUrl.isEmpty ? null : photoUrl,
originNo: user.containsKey('originNo') ? user.intOr('originNo') : null,
);
}
/// 푸시 알림 설정 — 정책 원장 B-77 5 개 구분.
///
/// `notificationPrefs` 를 쓴 이유는 보안 규칙이다. `firestore.rules` 의
/// `users/{uid}` 업데이트 화이트리스트에 이 이름이 이미 들어 있어, 다른 이름을
/// 쓰면 클라이언트 쓰기가 규칙 층에서 거부된다.
///
/// 문서에 없는 구분은 정책 기본값으로 채운다 — 구분이 추가될 때 기존 계정의
/// 문서를 일괄 수정하지 않아도 되고, Marketing 은 그 기본값이 OFF 다.
@override
Future<Map<PushTopic, bool>> pushSettings() async {
final uid = _backend.requireUid;
final snap = await _backend.db.doc('users/$uid').get();
final prefs = snap.data()?['notificationPrefs'];
final saved = prefs is Map ? prefs : const <dynamic, dynamic>{};
return {
for (final topic in PushTopic.values)
topic: saved[topic.name] is bool
? saved[topic.name] as bool
: topic.defaultOn,
};
}
@override
Future<void> setPush(PushTopic topic, bool on) async {
// 서비스 필수는 끌 수 없으므로 저장하지 않는다. 값이 남으면 다음에 읽을 때
// false 가 되어 화면과 정책이 어긋난다.
if (topic.locked) return;
final uid = _backend.requireUid;
await _backend.db.doc('users/$uid').set({
'notificationPrefs': {topic.name: on},
'updatedAt': FieldValue.serverTimestamp(),
}, SetOptions(merge: true));
}
@override
Future<void> updateNickname(String nickname) async {
final uid = _backend.requireUid;
await _backend.db.doc('users/$uid').set(
{'nickname': nickname},
SetOptions(merge: true),
);
}
/// POP 원장 — S-M-WALLET · 명세 `554:23732`.
///
/// 원장 한 행을 그대로 옮긴다. 합산·재계산을 하지 않는 것이 별첨 1 ②
/// 「POP 원장 – MY 통계 3자 교차 대조」를 앱 쪽에서 지키는 방법이다.
/// **금액에 부호가 실려 있다** — `spendPop` 이 `amount: -input.amount` 로 쓰고
/// 설계서 §3.4 대조식이 `balance == Σ amount` 이므로, 화면이 부호를 다시
/// 뒤집으면 차감이 지급으로 보인다.
///
/// 이어읽기는 시각 비교가 아니라 **커서 문서**로 한다. 한 트랜잭션이 일반·기여
/// 두 종류를 각각 쓰면 두 행의 서버 타임스탬프가 같아지는데, `createdAt` 만
/// 비교해 끊으면 그 경계에서 한 행이 사라진다. Firestore 는 정렬에 문서 이름
/// (`__name__`)을 암묵으로 덧붙이므로 `startAfterDocument` 는 동률까지 가른다.
/// 커서 문서를 한 번 더 읽는 값을 내지만, 원장 행이 조용히 빠지는 것보다 싸다.
///
/// 딱 하나 예외가 Zap 이다. 명세가 *Zap 횟수에 따라 리스트 생성이 아닌 당일 기준
/// 총기여 값 및 시간 update* 를 요구하므로 같은 날 붙어 있는 `ECLIPSE_DEFENSE`
/// 행을 한 줄로 접는다([_foldZap]).
@override
Future<List<PopLedgerEntry>> popHistory({
int limit = 20,
PopCursor? after,
}) async {
final uid = _backend.requireUid;
final collection = _backend.db.collection('users/$uid/popTransactions');
var query = collection.orderBy('createdAt', descending: true);
if (after != null) {
final anchor = await collection.doc(after.id).get();
// 원장은 append-only 이고 규칙이 삭제를 막아 두었으므로 없을 수가 없다.
// 그래도 없다면 그 자리를 찾을 방법이 없으니 끝으로 본다. 처음부터 다시
// 읽으면 이미 그린 줄이 목록에 두 번 붙는다.
if (!anchor.exists) return const [];
query = query.startAfterDocument(anchor);
}
final snapshot = await query.limit(limit).get();
final rows = <_LedgerRow>[];
for (final doc in snapshot.docs) {
final data = doc.data();
final reasonCode = data.strOr('reasonCode');
final label = popReasonLabel(reasonCode);
final at = data.dateOr('createdAt', _epoch);
rows.add(_LedgerRow(
reasonCode: reasonCode,
// Zap 한 행이 몇 번분인가. `fn-eclipse-defend` 가 `count` 를 묶어 받으므로
// 행 하나가 여러 번일 수 있다. 없는 문서는 한 번으로 본다.
count: data.intOr('count', 1).clamp(1, 1 << 30),
entry: PopLedgerEntry(
at: at,
title: label.title,
type: label.type,
kind: PopKind.parse(data.strOr('popType')),
txType: PopTxType.parse(data.strOr('type', 'grant')),
delta: data.intOr('amount'),
balanceAfter: data.intOr('balanceAfter'),
cursor: PopCursor(at: at, id: doc.id),
),
));
}
return _foldZap(rows);
}
/// 서비스 결제 내역 — `users/{uid}/purchases`(`fn-iap-verify` 가 쓴다) 최신순.
///
/// 명세 `554:23841` *결제 일시 · 결제 아이템명 · 결제 아이템 가격 · 최신순 소팅*.
/// 가격은 결제 때 스토어가 앱에 준 표시 가격이다(지급 판단에는 쓰지 않는 값).
@override
Future<List<PaymentEntry>> payments() async {
final uid = _backend.requireUid;
final snapshot = await _backend.db
.collection('users/$uid/purchases')
.orderBy('purchasedAt', descending: true)
.limit(100)
.get();
return [for (final doc in snapshot.docs) _payment(doc.id, doc.data())];
}
@override
Future<PaymentEntry> payment(String id) async {
final uid = _backend.requireUid;
final doc = await _backend.db.doc('users/$uid/purchases/$id').get();
final data = doc.data();
if (data == null) {
throw BackendException('E-PAY-02', appL10n.payNotFound);
}
return _payment(doc.id, data);
}
/// 환불 요청 — 명세 `554:24008` *결제 완료 → 환불 요청으로 update*.
///
/// 보안 규칙이 **지급 완료 → 환불 요청** 한 전이와 이 세 필드만 허용한다. 승인 · 반려 ·
/// 회수는 Admin 과 `fn-iap-refund-webhook` 이 한다. POP 은 여기서 건드리지 않는다.
@override
Future<void> requestRefund(String paymentId, String reason) async {
final uid = _backend.requireUid;
await _backend.db.doc('users/$uid/purchases/$paymentId').update({
'state': 'refund-requested',
'refundReason': reason,
'refundRequestedAt': FieldValue.serverTimestamp(),
});
}
/// Focus 구독 — `users/{uid}/subscription/current`(`fn-iap-verify` · 구독 웹훅이 쓴다).
/// 만료가 지났거나 해지·환불로 혜택이 꺼졌으면 null.
@override
Future<FocusStatus?> focus() async {
final uid = _backend.requireUid;
final data =
(await _backend.db.doc('users/$uid/subscription/current').get()).data();
if (data == null) return null;
final expiresAt = data.dateOr('expiresAt', _epoch);
final state = data.strOr('state');
if (!expiresAt.isAfter(DateTime.now()) ||
(state != 'active' && state != 'canceled')) {
return null;
}
return FocusStatus(
expiresAt: expiresAt,
// 서버와 같은 일 경계(KST). `fn-focus-daily-claim` 이 받은 날을 여기 적는다.
claimedToday: data.strOr('focusClaimedDay') == kstDayKey(),
);
}
@override
Future<int> claimFocusDaily() async =>
(await _backend.call('fnFocusDailyClaim')).intOr('granted');
PaymentEntry _payment(String id, Map<String, dynamic> data) => PaymentEntry(
id: id,
at: data.dateOr('purchasedAt', _epoch),
itemName: data.strOr('itemName', data.strOr('productId', appL10n.payItemFallback)),
price: data.intOr('priceAmount'),
method: data.strOr('method', appL10n.payMethodInApp),
state: paymentStateOf(data.strOr('state')),
);
/// 공지 — S-M-NOTICE · Figma `357:224659` 「공지사항」.
///
/// 필드와 정렬은 `firestore.indexes.json` 의 `notices` 복합 색인이 정해 둔
/// `active` + `publishedAt DESC` 를 그대로 따른다. 다른 조합으로 읽으면 색인이
/// 없어 실 배포에서 `failed-precondition` 으로 떨어진다 — 에뮬레이터는 색인을
/// 강제하지 않으므로(`firebase/test/README.md` 「복합 인덱스 검증 한계 실측」)
/// 여기서 어긋나도 테스트로는 잡히지 않는다.
@override
Future<List<NoticeItem>> notices() async {
final snapshot = await _backend.db
.collection('notices')
.where('active', isEqualTo: true)
.orderBy('publishedAt', descending: true)
.limit(50)
.get();
return [
for (final doc in snapshot.docs)
NoticeItem(
id: doc.id,
title: doc.data().strOr('title', appL10n.noticeBadge),
body: doc.data().strOr('body'),
at: doc.data().dateOr('publishedAt', _epoch),
),
];
}
@override
Future<List<BlockedUser>> blockedUsers() async {
final uid = _backend.requireUid;
final snapshot = await _backend.db.collection('users/$uid/blocks').get();
final list = [
for (final doc in snapshot.docs)
BlockedUser(
uid: doc.id,
// UID 를 이름 자리에 내보내지 않는다(G-07).
nickname: publicName(doc.data()['nickname'], appL10n.unknownUser),
at: doc.data().dateOr('createdAt', _epoch),
),
];
// 최근에 차단한 계정이 위로 온다. 규칙이 차단 문서 목록에 정렬 제약을 두지
// 않으므로 색인 없이 여기서 세운다(목록이 사용자당 수십 건 규모다).
list.sort((a, b) => (b.at ?? _epoch).compareTo(a.at ?? _epoch));
return list;
}
/// 차단 추가. 재화가 아니라 개인 설정이므로 규칙이 본인 직접 쓰기를 허용한다
/// (`firestore.rules` — `users/{uid}/blocks/{targetUid}` 는 `isSelf(uid)`).
@override
Future<void> block(String targetUid, {String? nickname}) async {
final uid = _backend.requireUid;
if (targetUid == uid) {
throw BackendException('E-PERM-01', appL10n.blockSelf);
}
await _backend.db.doc('users/$uid/blocks/$targetUid').set({
if (nickname != null && nickname.isNotEmpty) 'nickname': nickname,
'createdAt': FieldValue.serverTimestamp(),
}, SetOptions(merge: true));
}
@override
Future<void> unblock(String targetUid) async {
final uid = _backend.requireUid;
await _backend.db.doc('users/$uid/blocks/$targetUid').delete();
}
/// 1:1 문의 접수 — Figma `373:635052` 「새 문의 작성」.
///
/// `status` 를 `pending` 으로 고정해 보내는 것은 규칙이 그렇게만 생성을 허용하기
/// 때문이다(`allow create: if authorIsSelf() && status == 'pending'`).
/// 상태를 올리는 것은 운영이다.
@override
Future<void> submitInquiry(String title, String body) async {
final uid = _backend.requireUid;
await _backend.db.collection('inquiries').add({
'authorUid': uid,
'title': title,
'body': body,
'status': 'pending',
'createdAt': FieldValue.serverTimestamp(),
});
}
/// 문의 수정. 규칙이 접수 상태의 제목·본문만 허용하므로 그 두 개와 갱신 시각만
/// 보낸다. `status` 를 함께 보내면 규칙이 요청 전체를 거부한다.
@override
Future<void> updateInquiry(String id, String title, String body) async {
await _backend.db.doc('inquiries/$id').update({
'title': title,
'body': body,
'updatedAt': FieldValue.serverTimestamp(),
});
}
@override
Future<List<InquiryItem>> myInquiries() async {
final uid = _backend.requireUid;
// `authorUid` 조건이 규칙의 목록 허용 조건과 같아야 질의 자체가 통과한다.
final snapshot = await _backend.db
.collection('inquiries')
.where('authorUid', isEqualTo: uid)
.orderBy('createdAt', descending: true)
.limit(50)
.get();
return [
for (final doc in snapshot.docs)
InquiryItem(
id: doc.id,
title: doc.data().strOr('title', appL10n.inquiryFallback),
body: doc.data().strOr('body'),
at: doc.data().dateOr('createdAt', _epoch),
status: InquiryStatus.parse(doc.data().strOr('status')),
answerBody: doc.data().strOr('answerBody').isEmpty
? null
: doc.data().strOr('answerBody'),
answeredAt: doc.data().containsKey('answeredAt')
? doc.data().dateOr('answeredAt', _epoch)
: null,
),
];
}
/// 프로필 사진 업로드 — Storage 에 올리고 URL 을 사용자 문서에 남긴다.
///
/// 경로를 사용자당 한 장으로 고정한 이유는 고아 파일이다. 시각을 붙이면 바꿀
/// 때마다 이전 파일이 남고 지우는 경로가 따로 필요해진다. 덮어쓰면 다운로드
/// URL 의 토큰이 바뀌므로 캐시도 함께 끊긴다.
///
/// `photoUrl` 은 규칙의 본인 수정 허용 목록에 이미 들어 있는 필드다.
@override
Future<String> uploadProfilePhoto(PickedPhoto photo) async {
checkProfilePhoto(photo);
final uid = _backend.requireUid;
final ref = _backend.storage.ref('users/$uid/profile/photo');
try {
await ref.putData(
photo.bytes,
SettableMetadata(contentType: photo.contentType),
);
} on FirebaseException catch (error) {
// Storage 규칙이 용량·형식을 서버에서 한 번 더 본다(B-44). 거부를 그대로
// 삼키면 화면이 성공으로 넘어가므로 사유가 있는 실패로 바꿔 올린다.
throw PhotoRejected(
error.code == 'unauthorized'
? appL10n.photoRejectedServer
: appL10n.photoUploadFailed,
);
}
final url = await ref.getDownloadURL();
await _backend.db.doc('users/$uid').set(
{'photoUrl': url},
SetOptions(merge: true),
);
return url;
}
}
/// 원장 행 하나와 그 사유 코드·횟수. 접기 판정에만 쓰고 화면으로 나가지 않는다.
class _LedgerRow {
const _LedgerRow({
required this.reasonCode,
required this.entry,
this.count = 1,
});
final String reasonCode;
final PopLedgerEntry entry;
/// 이 행이 몇 번분인가. 원장의 `count` 값이고, 없으면 1 이다.
final int count;
}
/// 최신순으로 붙어 있는 같은 날 Zap 행을 한 줄로 접는다.
///
/// 사이에 다른 거래가 끼면 그룹이 끊긴다. 그래야 접힌 줄의 「누적 POP」이 그 시점의
/// 실제 잔액과 같다. 되돌아가 다시 묶으면 누적 값이 거짓이 된다.
///
/// 접는 판정과 합치는 규칙은 [PopLedgerEntry.foldsWith] · [PopLedgerEntry.fold]
/// 한 벌을 쓴다. 한 장 안에서 못 붙는 경계는 화면이 같은 규칙으로 이어 붙인다
/// (당일 Zap 이 한 장 크기를 넘으면 장이 갈린다).
///
/// 횟수는 원장 행의 `count` 를 합산한다. `fn-eclipse-defend` 가 `count` 를 묶어
/// 받으므로 행 하나가 여러 번분일 수 있다. **필드가 없는 행은 한 번으로 센다** —
/// 지금 배포된 함수가 `count` 를 남기지 않아 예전 행에는 값이 없고, 그 경우
/// 실제보다 작게 나올 수 있다. 함수 쪽에서 `count` 를 남기기 시작하면 이 코드는
/// 고치지 않고 값이 정확해진다.
List<PopLedgerEntry> _foldZap(List<_LedgerRow> rows) {
final folded = <PopLedgerEntry>[];
for (final row in rows) {
final entry = row.reasonCode == 'ECLIPSE_DEFENSE'
? _withRepeat(row.entry, row.count)
: row.entry;
final last = folded.isEmpty ? null : folded.last;
if (last != null && last.foldsWith(entry)) {
folded[folded.length - 1] = last.fold(entry);
continue;
}
folded.add(entry);
}
return folded;
}
PopLedgerEntry _withRepeat(PopLedgerEntry entry, int count) => PopLedgerEntry(
at: entry.at,
title: entry.title,
type: entry.type,
kind: entry.kind,
txType: entry.txType,
delta: entry.delta,
balanceAfter: entry.balanceAfter,
repeatCount: count,
cursor: entry.cursor,
);
/// 서버가 시각을 아직 채우지 않은 문서의 기본값. `DateTime.now()` 를 쓰면
/// 목록을 새로 고칠 때마다 순서가 바뀐다.
final DateTime _epoch = DateTime.fromMillisecondsSinceEpoch(0);
+527 -8
View File
@@ -1,15 +1,534 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/ui/tab_placeholder.dart'; import '../../core/data/backend.dart';
import '../../core/format.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/push/push_messaging.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart';
import '../auth/auth.dart';
import '../auth/auth_repository.dart';
import '../campaign/campaign_data.dart' show CampaignRepository;
import 'my_data.dart';
import 'my_lists_screen.dart';
import 'profile_edit_screen.dart';
import 'settings_screen.dart';
import 'tier_screens.dart';
import 'widgets/my_cards.dart';
import 'widgets/profile_card.dart';
/// MY 탭 루트 — S-M-HOME 마이 페이지. /// MY 탭 루트 — 화면 대조 원장 F6 · Figma `373:632962`.
class MyScreen extends StatelessWidget { ///
const MyScreen({super.key}); /// 명세 `549:22058`(프로필 카드 오버레이) — *프로필 사진 · 닉네임 · 티어 · 인포메이션 ·*
/// *누적 기여 그래프 · 누적 걸음 · 누적 기여 POP · 완료 퀘스트 건수 · 공유 4종*
class MyScreen extends StatefulWidget {
const MyScreen({
super.key,
required this.repository,
this.linker,
this.push,
this.auth,
this.campaigns,
});
final MyRepository repository;
/// 「내 캠페인」이 읽는 저장소. null 이면 표본.
final CampaignRepository? campaigns;
/// 설정 > SNS 연동으로 그대로 내려간다. null 이면 설정 화면이 표본을 쓴다.
final AccountLinker? linker;
/// 설정 > 푸시 알림 설정으로 내려간다. 목 모드에서는 null 이고, 그때 설정 화면은
/// 권한 재요청 버튼을 그리지 않는다(요청할 플러그인이 없다).
final PushMessaging? push;
/// 설정 > 로그아웃 배선. null 이면 확인 모달만 띄운다.
final AuthRepository? auth;
@override @override
Widget build(BuildContext context) => const TabPlaceholder( State<MyScreen> createState() => _MyScreenState();
tab: 'MY', }
screenId: 'S-M-HOME',
screenName: '마이 페이지', class _MyScreenState extends State<MyScreen> {
MyProfile? _profile;
FocusStatus? _focus;
bool _claiming = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final profile = await widget.repository.profile();
// 구독 조회 실패로 MY 전체가 멈추지 않게 한다 — 카드만 빠진다.
final focus = await widget.repository.focus().catchError((_) => null);
if (mounted) {
setState(() {
_profile = profile;
_focus = focus;
});
}
}
/// Focus 오늘 몫 받기(원장 B-04). 지급은 서버가 하고 화면은 결과만 알린다.
Future<void> _claimFocus() async {
if (_claiming) return;
setState(() => _claiming = true);
String message;
try {
final granted = await widget.repository.claimFocusDaily();
message = context.l10n.focusClaimed(formatPop(granted));
} on BackendException catch (error) {
message = error.message;
}
if (!mounted) return;
setState(() => _claiming = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(behavior: SnackBarBehavior.floating, content: Text(message)),
);
await _load();
}
@override
Widget build(BuildContext context) {
final profile = _profile;
if (profile == null) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return DefaultTabController(
length: 2,
child: Scaffold(
body: SafeArea(
child: RefreshIndicator(
onRefresh: _load,
child: NestedScrollView(
headerSliverBuilder: (context, _) => [
SliverToBoxAdapter(
child: Column(
children: [
// Top Navigation — 높이 54 · 안쪽 6/16 · 「MY」 ABeeZee 20 · 아이콘 칸 42.
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 6,
),
child: Row(
children: [
const Text(
'MY',
style: TextStyle(
fontFamily: AppFonts.display,
fontSize: 20,
height: 1.4,
color: AppColors.onCosmic,
),
),
const Spacer(),
_NavIcon(
tooltip: context.l10n.myProfileCardTooltip,
icon: AppIcons.share,
// 사본 `lucide/share-2` 는 20 칸(설정 24 와 다르다).
size: 20,
onTap: () => _showCard(profile),
),
_NavIcon(
tooltip: context.l10n.settingsTitle,
icon: AppIcons.settings,
onTap: () => _push(
SettingsScreen(
repository: widget.repository,
linker: widget.linker,
push: widget.push,
auth: widget.auth,
),
),
),
],
),
),
_MyHeader(
profile: profile,
onCard: () => _showCard(profile),
onPhoto: () => _changePhoto(profile),
onEdit: () async {
await _push(
ProfileEditScreen(
repository: widget.repository,
nickname: profile.nickname,
),
);
await _load();
},
),
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
MyTierCard(
profile: profile,
onInfo: () => _push(const TierInfoScreen()),
onDetail: () => _push(
TierDetailScreen(tiers: profile.planetTiers),
),
),
const SizedBox(height: 8),
MyStatsCard(profile: profile),
if (_focus case final focus?) ...[
const SizedBox(height: 8),
_FocusCard(
focus: focus,
busy: _claiming,
onClaim: _claimFocus,
),
],
],
),
),
const SizedBox(height: 16),
],
),
),
SliverToBoxAdapter(
child: MyTabBar(
tabs: [
context.l10n.myTabPlanets,
context.l10n.myTabActivity,
],
),
),
],
body: TabBarView(
children: [
ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
children: [
Row(
children: [
for (final (i, img) in const [
'assets/figma/my-planet-1.png',
'assets/figma/my-planet-2.png',
'assets/figma/my-planet-3.png',
].indexed) ...[
if (i > 0) const SizedBox(width: 8),
Expanded(
child: AspectRatio(
aspectRatio: 1,
child: Container(
// 칸 109 안에 행성 88 — 안쪽 10.5.
padding: const EdgeInsets.all(10.5),
decoration: BoxDecoration(
color: const Color(0xFF2C2C2C),
borderRadius: BorderRadius.circular(
AppRadius.md,
),
),
child: Image.asset(img, fit: BoxFit.contain),
),
),
),
],
],
),
],
),
ListView(
padding: const EdgeInsets.all(16),
children: [
// 시안 `373:633440` — 카드 하나에 「인사이드」 · 「캠페인」 두 줄.
Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: MyColors.card,
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: Column(
children: [
_Row(
label: context.l10n.myInside,
onTap: () => _push(
const MyListsScreen(kind: MyListKind.inside),
),
),
_Row(
label: context.l10n.myCampaign,
onTap: () => _push(
MyListsScreen(
kind: MyListKind.campaign,
campaigns: widget.campaigns,
),
),
),
],
),
),
],
),
],
),
),
),
),
),
);
}
/// 카메라 배지 → 프로필 수정(시안 `374:15479`). 고른 사진을 바로 올린다.
Future<void> _changePhoto(MyProfile profile) async {
final photo = await Navigator.of(context, rootNavigator: true)
.push<PickedPhoto>(
MaterialPageRoute(
builder: (_) =>
ProfilePhotoScreen(current: _MyHeader._avatarOf(profile)),
),
);
if (photo == null || !mounted) return;
try {
await widget.repository.uploadProfilePhoto(photo);
} on PhotoRejected catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(error.message)));
return;
}
await _load();
}
Future<void> _push(Widget screen) => Navigator.of(
context,
rootNavigator: true,
).push(MaterialPageRoute<void>(builder: (_) => screen));
/// 프로필 카드 오버레이 — 시안 `374:15738` · 명세 `549:22058`. 공유 4종이 붙는다.
Future<void> _showCard(MyProfile profile) => showDialog<void>(
context: context,
// 시안 `374:15738` — 바탕 #101010 · 좌우 20 · 모서리 24.
builder: (context) => Dialog(
backgroundColor: const Color(0xFF101010),
insetPadding: const EdgeInsets.symmetric(horizontal: 20),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
child: ProfileCard(
profile: profile,
onShare: (target) {
Navigator.of(context).pop();
ScaffoldMessenger.of(this.context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(context.l10n.myShareLater(target)),
behavior: SnackBarBehavior.floating,
),
);
},
),
),
);
}
/// Top Navigation 아이콘 칸 42×42 · 아이콘 24.
class _NavIcon extends StatelessWidget {
const _NavIcon({
required this.tooltip,
required this.icon,
required this.onTap,
this.size = 24,
});
final String tooltip;
final ImageProvider icon;
final VoidCallback onTap;
final double size;
@override
Widget build(BuildContext context) => IconButton(
tooltip: tooltip,
onPressed: onTap,
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
constraints: const BoxConstraints.tightFor(width: 42, height: 42),
icon: ImageIcon(icon, size: size, color: AppColors.onCosmic),
);
}
/// 상단 프로필 — 시안 MY: 지름 80 원형 사진 + 카메라 배지 30 · 닉네임 20/600 + 연필 14.
class _MyHeader extends StatelessWidget {
const _MyHeader({
required this.profile,
required this.onEdit,
required this.onCard,
required this.onPhoto,
});
final MyProfile profile;
final VoidCallback onEdit;
/// 카메라 배지 → 프로필 사진 수정.
final VoidCallback onPhoto;
/// 사진을 누르면 프로필 카드 오버레이(시안 `374:15738`)가 뜬다.
final VoidCallback onCard;
/// 올린 사진이 있으면 그것을, 없으면 시안 표본을 그린다.
static ImageProvider<Object> _avatarOf(MyProfile profile) {
final url = profile.photoUrl;
return url == null || url.isEmpty
? const AssetImage('assets/figma/avatar-sample.png')
: NetworkImage(url);
}
@override
Widget build(BuildContext context) => Column(
children: [
GestureDetector(
onTap: onCard,
child: SizedBox(
width: 80.5,
height: 80,
child: Stack(
children: [
CircleAvatar(
radius: 40,
backgroundColor: AppColors.cosmicSurfaceRaised,
backgroundImage: _avatarOf(profile),
),
Positioned(
right: 0,
bottom: 0,
child: Semantics(
button: true,
label: context.l10n.myEditPhoto,
child: GestureDetector(
onTap: onPhoto,
child: Image.asset(
'assets/figma/my-camera-badge.png',
width: 30,
height: 30,
),
),
),
),
],
),
),
),
const SizedBox(height: 20),
GestureDetector(
onTap: onEdit,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(profile.nickname, style: myText(20, FontWeight.w600)),
const SizedBox(width: 8),
Semantics(
button: true,
label: context.l10n.myEditProfile,
child: const ImageIcon(
AssetImage('assets/figma/icon-pencil.png'),
size: 14,
color: AppColors.onCosmic,
),
),
],
),
),
],
);
}
/// 활동 탭의 한 줄 — 글자 16/600 · 끝 chevron. 줄 높이 24 · 간격 24.
/// Focus 구독 혜택 카드 — 원장 B-04 *일 40 기여 POP · 당일 Claim · 미수령 누적 없음*.
///
/// Figma 에는 이 Claim 자리가 없다(퀘스트 완료 화면의 「광고 제거 보상 · 퀘스트 보상 × 2」
/// `518:88748` 은 B-04 가 대체한 과거 안). 반영 화면이 `MY` 로 정해져 있어 통계 카드 아래에 둔다.
/// 구독 중일 때만 보인다.
class _FocusCard extends StatelessWidget {
const _FocusCard({required this.focus, required this.busy, required this.onClaim});
final FocusStatus focus;
final bool busy;
final VoidCallback onClaim;
@override
Widget build(BuildContext context) {
final e = focus.expiresAt;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: MyColors.card,
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.focusActive,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.onCosmic,
),
),
const SizedBox(height: 4),
Text(
context.l10n.focusUntil(
'${e.year}.${e.month.toString().padLeft(2, '0')}.${e.day.toString().padLeft(2, '0')}',
),
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 12,
color: AppColors.onCosmicMuted,
),
),
],
),
),
const SizedBox(width: 8),
FilledButton(
onPressed: focus.claimedToday || busy ? null : onClaim,
style: FilledButton.styleFrom(
backgroundColor: AppColors.cosmicAccent,
foregroundColor: AppColors.onCosmicAccent,
),
child: Text(
focus.claimedToday
? context.l10n.focusClaimedToday
: context.l10n.focusClaimToday,
),
),
],
),
);
}
}
class _Row extends StatelessWidget {
const _Row({required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Expanded(child: Text(label, style: myText(16, FontWeight.w600))),
const MyChevron(),
],
),
),
); );
} }
+129
View File
@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import 'my_data.dart';
import 'widgets/my_cards.dart';
/// 「2026.04.30」 — 시안 공지 날짜 표기.
String _date(DateTime at) =>
'${at.year}.${at.month.toString().padLeft(2, '0')}.${at.day.toString().padLeft(2, '0')}';
/// 머리 세 줄 — 「공지」 13/500 분홍 · 제목 15/600 · 날짜 12/600 #777(간격 8 · 12).
class _NoticeHead extends StatelessWidget {
const _NoticeHead({required this.notice});
final NoticeItem notice;
static TextStyle _s(double size, FontWeight w, Color c) => TextStyle(
fontFamily: AppFonts.body,
fontSize: size,
fontWeight: w,
height: 1,
color: c,
);
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.noticeBadge,
style: _s(13, FontWeight.w500, AppColors.cosmicAccent),
),
const SizedBox(height: 8),
Text(notice.title, style: _s(15, FontWeight.w600, AppColors.onCosmic)),
const SizedBox(height: 12),
Text(_date(notice.at), style: _s(12, FontWeight.w600, MyColors.muted)),
],
);
}
/// 공지사항 — 화면 대조 원장 F6 · Figma `357:224574`: 카드 #19191c · 모서리 16 · 안쪽 20/16 · 간격 12.
class NoticeListScreen extends StatefulWidget {
const NoticeListScreen({super.key, required this.repository});
final MyRepository repository;
@override
State<NoticeListScreen> createState() => _NoticeListScreenState();
}
class _NoticeListScreenState extends State<NoticeListScreen> {
List<NoticeItem>? _notices;
@override
void initState() {
super.initState();
widget.repository.notices().then((n) {
if (mounted) setState(() => _notices = n);
});
}
@override
Widget build(BuildContext context) {
final notices = _notices;
if (notices == null) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
appBar: AppBar(title: Text(context.l10n.noticeTitle)),
body: notices.isEmpty
? Center(
child: Text(
context.l10n.noticeEmpty,
style: myText(14, FontWeight.w500),
),
)
: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: notices.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, i) {
final n = notices[i];
return GestureDetector(
onTap: () => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => NoticeDetailScreen(notice: n),
),
),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 20,
),
decoration: BoxDecoration(
color: MyColors.card,
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: _NoticeHead(notice: n),
),
);
},
),
);
}
}
/// 공지사항 상세 — Figma `357:224775`: 머리 · 구분선 #313131 · 본문 14/400 행간 140%.
class NoticeDetailScreen extends StatelessWidget {
const NoticeDetailScreen({super.key, required this.notice});
final NoticeItem notice;
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: Text(context.l10n.noticeDetailTitle)),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_NoticeHead(notice: notice),
const SizedBox(height: 12),
const SizedBox(height: 1, child: ColoredBox(color: Color(0xFF313131))),
const SizedBox(height: 18),
Text(notice.body, style: myText(14, FontWeight.w400)),
],
),
);
}
+468
View File
@@ -0,0 +1,468 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_radio.dart';
import '../../core/monetization/store.dart';
import '../../core/ui/app_icons.dart';
import 'my_data.dart';
import 'widgets/my_cards.dart';
import '../../core/i18n/app_locale.dart';
/// 서비스 결제 내역 — 화면 대조 원장 F6 · Figma `374:15185`.
///
/// 명세 `554:23841` — *결제 일시 · 결제 아이템명 · 결제 아이템 가격 · 최신순 소팅*
class PaymentHistoryScreen extends StatefulWidget {
const PaymentHistoryScreen({super.key, required this.repository});
final MyRepository repository;
@override
State<PaymentHistoryScreen> createState() => _PaymentHistoryScreenState();
}
class _PaymentHistoryScreenState extends State<PaymentHistoryScreen> {
List<PaymentEntry>? _payments;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final payments = await widget.repository.payments();
if (mounted) setState(() => _payments = payments);
}
Future<void> _restore() async {
try {
await Store.instance?.restore();
} on Exception {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
behavior: SnackBarBehavior.floating,
content: Text(context.l10n.storeConnectFailed),
));
return;
}
await _load();
}
@override
Widget build(BuildContext context) {
final payments = _payments;
if (payments == null) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.settingsServicePayments),
actions: [
// 구매 복원 — 재설치 · 기기 변경 뒤 Focus 구독을 되살린다. 스토어가 돌려준
// 구매는 서버가 거래 ID 로 멱등 검증하므로 두 번 지급되지 않는다.
if (Store.instance != null)
TextButton(onPressed: _restore, child: Text(context.l10n.payRestore)),
],
),
body: Column(
children: [
Expanded(
child: payments.isEmpty
? Center(
child: Text(
context.l10n.payEmpty,
style: myText(14, FontWeight.w500),
),
)
: ListView.builder(
itemCount: payments.length,
itemBuilder: (context, i) {
final p = payments[i];
// 시안 `374:15185` — 줄 높이 58 · 안쪽 20/16 · 날짜 14/500 #777 · 상품 16/600 · 금액 18/800.
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () async {
await Navigator.of(
context,
rootNavigator: true,
).push<void>(
MaterialPageRoute(
builder: (_) => PaymentDetailScreen(
paymentId: p.id,
repository: widget.repository,
),
),
);
await _load();
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 20,
),
child: Row(
children: [
Text(
'${p.at.month.toString().padLeft(2, '0')}.${p.at.day.toString().padLeft(2, '0')}',
style: myText(
14,
FontWeight.w500,
color: MyColors.muted,
height: 1,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
// 결제 완료가 아니면 상태를 붙인다(명세 `554:23961` 다섯 상태).
p.state == PaymentState.paid
? p.itemName
: '${p.itemName} · ${p.state.label}',
overflow: TextOverflow.ellipsis,
style: myText(16, FontWeight.w600, height: 1),
),
),
Text('₩ ${_won(p.price)}', style: _amount),
],
),
),
);
},
),
),
],
),
);
}
}
/// 금액 18/800 — 시안 결제 내역 · 상세.
const _amount = TextStyle(
fontFamily: AppFonts.body,
fontSize: 18,
fontWeight: FontWeight.w800,
height: 1,
color: AppColors.onCosmic,
);
/// 결제 내역 상세 — Figma `506:14308`.
///
/// 명세 `554:23961` — *결제 아이템명 · 결제 번호 · 일시 · 방식 · 상태 · 금액*.
/// 상태는 다섯이며 **결제 완료일 때만 환불을 신청**할 수 있다.
class PaymentDetailScreen extends StatefulWidget {
const PaymentDetailScreen({
super.key,
required this.paymentId,
required this.repository,
});
final String paymentId;
final MyRepository repository;
/// 시안 `506:14308` 「환불 안내」 문구 그대로.
static String get refundGuide => appL10n.payRefundGuide;
@override
State<PaymentDetailScreen> createState() => _PaymentDetailScreenState();
}
class _PaymentDetailScreenState extends State<PaymentDetailScreen> {
PaymentEntry? _payment;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final payment = await widget.repository.payment(widget.paymentId);
if (mounted) setState(() => _payment = payment);
}
@override
Widget build(BuildContext context) {
final p = _payment;
if (p == null) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
// 시안 `506:14308`(환불 가능) · `571:18672`(환불 불가 — 회색 「환불 불가」).
return Scaffold(
appBar: AppBar(title: Text(context.l10n.payDetailTitle)),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
p.itemName,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 24,
fontWeight: FontWeight.w600,
height: 1,
color: AppColors.onCosmic,
),
),
const SizedBox(height: 24),
const SizedBox(
height: 1,
child: ColoredBox(color: AppColors.onCosmicMuted),
),
const SizedBox(height: 24),
_Row(label: context.l10n.payNumber, value: p.id),
_Row(
label: context.l10n.payDate,
value:
'${p.at.year}.${p.at.month.toString().padLeft(2, '0')}.${p.at.day.toString().padLeft(2, '0')}',
),
_Row(label: context.l10n.payMethod, value: p.method),
_Row(label: context.l10n.payStatus, value: p.state.label),
_Row(label: context.l10n.payAmount, value: '₩ ${_won(p.price)}', amount: true),
const SizedBox(height: 8),
Text(context.l10n.payRefundInfo, style: _guide),
const SizedBox(height: 6),
Text(PaymentDetailScreen.refundGuide, style: _guide),
],
),
bottomNavigationBar: SafeArea(
minimum: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: FilledButton(
onPressed: p.state.canRequestRefund ? () => _requestRefund(p) : null,
style: FilledButton.styleFrom(
backgroundColor: AppColors.cosmicAccent,
foregroundColor: AppColors.onCosmicAccent,
disabledBackgroundColor: const Color(0xFF2C2C2C),
disabledForegroundColor: AppColors.onCosmicMuted,
),
child: Text(
p.state.canRequestRefund
? context.l10n.payRefundApply
: context.l10n.payRefundUnavailable,
),
),
),
);
}
Future<void> _requestRefund(PaymentEntry payment) async {
final reason = await Navigator.of(context, rootNavigator: true)
.push<String>(
MaterialPageRoute(
builder: (_) => RefundRequestScreen(
paymentId: payment.id,
repository: widget.repository,
),
),
);
if (reason == null || !mounted) return;
await _load();
}
}
/// 환불 요청 — Figma `506:14437`.
///
/// 명세 `554:23972` — *요청 디폴트 비활성화, 환불 사유 선택 시 활성화*
/// 명세 `554:23978` — *디폴트 선택 X · 단일 선택*
/// 명세 `554:24008` — *확인 시 결제 완료 → 환불 요청으로 update*
class RefundRequestScreen extends StatefulWidget {
const RefundRequestScreen({
super.key,
required this.paymentId,
required this.repository,
});
final String paymentId;
final MyRepository repository;
/// 환불 사유. Admin 에 등록될 목록의 표본이다.
static List<String> reasonsOf(AppLocalizations l10n) => [
l10n.refundReasonMistake,
l10n.refundReasonExpectation,
l10n.refundReasonError,
l10n.refundReasonDuplicate,
l10n.commonEtc,
];
static List<String> get reasons => reasonsOf(appL10n);
@override
State<RefundRequestScreen> createState() => _RefundRequestScreenState();
}
class _RefundRequestScreenState extends State<RefundRequestScreen> {
String? _reason;
// 시안 `506:14437` — 닫기 · 제목 「환불」 · 오른쪽 「요청」(비활성 #474747) ·
// 머리 띠 #232325 「환불 사유」 · 줄 48(밑줄 #313131) · 라디오 20 · 글자 16/600 #d1d1d2.
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
leading: IconButton(
tooltip: context.l10n.commonClose,
icon: const ImageIcon(AppIcons.x, size: 24),
onPressed: () => Navigator.of(context).pop(),
),
title: Text(context.l10n.refundTitle),
actions: [
GestureDetector(
// 디폴트 비활성화 · 사유 선택 시 활성화.
onTap: _reason == null ? null : _confirm,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Text(
context.l10n.refundSubmit,
style: myText(
14,
FontWeight.w600,
height: 1,
color: _reason == null
? const Color(0xFF474747)
: AppColors.cosmicAccent,
),
),
),
),
],
),
// 디폴트 미선택 · 단수 선택.
body: ListView(
children: [
Container(
height: 32,
color: const Color(0xFF232325),
padding: const EdgeInsets.symmetric(horizontal: 16),
alignment: Alignment.centerLeft,
child: Text(
context.l10n.refundReason,
style: myText(
12,
FontWeight.w600,
color: const Color(0xFF909091),
height: 1,
),
),
),
for (final reason in RefundRequestScreen.reasons)
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _reason = reason),
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 == reason),
const SizedBox(width: 8),
Text(
reason,
style: myText(
16,
FontWeight.w600,
color: const Color(0xFFD1D1D2),
height: 1,
),
),
],
),
),
),
],
),
);
/// 확인 — 시안 `506:14615` iOS 경고창 「정말 환불 신청을 할까요?」.
Future<void> _confirm() async {
final ok = await showCupertinoDialog<bool>(
context: context,
builder: (context) => CupertinoAlertDialog(
title: Text(context.l10n.refundConfirm),
actions: [
CupertinoDialogAction(
onPressed: () => Navigator.of(context).pop(false),
child: Text(context.l10n.commonCancel),
),
CupertinoDialogAction(
onPressed: () => Navigator.of(context).pop(true),
child: Text(context.l10n.commonConfirm),
),
],
),
);
if (ok != true || !mounted) return;
// 서버·Admin 에는 기준 언어(ko) 사유를 남긴다 — 화면 언어에 따라 원장 값이 갈리지 않게.
await widget.repository.requestRefund(
widget.paymentId,
RefundRequestScreen.reasonsOf(
koL10n,
)[RefundRequestScreen.reasons.indexOf(_reason!)],
);
if (mounted) Navigator.of(context).pop(_reason);
}
}
class _Row extends StatelessWidget {
const _Row({required this.label, required this.value, this.amount = false});
final String label;
final String value;
final bool amount;
/// 시안 — 라벨 14/600 #cdcdcd · 값 14/600 · 줄 24 · 간격 16.
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: SizedBox(
height: 24,
child: Row(
children: [
Text(
label,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFFCDCDCD),
),
),
const Spacer(),
Text(
value,
style: amount
? _amount
: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.onCosmic,
),
),
],
),
),
);
}
/// 환불 안내 12/400 #767676 행간 170%.
const _guide = TextStyle(
fontFamily: AppFonts.body,
fontSize: 12,
fontWeight: FontWeight.w400,
height: 1.7,
color: AppColors.onCosmicMuted,
);
/// 원화 표기. POP 과 다른 단위라 함수를 나눈다.
String _won(int value) {
final s = value.toString();
final buffer = StringBuffer();
for (var i = 0; i < s.length; i++) {
if (i > 0 && (s.length - i) % 3 == 0) buffer.write(',');
buffer.write(s[i]);
}
return buffer.toString();
}
+43
View File
@@ -0,0 +1,43 @@
import 'package:image_picker/image_picker.dart';
import 'my_data.dart';
/// 사진을 어디서 가져오는가 — Figma `374:15486` 「앨범 항목」과 카메라 칸.
enum PhotoSource { gallery, camera }
/// 사진 한 장을 고르는 일. 화면이 이 함수를 주입받는다.
///
/// 플러그인을 화면 안에서 직접 부르면 위젯 테스트가 플랫폼 채널을 타서 돌지
/// 않는다. 고르는 일만 밖으로 빼면 화면은 「골라진 사진」만 다루면 된다.
typedef PhotoPicker = Future<PickedPhoto?> Function(PhotoSource source);
/// 기본 구현 — `image_picker`.
///
/// 앨범·카메라 권한 문구는 iOS `Info.plist` 의 `NSPhotoLibraryUsageDescription`·
/// `NSCameraUsageDescription` 에 이미 들어가 있다(BUILD.md 「권한 사용 문구 6종」).
/// 문구가 없으면 권한을 묻는 순간 앱이 종료되므로 여기서 다시 확인해 둔다.
Future<PickedPhoto?> pickPhotoWithPlugin(PhotoSource source) async {
final file = await ImagePicker().pickImage(
source: source == PhotoSource.camera
? ImageSource.camera
: ImageSource.gallery,
);
if (file == null) return null;
final bytes = await file.readAsBytes();
return PickedPhoto(
bytes: bytes,
// 플러그인이 종류를 주지 않는 기기가 있어 확장자로 물러난다.
contentType: file.mimeType ?? _typeOfName(file.name),
name: file.name,
);
}
String _typeOfName(String name) {
final lower = name.toLowerCase();
if (lower.endsWith('.png')) return 'image/png';
if (lower.endsWith('.webp')) return 'image/webp';
if (lower.endsWith('.gif')) return 'image/gif';
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
// 모르는 확장자는 지어내지 않는다. `checkProfilePhoto` 가 형식으로 거른다.
return 'application/octet-stream';
}
+273
View File
@@ -0,0 +1,273 @@
import 'package:flutter/material.dart';
import '../../core/theme/tokens.dart';
import '../home/home_screen.dart' show formatPop;
import 'my_data.dart';
import 'widgets/my_cards.dart';
import '../../core/i18n/app_locale.dart';
/// POP 내역 — 화면 대조 원장 F6 · Figma `374:15090`.
///
/// 명세 `554:23714` — 현재 보유 POP
/// 명세 `554:23732` — *디폴트 최신순으로 20개 노출 · 날짜 · 활동명 · 활동 타입 ·*
/// *증감 POP 값 · 누적 POP 값*. Zap 은 건별이 아니라 **당일 합산 한 줄**이다.
///
/// 한 줄에 일반 POP / 기여 POP 을 함께 적는 것이 정책 원장 **B-01** 과 별첨 1 ②
/// 「무료/유료 POP 원장 분리 유지」를 화면에서 보이는 지점이다.
class PopHistoryScreen extends StatefulWidget {
const PopHistoryScreen({super.key, required this.repository});
final MyRepository repository;
/// 명세가 정한 한 장의 크기.
static const int pageSize = 20;
@override
State<PopHistoryScreen> createState() => _PopHistoryScreenState();
}
class _PopHistoryScreenState extends State<PopHistoryScreen> {
MyProfile? _profile;
final List<PopLedgerEntry> _entries = [];
bool _loadingMore = false;
/// 더 읽을 것이 남았는가.
///
/// **장의 길이로 판정하지 않는다.** Zap 을 당일 한 줄로 접으므로 원장에서 20건을
/// 읽어도 화면 줄이 그보다 적게 나온다. 길이로 끝을 판정하면 접힌 장 뒤에 남은
/// 내역이 조용히 사라진다. 빈 장이 올 때까지 열어 둔다.
bool _hasMore = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final profile = await widget.repository.profile();
final entries = await widget.repository.popHistory(
limit: PopHistoryScreen.pageSize,
);
if (!mounted) return;
setState(() {
_profile = profile;
_entries
..clear()
..addAll(entries);
_hasMore = entries.isNotEmpty;
});
}
/// 다음 장. 커서는 마지막 줄이 들고 있는 [PopLedgerEntry.cursor] 다.
///
/// 표시용 시각을 그대로 쓰면 접힌 Zap 줄의 안쪽 행을 다시 읽어 같은 줄이 두 번 나온다.
///
/// **장 경계를 이어 붙인다.** 저장소는 한 장 안에서만 접으므로, 당일 Zap 이 한 장
/// 크기를 넘으면 「Zap 기여」 줄이 장마다 하나씩 생긴다. 새 장의 첫 줄이 직전 줄과
/// 접히는 짝이면 여기서 합친다. 판정과 합치는 규칙은 저장소와 같은 것을 쓴다.
Future<void> _more() async {
final cursor = _entries.isEmpty ? null : _entries.last.cursor;
if (_loadingMore || !_hasMore || cursor == null) return;
setState(() => _loadingMore = true);
final next = await widget.repository.popHistory(
limit: PopHistoryScreen.pageSize,
after: cursor,
);
if (!mounted) return;
setState(() {
_append(next);
_hasMore = next.isNotEmpty;
_loadingMore = false;
});
}
/// 새 장을 목록 끝에 붙인다. 경계에서 접힐 짝이면 한 줄로 합친다.
void _append(List<PopLedgerEntry> next) {
if (next.isEmpty) return;
if (_entries.isNotEmpty && _entries.last.foldsWith(next.first)) {
_entries[_entries.length - 1] = _entries.last.fold(next.first);
_entries.addAll(next.skip(1));
return;
}
_entries.addAll(next);
}
@override
Widget build(BuildContext context) {
final profile = _profile;
if (profile == null) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
// 시안 `374:15090` — 머리(#141414 · 위아래 60) 아래 #19191c 띠 8, 그 아래 목록(#141414).
return Scaffold(
appBar: AppBar(title: Text(context.l10n.myPop)),
backgroundColor: MyColors.card,
body: ListView.builder(
// 0 은 보유 POP, 마지막 칸은 「더 보기」 자리다.
itemCount:
1 + (_entries.isEmpty ? 1 : _entries.length) + (_hasMore ? 1 : 0),
itemBuilder: (context, i) {
if (i == 0) return _Balance(pop: profile.balancePop);
i -= 1;
if (_entries.isEmpty && i == 0) {
return ColoredBox(
color: AppColors.cosmicVoid,
child: Padding(
padding: const EdgeInsets.all(AppSpace.s6),
child: Center(
child: Text(context.l10n.commonNoHistory, style: myText(14, FontWeight.w500)),
),
),
);
}
if (i >= _entries.length) {
return ColoredBox(
color: AppColors.cosmicVoid,
child: Center(
child: _loadingMore
? const Padding(
padding: EdgeInsets.all(AppSpace.s3),
child: CircularProgressIndicator(),
)
: TextButton(onPressed: _more, child: Text(context.l10n.commonMore)),
),
);
}
return _EntryRow(entry: _entries[i]);
},
),
);
}
}
/// 보유 POP — 번개 20 + 「POP」 18/500 민트 · 값 36/900.
class _Balance extends StatelessWidget {
const _Balance({required this.pop});
final int pop;
@override
Widget build(BuildContext context) => Container(
color: AppColors.cosmicVoid,
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(vertical: 60),
child: Column(
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/figma/icon-pop-small.png',
width: 20,
height: 20.8,
),
const SizedBox(width: 4),
Text(
'POP',
style: myText(18, FontWeight.w500, color: _mint, height: 1),
),
],
),
const SizedBox(height: 16),
SizedBox(
height: 24,
child: OverflowBox(
maxHeight: 48,
child: Text(
formatPop(pop),
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 36,
fontWeight: FontWeight.w900,
height: 1,
color: AppColors.onCosmic,
),
),
),
),
],
),
);
}
/// 민트 #67d3a9 — 활동 타입 · 적립 값.
const _mint = Color(0xFF67D3A9);
/// 한 줄 — 높이 79 · 안쪽 20/16 · 날짜 14/500 #777 · 활동명 16/600 · 타입 12 민트 ·
/// 증감 18/800(적립 민트, 차감 흰색) · 누적 14/500 #777.
///
/// 타입 자리에 거래 종류와 POP 종류(일반/기여)를 함께 적는다(B-01).
class _EntryRow extends StatelessWidget {
const _EntryRow({required this.entry});
final PopLedgerEntry entry;
String get _date =>
'${entry.at.month.toString().padLeft(2, '0')}.${entry.at.day.toString().padLeft(2, '0')}';
@override
Widget build(BuildContext context) {
final up = entry.delta >= 0;
return Container(
color: AppColors.cosmicVoid,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_date,
style: myText(
14,
FontWeight.w500,
color: MyColors.muted,
height: 1,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.displayTitle,
style: myText(16, FontWeight.w600, height: 1),
),
const SizedBox(height: 6),
Text(
entry.displaySubtitle,
style: myText(12, FontWeight.w400, color: _mint),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${up ? '+' : '-'}${formatPop(entry.delta.abs())}',
style: myText(
18,
FontWeight.w800,
color: up ? _mint : AppColors.onCosmic,
height: 1,
).copyWith(letterSpacing: 0),
),
const SizedBox(height: 6),
Text(
formatPop(entry.balanceAfter),
style: myText(
14,
FontWeight.w500,
color: MyColors.muted,
height: 1,
),
),
],
),
],
),
);
}
}
@@ -0,0 +1,343 @@
import 'package:flutter/material.dart';
import '../../core/theme/tokens.dart';
import 'my_data.dart';
import 'photo_picker.dart';
import 'widgets/my_cards.dart';
import '../../core/i18n/app_locale.dart';
/// 닉네임 수정 — 화면 대조 원장 F6 · 시안 `374:16029`(MY 연필에서 진입).
///
/// 명세 `549:22136` — *완료 디폴트 비활성화 · 변경 사항 있을 경우 활성화 ·*
/// ***중복된 닉네임일 경우 닉네임 필드 아래 헬프 텍스트로 오류 메시지 노출***
///
/// 사진은 시안대로 MY 의 카메라 배지 → [ProfilePhotoScreen] 에서 바꾼다.
class ProfileEditScreen extends StatefulWidget {
const ProfileEditScreen({
super.key,
required this.nickname,
required this.repository,
});
final String nickname;
final MyRepository repository;
@override
State<ProfileEditScreen> createState() => _ProfileEditScreenState();
}
class _ProfileEditScreenState extends State<ProfileEditScreen> {
late final _nickname = TextEditingController(text: widget.nickname);
String? _error;
bool _saving = false;
@override
void initState() {
super.initState();
// 「팬」처럼 두 자 미만이면 완료가 잠기므로 예약어는 입력하는 즉시 안내한다.
_nickname.addListener(
() => setState(
() => _error = nicknameReserved(_nickname.text)
? context.l10n.nicknameReserved
: null,
),
);
}
@override
void dispose() {
_nickname.dispose();
super.dispose();
}
/// 닉네임이 바뀌고 **길이 조건까지 맞는가**. 정책 원장 B-52 가 2~20자로
/// 확정했고 검증 기준이 *20자 성공 · 21자 거부* 다. 길이를 화면에서 막지 않으면
/// 완료를 눌러야 규칙이 거부하고, 사용자는 사유 없이 실패만 본다.
bool get _changed {
final value = _nickname.text.trim();
return value != widget.nickname &&
value.characters.length >= nicknameMinLength &&
value.characters.length <= nicknameMaxLength;
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: myTextActionBar(
context,
title: context.l10n.nicknameEdit,
onDone: _changed && !_saving ? _save : null,
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// 시안 — 카드 #19191c · 모서리 16 · 높이 58 · 글자 18/500.
Container(
height: 58,
padding: const EdgeInsets.symmetric(horizontal: 32),
alignment: Alignment.centerLeft,
decoration: BoxDecoration(
color: MyColors.card,
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: TextField(
controller: _nickname,
// 21자째가 아예 입력되지 않는다. 입력은 받아 두고 완료에서 막으면
// 무엇이 문제인지 알기 어렵다.
maxLength: nicknameMaxLength,
style: myText(18, FontWeight.w500, height: 1),
decoration: const InputDecoration(
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
isCollapsed: true,
counterText: '',
),
),
),
// 명세 — 오류는 필드 아래 헬프 텍스트로.
if (_error case final message?)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Text(
message,
style: myText(12, FontWeight.w500, color: AppColors.error),
),
),
],
),
);
Future<void> _save() async {
if (nicknameReserved(_nickname.text.trim())) {
setState(() => _error = context.l10n.nicknameReserved);
return;
}
setState(() => _saving = true);
try {
await widget.repository.updateNickname(_nickname.text.trim());
if (mounted) Navigator.of(context).pop(true);
} on NicknameTakenException {
if (mounted) {
setState(() {
_saving = false;
_error = context.l10n.nicknameTaken;
});
}
}
}
}
/// 「취소 · 제목 · 완료」 머리 — 시안 `374:15479` · `374:16029`: 양쪽 글자 14/600,
/// 완료는 잠겨 있으면 #474747.
PreferredSizeWidget myTextActionBar(
BuildContext context, {
required String title,
required VoidCallback? onDone,
}) => AppBar(
automaticallyImplyLeading: false,
leadingWidth: 72,
leading: _BarText(label: context.l10n.commonCancel, onTap: () => Navigator.of(context).pop()),
title: Text(title),
actions: [_BarText(label: context.l10n.commonDone, onTap: onDone)],
);
class _BarText extends StatelessWidget {
const _BarText({required this.label, required this.onTap});
final String label;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) => Semantics(
button: true,
enabled: onTap != null,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Center(
widthFactor: 1,
child: Text(
label,
style: myText(
14,
FontWeight.w600,
height: 1,
color: onTap == null
? const Color(0xFF474747)
: AppColors.onCosmic,
),
),
),
),
),
);
}
/// 프로필 사진 설정 — Figma `374:15479` · `374:16029` · `374:15486` 「앨범 항목」.
///
/// 명세 `549:22105` — *완료 디폴트 비활성화 · 사진 선택 또는 사진 촬영 후 활성화*
/// 명세 `549:22119` — *노출 영역 원형 고정 · 확대 축소, 드래그 제스처*
/// 명세 `549:22129` — *이미지 최신순으로 소팅*
///
/// 앨범 목록을 앱이 직접 그리지 않고 기기 사진 선택기를 띄운다. 최신순 정렬과
/// 앨범 탐색이 선택기 쪽의 일이라 앱이 사진 전체에 대한 접근을 받아 둘 필요가
/// 없다. **확대·축소·드래그로 잘라내는 편집은 아직 없다** — 자르기는 별도
/// 플러그인이 필요하고 이번 범위에 없다.
class ProfilePhotoScreen extends StatefulWidget {
const ProfilePhotoScreen({
super.key,
this.pickPhoto = pickPhotoWithPlugin,
this.current,
});
final PhotoPicker pickPhoto;
/// 지금 쓰는 사진. 새로 고르기 전까지 미리보기에 그린다.
final ImageProvider? current;
@override
State<ProfilePhotoScreen> createState() => _ProfilePhotoScreenState();
}
class _ProfilePhotoScreenState extends State<ProfilePhotoScreen> {
PickedPhoto? _selected;
String? _error;
@override
Widget build(BuildContext context) {
final selected = _selected;
final preview = selected != null
? MemoryImage(selected.bytes)
: widget.current;
// 시안 `374:15479` — 375 정사각 미리보기(원형 노출 영역) · 「앨범 항목」 줄 56 ·
// 92 칸 격자(간격 2). 첫 칸은 카메라, 둘째 칸은 기기 앨범 선택기다.
return Scaffold(
appBar: myTextActionBar(
context,
title: context.l10n.myEditProfile,
// 고르기 전까지 완료가 잠겨 있다.
onDone: selected == null
? null
: () => Navigator.of(context).pop(selected),
),
body: ListView(
children: [
AspectRatio(
aspectRatio: 1,
child: ColoredBox(
color: const Color(0xFF4D4D4D),
child: ClipOval(
child: preview == null
? const ColoredBox(color: AppColors.cosmicSurfaceRaised)
: Image(image: preview, fit: BoxFit.cover),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
child: Text(context.l10n.albumItems, style: myText(16, FontWeight.w600, height: 1)),
),
// 시안 4×3 격자 — 카메라 · 앨범 선택기 다음 열 칸은 #000 빈 칸(사진 목록은 기기 선택기가 그린다).
Wrap(
spacing: 2,
runSpacing: 2,
children: [
_Cell(
label: context.l10n.takePhoto,
color: const Color(0xFF1F1E1F),
onTap: () => _pick(PhotoSource.camera),
child: const ImageIcon(
AssetImage('assets/figma/icon-camera.png'),
size: 32,
color: AppColors.onCosmic,
),
),
_Cell(
label: context.l10n.pickFromAlbum,
color: AppColors.onCosmic,
onTap: () => _pick(PhotoSource.gallery),
child: preview == null
? const Icon(
Icons.photo_library_outlined,
color: Colors.black,
)
: Padding(
padding: const EdgeInsets.all(6),
child: ClipOval(
child: Image(image: preview, fit: BoxFit.cover),
),
),
),
for (var i = 0; i < 10; i++)
const SizedBox.square(
dimension: 92,
child: ColoredBox(color: Colors.black),
),
],
),
if (_error case final message?)
Padding(
padding: const EdgeInsets.all(16),
child: Text(
message,
style: myText(12, FontWeight.w500, color: AppColors.error),
),
),
],
),
);
}
/// 고른 그 자리에서 1차 검증한다. 업로드까지 가서 거부되면 사용자가 왜
/// 안 되는지 모른 채 기다린다.
Future<void> _pick(PhotoSource source) async {
final photo = await widget.pickPhoto(source);
if (photo == null || !mounted) return;
try {
checkProfilePhoto(photo);
} on PhotoRejected catch (error) {
setState(() {
_selected = null;
_error = error.message;
});
return;
}
setState(() {
_selected = photo;
_error = null;
});
}
}
/// 격자 한 칸 92×92.
class _Cell extends StatelessWidget {
const _Cell({
required this.label,
required this.color,
required this.onTap,
required this.child,
});
final String label;
final Color color;
final VoidCallback onTap;
final Widget child;
@override
Widget build(BuildContext context) => Semantics(
button: true,
label: label,
child: GestureDetector(
onTap: onTap,
child: Container(
width: 92,
height: 92,
color: color,
alignment: Alignment.center,
child: child,
),
),
);
}
@@ -0,0 +1,220 @@
import 'package:flutter/material.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import 'my_data.dart';
import 'widgets/my_cards.dart';
/// 푸시 알림 설정 — 화면 대조 원장 F6 · Figma `357:223444` · 정책 원장 B-77.
///
/// **토글은 5 개다.** 별첨 B 가 「서비스 필수 / 팬덤 이벤트 / AI Briefing /
/// Community / Marketing」 5 개 구분으로 확정했고, Figma `554:24062` 의 6 종
/// (이클립스·좋아요·댓글·퀘스트·캠페인·마케팅)은 그 안으로 들어간다. 공통 기준
/// 2항 — Figma 의 과거 표기가 이후 확정된 정책보다 우선하지 않는다.
///
/// 두 상태를 나눈 Figma 명세는 그대로 지킨다.
/// - `554:24049` — *디바이스 앱 설정의 알림이 **off** 되어 있을 경우 표시*
/// - `554:24062` — *알림이 **ON** 되어 있을 경우 표시*
///
/// **권한 조회는 화면이 하지 않는다.** [deviceAllowed] 로 주입받아 두 갈래를 모두
/// 그린다. 권한을 여기서 읽으면 위젯 테스트가 플러그인을 요구하고, 실제 판정
/// 주체는 `PushMessaging` 하나여야 한다. 거부 상태의 재요청은
/// [onRequestPermission] 이 맡고, 없으면 기기 설정 안내만 띄운다.
class PushSettingsScreen extends StatefulWidget {
const PushSettingsScreen({
super.key,
required this.repository,
this.deviceAllowed = true,
this.onRequestPermission,
this.onSettingsChanged,
});
final MyRepository repository;
/// 기기에서 FANUZU 알림이 켜져 있는가.
final bool deviceAllowed;
/// 권한 재요청. 허용되면 true 를 돌려준다.
final Future<bool> Function()? onRequestPermission;
/// 설정이 바뀐 직후 토픽 구독을 다시 맞추는 콜백.
///
/// **저장만으로는 발송이 멈추지 않는다.** 서버는 토픽으로만 보내므로, 문서에
/// `false` 를 적어도 그 토픽을 구독한 채면 알림이 계속 온다(B-77 검증 기준
/// 「OFF 카테고리 발송 0건」). 그래서 저장과 구독 갱신을 같은 자리에서 한다.
final Future<void> Function(Map<PushTopic, bool> prefs)? onSettingsChanged;
@override
State<PushSettingsScreen> createState() => _PushSettingsScreenState();
}
class _PushSettingsScreenState extends State<PushSettingsScreen> {
Map<PushTopic, bool>? _settings;
bool _allowed = true;
bool _requesting = false;
@override
void initState() {
super.initState();
_allowed = widget.deviceAllowed;
_load();
}
Future<void> _load() async {
final settings = await widget.repository.pushSettings();
if (mounted) setState(() => _settings = settings);
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: Text(context.l10n.settingsPush)),
body: !_allowed
? _blocked()
: _settings == null
? const Center(child: CircularProgressIndicator())
: _toggles(),
);
/// 알림이 꺼져 있을 때 — 시안 `357:223444`. 토글을 그리지 않는다. 앱 안에서 켜도 소용이 없다.
///
/// 「설정하러 가기」는 **앱 안에서 한 번 더 물을 수 있으면** 시스템 다이얼로그를
/// 다시 띄우고([onRequestPermission]), 영구 거부면 기기 설정 안내를 띄운다.
Widget _blocked() => ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
context.l10n.pushDeviceOff,
textAlign: TextAlign.center,
style: myText(20, FontWeight.w400, height: 1.2).copyWith(
fontFamily: AppFonts.display,
fontFamilyFallback: const [AppFonts.body],
letterSpacing: 0,
),
),
const SizedBox(height: 16),
Text(
context.l10n.pushDeviceOffHint,
textAlign: TextAlign.center,
style: myText(
14,
FontWeight.w500,
color: MyColors.muted,
height: 1.2,
).copyWith(letterSpacing: 0),
),
const SizedBox(height: 24),
MyGroup(
children: [
MyLinkRow(
label: _requesting
? context.l10n.pushRequesting
: context.l10n.pushGoToSettings,
onTap: _requesting
? () {}
: widget.onRequestPermission == null
? () => _toast(context.l10n.pushOpeningDeviceSettings)
: _retryPermission,
),
],
),
],
);
Future<void> _retryPermission() async {
setState(() => _requesting = true);
final granted = await widget.onRequestPermission!.call();
if (!mounted) return;
setState(() {
_requesting = false;
_allowed = granted;
});
if (!granted) {
_toast(context.l10n.pushEnableInDevice);
}
}
/// 알림이 켜져 있을 때 — 시안 `357:223611` 의 「기기 알림 설정 · 서비스 알림 설정」 두 구획.
/// 줄은 B-77 확정 5 구분이다.
Widget _toggles() {
final settings = _settings!;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
children: [
MyGroup(
title: context.l10n.pushDeviceSection,
children: [
MyLinkRow(
label: context.l10n.pushSoundVibration,
onTap: () => _toast(context.l10n.pushOpeningDeviceSettings),
),
],
),
MyGroup(
title: context.l10n.pushServiceSection,
children: [
for (final topic in PushTopic.values)
SizedBox(
height: 32,
child: Row(
children: [
Expanded(
child: Text(
_topicLabel(context.l10n, topic),
style: myText(
16,
FontWeight.w600,
height: 1,
color: topic.locked
? MyColors.muted
: AppColors.onCosmic,
),
),
),
Switch(
value: topic.locked
? true
: (settings[topic] ?? topic.defaultOn),
activeThumbColor: AppColors.onCosmic,
activeTrackColor: AppColors.cosmicAccent,
inactiveThumbColor: const Color(0xFF3A3A3A),
inactiveTrackColor: const Color(0xFF2C2C2C),
trackOutlineColor: const WidgetStatePropertyAll(
Colors.transparent,
),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
// 서비스 필수는 끌 수 없다(B-77 「서비스 필수 외 4종 개별 OFF」).
onChanged: topic.locked
? null
: (on) async {
setState(() => settings[topic] = on);
await widget.repository.setPush(topic, on);
// 저장 뒤에 부른다 — 구독을 먼저 바꾸면 저장이 실패했을 때
// 문서와 실제 구독이 갈린다.
await widget.onSettingsChanged?.call(settings);
},
),
],
),
),
],
),
],
);
}
static String _topicLabel(AppLocalizations l10n, PushTopic t) => switch (t) {
PushTopic.essential => l10n.pushTopicEssential,
PushTopic.fandomEvent => l10n.pushTopicFandomEvent,
PushTopic.briefing => l10n.pushTopicBriefing,
PushTopic.community => l10n.pushTopicCommunity,
PushTopic.marketing => l10n.pushTopicMarketing,
};
void _toast(String message) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
);
}
}
+709
View File
@@ -0,0 +1,709 @@
import 'package:flutter/material.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/push/push_messaging.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart';
import '../auth/auth.dart';
import '../auth/auth_repository.dart';
import '../home/home_screen.dart' show formatPop;
import 'my_data.dart';
import 'notice_screens.dart';
import 'payment_screens.dart';
import 'pop_history_screen.dart';
import 'push_settings_screen.dart';
import 'support_screens.dart';
import 'widgets/my_cards.dart';
/// 설정 — 화면 대조 원장 F6 · Figma `354:222008`.
///
/// 명세 `554:22634` — 계정 정보(닉네임 · 가입 이메일)
/// 명세 `554:22797` — SNS 연동. *동일 이메일만 연동 가능*. 행의 「연결」에서 바로 제공자
/// 시트를 띄운다(시안 F6-05~07 · 대표 결정 — 별도 연동 화면을 거치지 않는다).
/// 명세 `554:22831` — 푸시 알림 · 차단 · 언어
/// 명세 `554:22857` — 공지사항 · 1:1 문의접수 · 앱 버전
/// 명세 `554:22903` — 로그아웃 · 서비스 탈퇴는 **안내 모달**을 거친다
class SettingsScreen extends StatefulWidget {
SettingsScreen({
super.key,
required this.repository,
AccountLinker? linker,
this.push,
this.auth,
}) : linker =
linker ??
SampleAccountLinker(
seed: const {
AuthProviderKind.google: 'fan@gmail.com',
AuthProviderKind.kakao: 'fan@kakao.com',
},
);
final MyRepository repository;
/// 푸시 수신 경계. 푸시 알림 설정 화면이 권한 상태와 재요청에 쓴다.
final PushMessaging? push;
/// 로그아웃 배선. null 이면 확인 모달만 띄운다(표본·테스트 경로).
final AuthRepository? auth;
/// 계정 연결 경계 — 별첨 B v1.4 「하나의 UID 에 복수 소셜 Provider」.
final AccountLinker linker;
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
MyProfile? _profile;
Map<AuthProviderKind, String?> _linked = const {};
({String current, bool latest})? _version;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final profile = await widget.repository.profile();
final version = await widget.repository.appVersion();
if (!mounted) return;
setState(() {
_profile = profile;
_linked = widget.linker.linkedAccounts;
_version = version;
});
}
@override
Widget build(BuildContext context) {
final profile = _profile;
if (profile == null) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
final version = _version;
final l10n = context.l10n;
return Scaffold(
appBar: AppBar(title: Text(l10n.settingsTitle)),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
children: [
MyGroup(
title: l10n.settingsAccount,
children: [
_ValueRow(label: l10n.settingsNickname, value: profile.nickname),
const MyDivider(),
_ValueRow(label: l10n.settingsEmail, value: profile.email),
],
),
MyGroup(
title: l10n.settingsSns,
children: [
// 별첨 B v1.4 확정 4종만 건다. X 로그인은 1차 범위에서 빠졌다.
for (final provider in _providerOrder)
_SnsRow(
provider: provider,
email: _linked.containsKey(provider)
? _linked[provider] ?? ''
: null,
onTap: () => _toggleLink(provider),
),
],
),
MyGroup(
title: l10n.settingsPopHistory,
children: [
MyLinkRow(
onTap: () =>
_push(PopHistoryScreen(repository: widget.repository)),
child: Row(
children: [
Image.asset(
'assets/figma/icon-pop-small.png',
width: 20,
height: 20.8,
),
const SizedBox(width: 4),
Text(
formatPop(profile.balancePop),
style: myText(18, FontWeight.w800, height: 1),
),
],
),
),
],
),
MyGroup(
title: l10n.settingsPayments,
children: [
MyLinkRow(
label: l10n.settingsServicePayments,
onTap: () =>
_push(PaymentHistoryScreen(repository: widget.repository)),
),
],
),
MyGroup(
title: l10n.settingsService,
children: [
MyLinkRow(label: l10n.settingsPush, onTap: _openPushSettings),
MyLinkRow(
label: l10n.settingsBlocked,
onTap: () =>
_push(BlockedScreen(repository: widget.repository)),
),
// 시안은 현재 언어를 적고 아래 화살표를 둔다 — 누르면 언어 설정 시트.
MyLinkRow(
label: languageName(_currentLocale),
down: true,
onTap: _pickLanguage,
),
],
),
MyGroup(
title: l10n.settingsInfo,
children: [
MyLinkRow(
label: l10n.settingsNotice,
onTap: () =>
_push(NoticeListScreen(repository: widget.repository)),
),
MyLinkRow(
label: l10n.settingsInquiry,
onTap: () =>
_push(InquiryScreen(repository: widget.repository)),
),
const MyDivider(),
SizedBox(
height: 24,
child: Row(
children: [
Expanded(
child: Text(
l10n.settingsAppVersion,
style: myText(16, FontWeight.w600, height: 1),
),
),
// 명세 — 최신이면 「최신 버전」, 아니면 버전 번호.
Text(
version == null
? '—'
: version.latest
? l10n.settingsLatest
: version.current,
style: myText(
14,
FontWeight.w500,
color: const Color(0xFFCDCDCD),
height: 1,
),
),
],
),
),
],
),
MyGroup(
children: [
MyLinkRow(
label: l10n.settingsLogout,
color: MyColors.danger,
onTap: () => _confirm(
title: l10n.settingsLogoutConfirm,
action: l10n.settingsLogout,
// 푸시 정리를 **먼저** 한다. 보안 규칙이 `users/{uid}` 쓰기를
// `isSelf(uid)` 로 막아, 로그아웃 뒤에는 이 기기 토큰을 지울 수 없다.
onConfirm: () async {
await widget.push?.clearOnSignOut();
await widget.auth?.signOut();
},
),
),
const MyDivider(),
MyLinkRow(
label: l10n.settingsWithdraw,
// 정책 §P-13 이용자 고지 — POP 잔액 소멸. 사본 팝업 틀에 본문 한 줄만 더한다.
onTap: () => _confirm(
title: l10n.settingsWithdrawConfirm,
body: l10n.settingsWithdrawBody,
action: l10n.settingsWithdraw,
withdraw: true,
),
),
],
),
],
),
);
}
/// 시안 `354:222008` 의 목록 순서(Apple · Google · Kakao) 뒤에 원장 1차 로그인 4종의 LINE.
static const _providerOrder = [
AuthProviderKind.apple,
AuthProviderKind.google,
AuthProviderKind.kakao,
AuthProviderKind.line,
];
/// 표시 언어. 목록은 정책 원장 B-76 확정 5종이다([appLocales]).
/// 앱 언어 상태([LocaleScope])가 없는 트리(단독 화면 테스트)에서는 이 화면 안에서만 바뀐다.
Locale? _pickedLocale;
Locale get _currentLocale =>
LocaleScope.maybeOf(context)?.locale ?? _pickedLocale ?? fallbackLocale;
/// 설정 행에서 바로 연결 · 해제한다(시안 F6-05~07 — 설정 위에 OS 제공자 시트가 뜬다).
///
/// 연결은 제공자 시트로 곧장 가고, 해제는 확인 팝업(시안 `554:23257`)을 거친다.
/// 이메일이 다르면(E-AUTH-08 · [sameAccountIdentity]) 시안 `554:23103` 안내 팝업.
Future<void> _toggleLink(AuthProviderKind provider) async {
final connected = _linked.containsKey(provider);
if (connected) {
final l10n = context.l10n;
final ok = await _confirm(
title: l10n.settingsUnlinkConfirm,
body: l10n.settingsUnlinkBody,
action: l10n.settingsUnlink,
);
if (!ok) return;
}
final outcome = connected
? await widget.linker.unlink(provider)
: await widget.linker.link(provider);
if (!mounted) return;
// 화면 상태를 추측하지 않고 연결 경계에 다시 물어본다 — 성공했다고 믿고
// 로컬 값을 뒤집으면 서버가 거절한 연결이 붙은 것처럼 보인다.
setState(() => _linked = widget.linker.linkedAccounts);
switch (outcome) {
case AuthFailed(code: 'E-AUTH-08'):
await _notice(context.l10n.settingsSameEmailOnly);
case AuthFailed(:final code, :final message):
_toast('[$code] $message');
case AuthNotConfigured(:final missing):
_toast(
context.l10n.settingsLinkNotReady(
_snsName(context, provider),
missing.join(' · '),
),
);
case AuthSucceeded() || AuthCancelled():
break;
}
}
void _toast(String message) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
);
}
Future<void> _push(Widget screen) async {
await Navigator.of(
context,
).push<void>(MaterialPageRoute(builder: (_) => screen));
if (mounted) await _load();
}
/// 푸시 알림 설정 진입.
///
/// 권한 상태를 **들어가기 전에** 읽는다. 화면 안에서 읽으면 로딩 한 프레임 동안
/// 토글이 보이고 곧 「꺼져 있습니다」로 바뀌어, 방금 끈 것처럼 보인다.
/// 푸시 경계가 없으면(목 모드) 허용 상태로 두어 토글 동선은 그대로 확인된다.
Future<void> _openPushSettings() async {
final push = widget.push;
final allowed = push == null || (await push.refreshStatus()).isAllowed;
if (!mounted) return;
await _push(
PushSettingsScreen(
repository: widget.repository,
deviceAllowed: allowed,
onRequestPermission: push == null
? null
: () async => (await push.requestPermission()).isAllowed,
// 토글을 바꾸면 그 자리에서 토픽 구독까지 맞춘다. 앱 시작 시 한 번만
// 맞추면 설정을 바꿔도 OFF 구분의 알림이 계속 온다.
onSettingsChanged: push == null ? null : push.syncTopics,
),
);
}
/// 언어 설정 — 명세 `554:22831` *언어 설정 바텀시트 노출* · 시안 `357:224379`.
Future<void> _pickLanguage() async {
final picked = await showModalBottomSheet<Locale>(
context: context,
backgroundColor: const Color(0xFF101010),
builder: (context) => _LanguageSheet(current: _currentLocale),
);
if (picked == null || !mounted) return;
setState(() => _pickedLocale = picked);
await LocaleScope.maybeOf(context)?.select(picked);
}
/// 안내 팝업(확인 하나) — 시안 `554:23103`.
Future<void> _notice(String message) => showDialog<void>(
context: context,
builder: (context) => _MyDialog(
title: message,
actions: [
_DialogButton(
label: context.l10n.commonConfirm,
filled: true,
onTap: () => Navigator.of(context).pop(),
),
],
),
);
/// 확인 팝업(취소 · 실행) — 시안 `554:24669` · `374:14364` · `554:23257`.
Future<bool> _confirm({
required String title,
String? body,
required String action,
bool withdraw = false,
Future<void> Function()? onConfirm,
}) async {
final ok = await showDialog<bool>(
context: context,
builder: (context) => _MyDialog(
title: title,
body: body,
actions: [
_DialogButton(
label: context.l10n.commonCancel,
onTap: () => Navigator.of(context).pop(false),
),
_DialogButton(
label: action,
filled: true,
onTap: () => Navigator.of(context).pop(true),
),
],
),
);
if (ok == true && onConfirm != null) {
await onConfirm();
} else if (ok == true && withdraw && mounted) {
_toast(context.l10n.settingsWithdrawLater);
}
return ok ?? false;
}
}
/// 「닉네임 · BTS_Hong」 — 둘 다 16/600, 값은 #bbbbbb.
class _ValueRow extends StatelessWidget {
const _ValueRow({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) => Row(
children: [
Text(label, style: myText(16, FontWeight.w600, height: 1)),
const SizedBox(width: 16),
Expanded(
child: Text(
value,
textAlign: TextAlign.right,
overflow: TextOverflow.ellipsis,
style: myText(16, FontWeight.w600, color: MyColors.value, height: 1),
),
),
],
);
}
/// 시안 표기 이름. 앱 공통 [AuthProviderKind.shortName] 은 한글(카카오)이라 따로 둔다.
String _snsName(BuildContext context, AuthProviderKind p) => switch (p) {
AuthProviderKind.apple => 'Apple',
AuthProviderKind.google => 'Google',
AuthProviderKind.kakao => 'Kakao',
AuthProviderKind.line => 'LINE',
AuthProviderKind.email => context.l10n.settingsEmailProvider,
};
/// SNS 연동 한 줄 — 아이콘 20 · 이름 14/500 · 오른쪽 45×29 버튼(미연결 분홍 「연결」,
/// 연결 흰 「연결해제」 · 시안 `374:14705`). 연결되어 있으면 아래에 제공자 이메일.
class _SnsRow extends StatelessWidget {
const _SnsRow({
required this.provider,
required this.email,
required this.onTap,
});
final AuthProviderKind provider;
/// null 이면 미연결. 연결됐지만 제공자가 이메일을 안 준 경우는 빈 문자열.
final String? email;
final VoidCallback onTap;
Widget get _icon => switch (provider) {
AuthProviderKind.apple => const ImageIcon(
AssetImage('assets/figma/brand-apple.png'),
size: 20,
color: AppColors.onCosmic,
),
AuthProviderKind.google => Image.asset(
'assets/figma/brand-google.png',
width: 20,
height: 20,
),
AuthProviderKind.kakao => const ImageIcon(
AssetImage('assets/figma/brand-kakao.png'),
size: 20,
color: AppColors.onCosmic,
),
// 시안에 LINE 아이콘이 없어(사본은 X 자리) 브랜드 색 말풍선으로 대신한다.
_ => const Icon(Icons.chat_bubble, size: 18, color: BrandColors.line),
};
@override
Widget build(BuildContext context) {
final connected = email != null;
final action = connected
? context.l10n.settingsDisconnect
: context.l10n.settingsConnect;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 29,
child: Row(
children: [
SizedBox(width: 20, height: 20, child: Center(child: _icon)),
const SizedBox(width: 6),
Expanded(
child: Text(
_snsName(context, provider),
style: myText(14, FontWeight.w500),
),
),
Semantics(
button: true,
label: '${_snsName(context, provider)} $action',
child: GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: connected
? AppColors.onCosmic
: AppColors.cosmicAccent,
borderRadius: BorderRadius.circular(AppRadius.xs),
),
child: Text(
action,
style: myText(
12,
FontWeight.w500,
color: AppColors.onCosmicAccent,
),
),
),
),
),
],
),
),
if (email case final e? when e.isNotEmpty) ...[
const SizedBox(height: 8),
Text(e, style: myText(14, FontWeight.w500)),
],
],
);
}
}
/// 팝업 — 바탕 #2d2d2d · 좌우 31.5 · 안쪽 24/16 · 제목 16/600 가운데 · 버튼 높이 48 간격 8.
class _MyDialog extends StatelessWidget {
const _MyDialog({required this.title, this.body, required this.actions});
final String title;
final String? body;
final List<Widget> actions;
@override
Widget build(BuildContext context) => Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 31.5),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
textAlign: TextAlign.center,
style: myText(16, FontWeight.w600),
),
if (body case final b?) ...[
const SizedBox(height: 16),
Text(
b,
textAlign: TextAlign.center,
style: myText(16, FontWeight.w500),
),
],
const SizedBox(height: 24),
Row(
children: [
for (final (i, a) in actions.indexed) ...[
if (i > 0) const SizedBox(width: 8),
Expanded(child: a),
],
],
),
],
),
),
);
}
class _DialogButton extends StatelessWidget {
const _DialogButton({
required this.label,
required this.onTap,
this.filled = false,
});
final String label;
final VoidCallback onTap;
final bool filled;
@override
Widget build(BuildContext context) {
final shape = RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
);
final text = Text(
label,
style: myText(
16,
FontWeight.w600,
height: 1,
color: filled ? AppColors.onCosmicAccent : AppColors.onCosmic,
),
);
return SizedBox(
height: 48,
child: filled
? FilledButton(
onPressed: onTap,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
padding: EdgeInsets.zero,
backgroundColor: AppColors.cosmicAccent,
shape: shape,
),
child: text,
)
: OutlinedButton(
onPressed: onTap,
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
padding: EdgeInsets.zero,
side: const BorderSide(color: AppColors.onCosmic),
shape: shape,
),
child: text,
),
);
}
}
/// 언어 설정 시트 — 시안 `357:224379`: 제목 + 닫기 · 언어 목록(고른 것에 분홍 체크) · 「선택」.
class _LanguageSheet extends StatefulWidget {
const _LanguageSheet({required this.current});
final Locale current;
@override
State<_LanguageSheet> createState() => _LanguageSheetState();
}
class _LanguageSheetState extends State<_LanguageSheet> {
late Locale _picked = widget.current;
@override
Widget build(BuildContext context) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 36,
height: 5,
decoration: BoxDecoration(
color: AppColors.cosmicOutline,
borderRadius: BorderRadius.circular(AppRadius.full),
),
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: Text(
context.l10n.languageSettingsTitle,
style: myText(18, FontWeight.w600),
),
),
IconButton(
tooltip: context.l10n.commonClose,
onPressed: () => Navigator.of(context).pop(),
icon: const ImageIcon(
AppIcons.x,
size: 24,
color: AppColors.onCosmic,
),
),
],
),
const SizedBox(height: 8),
for (final lang in appLocales)
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _picked = lang),
child: SizedBox(
height: 50,
child: Row(
children: [
Expanded(
child: Text(
languageName(lang),
style: myText(16, FontWeight.w500),
),
),
if (lang == _picked)
const Icon(
Icons.check_circle_outline,
size: 22,
color: AppColors.cosmicAccent,
),
],
),
),
),
const SizedBox(height: 24),
FilledButton(
onPressed: () => Navigator.of(context).pop(_picked),
style: FilledButton.styleFrom(
backgroundColor: AppColors.cosmicAccent,
foregroundColor: AppColors.onCosmicAccent,
),
child: Text(context.l10n.commonSelect),
),
],
),
),
);
}
+450
View File
@@ -0,0 +1,450 @@
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),
);
}
+226
View File
@@ -0,0 +1,226 @@
import 'package:flutter/material.dart';
import '../../core/theme/tokens.dart';
import '../home/home_screen.dart' show formatPop;
import 'my_data.dart';
import 'widgets/my_cards.dart';
import '../../core/i18n/app_locale.dart';
/// 티어 정보 — 화면 대조 원장 F6 · Figma `373:633256`.
///
/// 명세 `549:22185` — *티어별 이미지 · 티어명 · 기준치 · **티어가 낮은 순으로 소팅***
///
/// C-18 — 기준치는 **레벨 구간**이다. 누적 기여 POP 문턱과 칭호별 권한·보상은 적지
/// 않는다. Passport Tier(B-65)는 별개 체계라 따로 늘어놓는다.
class TierInfoScreen extends StatelessWidget {
const TierInfoScreen({super.key});
@override
Widget build(BuildContext context) {
// 시안 `373:633256` 한 줄 — 새싹 80 · 간격 17 · 이름 20/500 · 기준 12/400 #84868e ·
// 위아래 12 · 밑줄 #1b1c1c.
Widget row(String label, String note, {bool sprout = true}) => Container(
margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: Color(0xFF1B1C1C))),
),
child: Row(
children: [
SizedBox(
width: 80,
height: 80,
child: sprout
? Image.asset('assets/figma/icon-tier-sprout.png')
: null,
),
const SizedBox(width: 17),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: myText(20, FontWeight.w500)),
if (note.isNotEmpty)
Text(
note,
style: myText(
12,
FontWeight.w400,
color: const Color(0xFF84868E),
),
),
],
),
),
],
),
);
return Scaffold(
appBar: AppBar(title: Text(context.l10n.tierInfoTitle)),
body: ListView(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 0),
children: [
Text.rich(
TextSpan(
style: myText(18, FontWeight.w500),
children: [
TextSpan(text: context.l10n.tierPromptPrefix),
TextSpan(
text: context.l10n.tierPromptRank,
style: TextStyle(color: AppColors.cosmicAccent),
),
TextSpan(text: context.l10n.tierPromptSuffix),
],
),
),
const SizedBox(height: 20),
// 낮은 순으로. LevelTitle 선언 순서가 곧 낮은 순이다. 기준치는 레벨 구간(C-18).
for (final title in LevelTitle.values)
row('${title.label} TIER', title.rangeLabel),
// Passport Tier 는 레벨과 별개 체계다(B-65 · FR-MY-01). 시안에 없는 구획이다.
Text('PASSPORT', style: myText(16, FontWeight.w600)),
const SizedBox(height: 4),
Text(
context.l10n.tierPassportNote,
style: myText(12, FontWeight.w400, color: const Color(0xFF84868E)),
),
const SizedBox(height: 8),
for (final tier in PassportTier.values)
row(tier.label, '', sprout: false),
],
),
);
}
}
/// 티어 상세 — 화면 대조 원장 F6 · Figma `494:16177`.
///
/// 명세 `549:22206` — *갤럭시에서 선택된 기본 행성의 티어를 메인으로 상단에 위치 ·*
/// *나머지 행성 티어는 티어 높은순으로 소팅*
///
/// 칭호는 개인 레벨에서만 나오므로(C-18) 행성별로는 **기여 실적(POP)만** 보인다.
class TierDetailScreen extends StatelessWidget {
const TierDetailScreen({super.key, required this.tiers});
final List<PlanetTier> tiers;
/// 기본 행성이 맨 앞, 나머지는 기여 POP 높은 순.
List<PlanetTier> get ordered {
final primary = tiers.where((p) => p.isPrimary).toList();
final rest = tiers.where((p) => !p.isPrimary).toList()
..sort((a, b) => b.contributedPop.compareTo(a.contributedPop));
return [...primary, ...rest];
}
@override
Widget build(BuildContext context) {
final list = ordered;
final top = list.fold<int>(
0,
(m, p) => p.contributedPop > m ? p.contributedPop : m,
);
// 시안 `494:16177` — 기본 행성 카드(#19191c · 분홍 획) · 구분선 #2c2c2c · 나머지 카드.
// 행성별 칭호는 두지 않는다(C-18) — 시안의 「GLOW TIER」 줄은 비우고 기여 실적만 그린다.
return Scaffold(
appBar: AppBar(title: const Text('TIER')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
for (final (i, planet) in list.indexed) ...[
if (i > 0) const SizedBox(height: 16),
_PlanetCard(
planet: planet,
progress: top == 0 ? 0 : planet.contributedPop / top,
),
if (i == 0 && planet.isPrimary && list.length > 1) ...[
const SizedBox(height: 16),
const SizedBox(
height: 1,
child: ColoredBox(color: MyColors.divider),
),
],
],
],
),
);
}
}
/// 행성 한 장 — 행성 60 + 이름 12/600 · 진행 바 · 「누적 기여 / N POP」.
class _PlanetCard extends StatelessWidget {
const _PlanetCard({required this.planet, required this.progress});
final PlanetTier planet;
final double progress;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: planet.isPrimary
? BoxDecoration(
color: MyColors.card,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.cosmicAccent),
)
: null,
child: Row(
children: [
SizedBox(
width: 60,
child: Column(
children: [
Image.asset(
'assets/figma/my-planet-1.png',
width: 60,
height: 60,
),
Text(
planet.fandomName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 12,
fontWeight: FontWeight.w600,
height: 1,
color: AppColors.onCosmic,
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyProgressBar(value: progress),
const SizedBox(height: 8),
Row(
children: [
Text(
context.l10n.tierTotalContribution,
style: myText(
12,
FontWeight.w500,
color: AppColors.cosmicAccent,
),
),
const Spacer(),
Text(
'${formatPop(planet.contributedPop)} POP',
style: myText(
12,
FontWeight.w500,
color: AppColors.cosmicAccent,
),
),
],
),
],
),
),
],
),
);
}
+435
View File
@@ -0,0 +1,435 @@
import 'package:flutter/material.dart';
import '../../../core/theme/tokens.dart';
import '../../../core/ui/app_icons.dart';
import '../../../core/ui/relative_time.dart' show formatReaction;
import '../../home/home_screen.dart' show formatPop;
import '../my_data.dart';
import '../../../core/i18n/app_locale.dart';
/// MY 시안(`373:632962` 외 F6 전체)에서 되풀이되는 색. 토큰에 없는 Figma 값만 둔다.
abstract final class MyColors {
/// 카드 바탕 #19191c.
static const card = Color(0xFF19191C);
/// 카드 안 구분선 #2c2c2c.
static const divider = Color(0xFF2C2C2C);
/// 행 끝 chevron #444444.
static const chevron = Color(0xFF444444);
/// 보조 값(닉네임 · 이메일 오른쪽) #bbbbbb.
static const value = Color(0xFFBBBBBB);
/// 섹션 머리 #999999.
static const section = Color(0xFF999999);
/// 통계 라벨 #777777.
static const muted = Color(0xFF777777);
/// 위험 행(로그아웃) #ff5e5e.
static const danger = Color(0xFFFF5E5E);
}
/// 42dot Sans 스타일 한 줄. Figma 자간 -2.5% 를 크기에 맞춰 넣는다.
TextStyle myText(
double size,
FontWeight weight, {
Color color = AppColors.onCosmic,
double height = 1.4,
}) => TextStyle(
fontFamily: AppFonts.body,
fontSize: size,
fontWeight: weight,
height: height,
letterSpacing: size * -0.025,
color: color,
);
/// 행 끝 chevron-right 24.
class MyChevron extends StatelessWidget {
const MyChevron({super.key});
@override
Widget build(BuildContext context) =>
const ImageIcon(AppIcons.chevronRight, size: 24, color: MyColors.chevron);
}
/// 티어 카드 — 시안 MY `373:632962` 「GLOW TIER ⓘ · 진행 바 · 누적 기여 / 값 · ›」 틀.
///
/// C-18 — 제목 자리에 User Level(`Lv.N` + 칭호)을, 그 옆에 Passport Tier(B-65)를
/// 따로 적는다(FR-MY-01 세 체계 구분). 진행 바는 레벨 구간 EXP 진행도다.
/// [onInfo] · [onDetail] 이 없으면(프로필 카드 오버레이) 아이콘과 화살표를 그리지 않는다.
class MyTierCard extends StatelessWidget {
const MyTierCard({
super.key,
required this.profile,
this.onInfo,
this.onDetail,
});
final MyProfile profile;
final VoidCallback? onInfo;
final VoidCallback? onDetail;
@override
Widget build(BuildContext context) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onDetail,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: MyColors.card,
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: Row(
children: [
Image.asset(
'assets/figma/icon-tier-sprout.png',
width: 60,
height: 60,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
profile.levelLabel,
style: myText(14, FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
),
// Passport Tier 는 레벨과 별개 체계다(B-65 · B-66).
Text(
' · ${profile.passportTier.label}',
style: myText(14, FontWeight.w500, color: MyColors.value),
),
if (onInfo != null) ...[
const SizedBox(width: 4),
Semantics(
button: true,
label: context.l10n.tierGuide,
child: GestureDetector(
onTap: onInfo,
child: const ImageIcon(
AssetImage('assets/figma/icon-info.png'),
size: 14,
color: MyColors.value,
),
),
),
],
],
),
const SizedBox(height: 8),
MyProgressBar(value: profile.levelProgress),
const SizedBox(height: 8),
Row(
children: [
Text(
'EXP',
style: myText(
12,
FontWeight.w500,
color: AppColors.cosmicAccent,
),
),
const Spacer(),
Text(
formatPop(profile.userExp),
style: myText(
12,
FontWeight.w500,
color: AppColors.cosmicAccent,
),
),
],
),
],
),
),
if (onDetail != null) ...[
const SizedBox(width: 12),
const MyChevron(),
],
],
),
),
);
}
/// 진행 바 — 높이 10 · 트랙 검정 20% · 채움 분홍 방사 그라데이션(시안 Rectangle 32988).
class MyProgressBar extends StatelessWidget {
const MyProgressBar({super.key, required this.value});
final double value;
@override
Widget build(BuildContext context) => ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.full),
child: SizedBox(
height: 10,
child: Stack(
children: [
const Positioned.fill(child: ColoredBox(color: Color(0x33000000))),
FractionallySizedBox(
widthFactor: value.clamp(0.0, 1.0),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.full),
gradient: const RadialGradient(
radius: 4,
colors: [
Color(0xFFFF7DDD),
Color(0xFFFF97E4),
Color(0xFFFF7DDD),
],
stops: [0, 0.7, 1],
),
),
),
),
],
),
),
);
}
/// 통계 카드 — 시안 MY: 3D 아이콘 36 · 라벨 12/600 #777 · 값 16/600.
class MyStatsCard extends StatelessWidget {
const MyStatsCard({super.key, required this.profile});
final MyProfile profile;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
decoration: BoxDecoration(
color: MyColors.card,
borderRadius: BorderRadius.circular(AppRadius.lg),
),
// 세 칸을 고르게 나눈다. 시안의 칸 안쪽 20 을 그대로 두면 여섯 자리 수치에서 넘친다.
child: Row(
children: [
// 시안 표기 「1.0K」 — 걸음은 K 단위로 줄인다. POP 은 천 단위 구분자다.
_Stat(
icon: 'assets/figma/icon-steps.png',
label: context.l10n.statSteps,
value: formatReaction(profile.steps),
),
_Stat(
icon: 'assets/figma/icon-pop-bolt.png',
label: context.l10n.statContribution,
value: formatPop(profile.contributedPop),
),
_Stat(
icon: 'assets/figma/icon-quests.png',
label: context.l10n.statQuests,
value: '${profile.completedQuests}',
),
],
),
);
}
class _Stat extends StatelessWidget {
const _Stat({required this.icon, required this.label, required this.value});
final String icon;
final String label;
final String value;
@override
Widget build(BuildContext context) => Expanded(
child: Column(
children: [
Image.asset(icon, width: 36, height: 36),
const SizedBox(height: 6),
Text(label, style: myText(12, FontWeight.w600, color: MyColors.muted)),
const SizedBox(height: 12),
Text(value, style: myText(16, FontWeight.w600)),
],
),
);
}
/// 두 칸 탭 — 시안 「행성/성운 · 활동」: 칸 높이 44 · 글자 16/500 · 활성 흰 밑줄,
/// 비활성 #1a1a1a 밑줄 · 비활성 글자 #bbbbbb.
class MyTabBar extends StatelessWidget {
const MyTabBar({super.key, required this.tabs});
final List<String> tabs;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: TabBar(
labelColor: AppColors.onCosmic,
unselectedLabelColor: MyColors.value,
indicator: const UnderlineTabIndicator(
borderSide: BorderSide(color: AppColors.onCosmic, width: 2),
),
indicatorSize: TabBarIndicatorSize.tab,
dividerColor: const Color(0xFF1A1A1A),
dividerHeight: 2,
labelPadding: EdgeInsets.zero,
labelStyle: myText(16, FontWeight.w500, height: 1),
unselectedLabelStyle: myText(16, FontWeight.w500, height: 1),
tabs: [for (final t in tabs) Tab(height: 44, text: t)],
),
);
}
/// 정렬 칩 — 시안 Chip(`370:228289`): 높이 32 · 모서리 8 · 아이콘 16 · 글자 12/500.
/// 선택은 흰 바탕에 검정 글자, 비선택은 #1a1a1a 바탕 · #232324 획 · #d5d7d9 글자.
class MySortChip extends StatelessWidget {
const MySortChip({
super.key,
required this.label,
required this.icon,
required this.selected,
required this.onTap,
});
static const clock = AssetImage('assets/figma/icon-clock.png');
static const bolt = AssetImage('assets/figma/icon-pop-small.png');
static const fileCheck = AssetImage('assets/figma/icon-file-check.png');
final String label;
final ImageProvider icon;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final fg = selected ? Colors.black : const Color(0xFFD5D7D9);
return GestureDetector(
onTap: onTap,
child: Container(
height: 32,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: selected ? AppColors.onCosmic : const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(AppRadius.sm),
border: Border.all(color: AppColors.cosmicSurfaceRaised),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ImageIcon(icon, size: 16, color: fg),
const SizedBox(width: 4),
Text(
label,
style: myText(12, FontWeight.w500, color: fg, height: 1.3),
),
],
),
),
);
}
}
/// 섹션 — 머리 12/600 #999 · 간격 16 · 카드 #19191c 모서리 16 · 안쪽 20/16 · 줄 간격 16.
/// 섹션 사이는 24. [title] 이 없으면 카드만 그린다(로그아웃 · 탈퇴).
class MyGroup extends StatelessWidget {
const MyGroup({this.title, required this.children});
final String? title;
final List<Widget> children;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (title case final t?) ...[
Text(
t,
style: myText(
12,
FontWeight.w600,
color: MyColors.section,
height: 1,
),
),
const SizedBox(height: 16),
],
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
decoration: BoxDecoration(
color: MyColors.card,
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (i, c) in children.indexed) ...[
if (i > 0) const SizedBox(height: 16),
c,
],
],
),
),
],
),
);
}
class MyDivider extends StatelessWidget {
const MyDivider();
@override
Widget build(BuildContext context) =>
const SizedBox(height: 1, child: ColoredBox(color: MyColors.divider));
}
/// 눌러서 들어가는 줄 — 높이 24 · 글자 16/600 · 끝 chevron(언어는 아래 화살표).
class MyLinkRow extends StatelessWidget {
const MyLinkRow({
this.label,
this.child,
this.color = AppColors.onCosmic,
this.down = false,
required this.onTap,
});
final String? label;
final Widget? child;
final Color color;
final bool down;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: SizedBox(
height: child == null ? 24 : 25,
child: Row(
children: [
Expanded(
child:
child ??
Text(
label!,
style: myText(16, FontWeight.w600, color: color, height: 1),
),
),
if (down)
const ImageIcon(
AssetImage('assets/figma/icon-chevron-down.png'),
size: 24,
color: Color(0xFF4F4F4F),
)
else
const MyChevron(),
],
),
),
);
}
@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import '../../../core/theme/tokens.dart';
import '../../../core/ui/app_icons.dart';
import '../my_data.dart';
import 'my_cards.dart';
import '../../../core/i18n/app_locale.dart';
/// 프로필 카드 오버레이 — 시안 `374:15738`(명세 `549:22058`).
///
/// 닫기 · 프로필 사진 80 · 닉네임 · 티어 카드 · 통계 카드 · 공유 4종(36 원형 아이콘).
/// *인스타 공유 · 틱톡 공유 · X 공유 · 링크 복사*
class ProfileCard extends StatelessWidget {
const ProfileCard({super.key, required this.profile, required this.onShare});
final MyProfile profile;
final void Function(String target) onShare;
/// 공유 대상과 시안 아이콘. 명세가 적은 순서 그대로다.
static List<String> get shareTargets => [
appL10n.shareInstagram,
appL10n.shareTiktok,
'X',
appL10n.shareCopyLink,
];
static const _shareIcons = [
'assets/figma/share-instagram.png',
'assets/figma/share-tiktok.png',
'assets/figma/share-x.png',
'assets/figma/share-link.png',
];
ImageProvider<Object> get _photo {
final url = profile.photoUrl;
return url == null || url.isEmpty
? const AssetImage('assets/figma/avatar-sample.png')
: NetworkImage(url);
}
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 28),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Align(
alignment: Alignment.centerRight,
child: IconButton(
tooltip: context.l10n.commonClose,
onPressed: () => Navigator.of(context).maybePop(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(width: 24, height: 24),
icon: const ImageIcon(
AppIcons.x,
size: 24,
color: AppColors.onCosmic,
),
),
),
const SizedBox(height: 24),
CircleAvatar(
radius: 40,
backgroundColor: AppColors.cosmicSurfaceRaised,
backgroundImage: _photo,
),
const SizedBox(height: 20),
Text(profile.nickname, style: myText(20, FontWeight.w600)),
const SizedBox(height: 24),
MyTierCard(profile: profile),
const SizedBox(height: 19),
MyStatsCard(profile: profile),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (final (i, target) in shareTargets.indexed) ...[
if (i > 0) const SizedBox(width: 16),
Semantics(
button: true,
label: target,
child: Tooltip(
message: target,
child: GestureDetector(
onTap: () => onShare(target),
child: ClipOval(
child: Container(
width: 36,
height: 36,
color: Colors.black,
alignment: Alignment.center,
child: Image.asset(
_shareIcons[i],
// X 원본은 21 짜리 글리프라 검정 원 안에 그대로 둔다.
width: i == 2 ? 21 : 36,
height: i == 2 ? 21 : 36,
),
),
),
),
),
),
],
],
),
],
),
);
}
@@ -0,0 +1,241 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import '../../core/data/backend.dart';
import '../../core/i18n/app_locale.dart';
/// 알림함이 그리는 값과 그 출처 — 화면 대조 원장 F0 · Figma `229:11265`.
///
/// 명세 `518:88347` — *뒤로가기 클릭 시, 이전 화면 이동 및 **읽음 상태값 반환***
/// 명세 `518:88353` — *날짜 기준 그룹화 및 최신순 정렬 · 오늘 및 어제를 제외한*
/// *과거는 yyyy.mm.dd 로 표시* · 종류별 이동 규칙
/// 알림 종류. **눌렀을 때 어디로 가는지가 종류마다 다르다**(명세).
enum NotificationKind {
eclipse(Icons.dark_mode_outlined),
itemUsed(Icons.auto_awesome),
like(Icons.favorite_border),
comment(Icons.mode_comment_outlined),
campaign(Icons.campaign_outlined),
quest(Icons.checklist_rtl);
const NotificationKind(this.icon);
final IconData icon;
String get label => switch (this) {
eclipse => appL10n.notifKindEclipse,
itemUsed => appL10n.notifKindItemUsed,
like => appL10n.notifKindLike,
comment => appL10n.notifKindComment,
campaign => appL10n.notifKindCampaign,
quest => appL10n.notifKindQuest,
};
/// 눌러서 갈 곳. `null` 이면 이동하지 않는다 — 명세가 이클립스와 아이템
/// 사용을 *클릭 이동 X* 로 정했다.
String? get target => switch (this) {
eclipse || itemUsed => null,
like || comment => appL10n.notifTargetPost,
campaign => appL10n.notifTargetCampaign,
quest => appL10n.notifTargetQuest,
};
bool get navigable => target != null;
/// 서버 문서의 `eventType` → 종류.
///
/// **알림 문서는 축이 둘이다.**
/// - `type` 은 수신 설정 축이고 값은 정책 원장 **B-77** 의 5구분이다
/// (`essential` · `fandomEvent` · `briefing` · `community` · `marketing`) —
/// 서버 `firebase/functions/src/notify/push.ts` 의 `PUSH_TOPICS` 와 같은 표기다.
/// - `eventType` 은 알림함 표기·이동 축이고 값은 Figma `518:88353` 의 6종이다.
///
/// 둘을 한 필드로 합칠 수 없다. B-77 의 「팬덤 이벤트」 하나가 Eclipse·Campaign·
/// Quest 를 모두 담는데, 명세는 이클립스를 *이동 X*, 캠페인을 캠페인으로,
/// 퀘스트를 퀘스트 상세로 보내라고 갈라 두었다. 카테고리만으로는 이동할 곳을
/// 정할 수 없다.
///
/// `eventType` 이 없으면 `type` 을 넣어 본다. B-77 의 5값은 여기에 걸리지 않아
/// 이동하지 않는 [itemUsed] 로 떨어지는데, 그것이 의도한 동작이다. 종류를
/// 잘못 짚어 **없는 화면으로 보내는 것보다 안 보내는 쪽이 안전하다.**
static NotificationKind parse(String raw) => switch (raw) {
'eclipse' => NotificationKind.eclipse,
'item_used' => NotificationKind.itemUsed,
'like' => NotificationKind.like,
'comment' => NotificationKind.comment,
'campaign' => NotificationKind.campaign,
'quest' => NotificationKind.quest,
_ => NotificationKind.itemUsed,
};
}
class AppNotification {
const AppNotification({
required this.id,
required this.kind,
required this.title,
required this.at,
this.read = false,
this.source,
});
final String id;
final NotificationKind kind;
final String title;
final DateTime at;
final bool read;
/// 둘째 줄 앞머리 — 시안은 알림이 난 곳을 쓴다(「INSIDE」 · 행성명 「BTS」 · 「퀘스트」).
/// 없으면 종류 이름([NotificationKind.label])으로 물러난다.
final String? source;
String get sourceLabel => source ?? kind.label;
}
/// 날짜 머리글. 명세 — *오늘 및 어제를 제외한 과거는 yyyy.mm.dd 로 표시*.
String notificationDayLabel(DateTime at, {DateTime? now}) {
final today = _dateOnly(now ?? DateTime.now());
final day = _dateOnly(at);
final gap = today.difference(day).inDays;
if (gap == 0) return appL10n.dayToday;
if (gap == 1) return appL10n.dayYesterday;
final y = day.year.toString().padLeft(4, '0');
final m = day.month.toString().padLeft(2, '0');
final d = day.day.toString().padLeft(2, '0');
return '$y.$m.$d';
}
/// 생성일시. 명세 — *yyyy.mm.dd 00:00*.
String notificationTimestamp(DateTime at) {
final y = at.year.toString().padLeft(4, '0');
final m = at.month.toString().padLeft(2, '0');
final d = at.day.toString().padLeft(2, '0');
final hh = at.hour.toString().padLeft(2, '0');
final mm = at.minute.toString().padLeft(2, '0');
return '$y.$m.$d $hh:$mm';
}
DateTime _dateOnly(DateTime at) => DateTime(at.year, at.month, at.day);
/// 날짜별로 묶는다. 최신 날짜가 앞이고, 그룹 안에서도 최신이 앞이다.
List<({String label, List<AppNotification> items})> groupNotifications(
List<AppNotification> items, {
DateTime? now,
}) {
final sorted = [...items]..sort((a, b) => b.at.compareTo(a.at));
final groups = <String, List<AppNotification>>{};
for (final item in sorted) {
groups
.putIfAbsent(notificationDayLabel(item.at, now: now), () => [])
.add(item);
}
return [
for (final entry in groups.entries) (label: entry.key, items: entry.value),
];
}
abstract interface class NotificationRepository {
Future<List<AppNotification>> load({int limit = 50});
/// 읽음 처리. 명세가 뒤로가기에서 읽음 상태를 돌려주라 했으므로 화면을 떠날
/// 때가 아니라 **누른 그 순간** 서버에 남긴다. 떠날 때 한꺼번에 보내면 앱이
/// 죽거나 통신이 끊긴 경우에 읽음이 사라진다.
Future<void> markRead(String id);
}
/// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.**
class SampleNotificationRepository implements NotificationRepository {
/// 목록을 뒤늦게 만드는 이유는 `const` 다. HOME·GALAXY 가 기존 표본 저장소와
/// 같은 모양으로 기본값에 쓸 수 있어야 한다.
const SampleNotificationRepository({List<AppNotification>? items})
: _items = items;
final List<AppNotification>? _items;
static List<AppNotification> _defaults() => [
AppNotification(
id: 'n1',
kind: NotificationKind.quest,
title: '만보기 퀘스트를 완료했습니다',
at: DateTime.now().subtract(const Duration(hours: 2)),
),
AppNotification(
id: 'n2',
kind: NotificationKind.like,
title: '아미보이님이 회원님의 글을 좋아합니다',
at: DateTime.now().subtract(const Duration(hours: 5)),
),
AppNotification(
id: 'n3',
kind: NotificationKind.eclipse,
title: 'BTS 행성에 이클립스가 발생했습니다',
at: DateTime.now().subtract(const Duration(days: 1, hours: 3)),
read: true,
),
AppNotification(
id: 'n4',
kind: NotificationKind.campaign,
title: '강남역 LED 캠페인이 목표를 달성했습니다',
at: DateTime.now().subtract(const Duration(days: 9)),
read: true,
),
];
@override
Future<List<AppNotification>> load({int limit = 50}) async =>
(_items ?? _defaults()).take(limit).toList();
@override
Future<void> markRead(String id) async {}
}
/// 실 서버 구현 — `users/{uid}/notifications`.
///
/// 규칙이 본인 읽기와 `read`·`readAt` 두 필드 수정만 허용한다. 생성과 삭제는
/// Functions(FCM) 경유라 앱에서 부를 경로 자체가 없다.
class FirestoreNotificationRepository implements NotificationRepository {
const FirestoreNotificationRepository(this._backend);
final Backend _backend;
@override
Future<List<AppNotification>> load({int limit = 50}) async {
final uid = _backend.requireUid;
final snapshot = await _backend.db
.collection('users/$uid/notifications')
.orderBy('createdAt', descending: true)
.limit(limit)
.get();
return [
for (final doc in snapshot.docs)
AppNotification(
id: doc.id,
// `eventType`(이동 축) 우선, 없으면 `type`(수신 설정 축)으로 물러난다.
kind: NotificationKind.parse(
doc.data().strOr('eventType', doc.data().strOr('type')),
),
title: doc.data().strOr('title', appL10n.homeNotificationsTooltip),
at: doc.data().dateOr('createdAt', _epoch),
read: doc.data().boolOr('read'),
// 서버가 아직 쓰지 않는 필드다. 비면 종류 이름으로 물러난다.
source: switch (doc.data().strOr('source')) {
'' => null,
final v => v,
},
),
];
}
@override
Future<void> markRead(String id) async {
final uid = _backend.requireUid;
await _backend.db.doc('users/$uid/notifications/$id').update({
'read': true,
'readAt': FieldValue.serverTimestamp(),
});
}
}
/// 서버가 시각을 아직 채우지 않은 문서의 기본값.
final DateTime _epoch = DateTime.fromMillisecondsSinceEpoch(0);
@@ -0,0 +1,201 @@
import 'package:flutter/material.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart';
import 'notification_data.dart';
import '../../core/i18n/app_locale.dart';
/// 알림 종류·표기·그룹화와 저장소는 `notification_data.dart` 에 있다.
/// 이 화면을 임포트한 곳이 모델까지 함께 쓰므로 그대로 내보낸다.
export 'notification_data.dart';
/// 알림 — 화면 대조 원장 F0 · Figma `229:11265`.
class NotificationScreen extends StatefulWidget {
const NotificationScreen({super.key, required this.repository});
final NotificationRepository repository;
@override
State<NotificationScreen> createState() => _NotificationScreenState();
}
class _NotificationScreenState extends State<NotificationScreen> {
List<AppNotification>? _items;
final Set<String> _read = {};
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final items = await widget.repository.load();
if (!mounted) return;
setState(() {
_items = items;
_read
..clear()
..addAll(items.where((i) => i.read).map((i) => i.id));
});
}
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final items = _items;
if (items == null) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
final groups = groupNotifications(items);
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.homeNotificationsTooltip),
// 명세 — 뒤로가기 시 읽음 상태값을 돌려준다.
leading: BackButton(
onPressed: () => Navigator.of(context).pop({..._read}),
),
),
body: items.isEmpty
? Center(child: Text(context.l10n.notifEmpty, style: t.bodyMedium))
: ListView(
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
children: [
for (final group in groups)
// 시안 날짜 묶음 — 위아래 20 · 머리글과 항목 사이 20 · 항목 사이 24 · 아래 #1a1a1a 줄.
Container(
padding: const EdgeInsets.symmetric(vertical: 20),
foregroundDecoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: Color(0xFF1A1A1A)),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
group.label,
style: _style(_grey, height: 24 / 14),
),
for (final (i, item) in group.items.indexed) ...[
SizedBox(height: i == 0 ? 20 : 24),
_tile(item),
],
],
),
),
],
),
);
}
Widget _tile(AppNotification item) {
final read = _read.contains(item.id);
final (fill, icon) = _badge(item.kind);
return InkWell(
onTap: () => _open(item),
child: Row(
children: [
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 30,
height: 30,
alignment: Alignment.center,
decoration: BoxDecoration(
color: fill,
shape: BoxShape.circle,
border: Border.all(color: const Color(0xFF333333)),
),
child: ImageIcon(icon, size: 14, color: AppColors.onCosmic),
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
style: _style(
read ? AppColors.onCosmicVariant : AppColors.onCosmic,
),
),
const SizedBox(height: 6),
Text(
'${item.sourceLabel} · ${notificationTimestamp(item.at)}',
style: _style(_grey),
),
],
),
),
],
),
),
// 이동할 곳이 없는 종류에는 화살표를 그리지 않는다.
if (item.kind.navigable) ...[
const SizedBox(width: 13),
const ImageIcon(
AppIcons.chevronRight,
size: 24,
color: AppColors.onCosmicMuted,
),
],
],
),
);
}
Future<void> _open(AppNotification item) async {
// 처음 읽은 것만 서버에 알린다. 이미 읽은 알림을 다시 눌러도 쓰기가 없다.
final first = _read.add(item.id);
setState(() {});
if (item.kind.navigable && mounted) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(context.l10n.notifGoTo('${item.kind.target}')),
behavior: SnackBarBehavior.floating,
),
);
}
if (!first) return;
try {
await widget.repository.markRead(item.id);
} on Exception {
// 읽음 표시가 실패해도 화면을 막지 않는다. 다음 진입에서 서버 값으로
// 되돌아오므로 잃는 것이 없고, 여기서 막으면 알림 자체를 못 읽는다.
}
}
}
const _grey = Color(0xFF777777);
/// 시안 알림 글자 — 42dot 14/600 · 자간 -2.5% · 행간 130%.
TextStyle _style(Color color, {double height = 1.3}) => TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
fontWeight: FontWeight.w600,
height: height,
letterSpacing: -0.35,
color: color,
);
/// 종류별 원 배지 — 시안 30 원 · #333333 획. 댓글·이클립스는 검정, 좋아요는 #ff5151,
/// 아이템은 #cebf3b, 퀘스트는 #1a1a1c. 캠페인 달성은 시안이 없어 댓글과 같은 검정이다.
(Color, ImageProvider) _badge(NotificationKind kind) => switch (kind) {
NotificationKind.comment => (Colors.black, _icon('message_circle')),
NotificationKind.campaign => (Colors.black, _icon('message_circle')),
NotificationKind.like => (const Color(0xFFFF5151), _icon('heart')),
NotificationKind.eclipse => (Colors.black, _icon('planet')),
NotificationKind.itemUsed => (const Color(0xFFCEBF3B), _icon('water_ec')),
NotificationKind.quest => (
AppColors.cosmicSurfaceContainer,
_icon('book_open'),
),
};
AssetImage _icon(String name) => AssetImage('assets/icons/$name.png');
+105
View File
@@ -0,0 +1,105 @@
/// STATION 탭이 그리는 값의 형태.
///
/// **STATION 은 Figma 에 화면이 없다.** 있는 것은 Bottom Navigation 의 탭 하나뿐이며,
/// 근거는 견적서 v2.1 의 5탭 구성과 Firestore 설계서의 `stationLinks` ·
/// `stationSuggestions` 두 컬렉션, 그리고 화면 정의 S-S-HUB · S-S-SUGGEST 다.
/// 그래서 이 화면은 **데이터 모델이 정의한 것까지만** 그린다 — 디자인이 확정되면
/// 그 시안으로 다시 맞춘다.
///
/// 하는 일은 큐레이션이다. 운영이 검증한 판매처·공식 링크를 모아 보여 주고,
/// 사용자가 새 판매처를 제안하면 운영이 심사한다(`stationSuggestions`).
library;
import '../../core/i18n/app_locale.dart';
/// 링크 분류. `stationLinks` 의 `category` 값이다.
enum StationCategory { official, shop, ticket, media }
extension StationCategoryLabel on StationCategory {
String get label => switch (this) {
StationCategory.official => appL10n.stationOfficial,
StationCategory.shop => appL10n.stationShop,
StationCategory.ticket => appL10n.stationTicket,
StationCategory.media => appL10n.stationMedia,
};
}
/// 큐레이션 링크 한 줄.
class StationLink {
const StationLink({
required this.id,
required this.title,
required this.url,
required this.category,
this.description = '',
this.verified = false,
});
final String id;
final String title;
/// 바깥 브라우저로 여는 주소. 앱 안에서 열지 않는다 — 결제·로그인이 걸린
/// 외부 사이트를 인앱 웹뷰에 담으면 심사에서 문제가 된다.
final String url;
final StationCategory category;
final String description;
/// 운영이 검증한 판매처. 명세가 없어 표시만 하고 정렬에는 쓰지 않는다.
final bool verified;
}
class StationSnapshot {
const StationSnapshot({required this.links, this.canSuggest = true});
final List<StationLink> links;
/// 제안 버튼 노출. 로그인 사용자만 제안할 수 있다(규칙 `authorIsSelf()`).
final bool canSuggest;
}
abstract interface class StationRepository {
Future<StationSnapshot> load();
}
/// 서버가 붙기 전에 쓰는 표본.
class SampleStationRepository implements StationRepository {
const SampleStationRepository();
@override
Future<StationSnapshot> load() async => const StationSnapshot(
links: [
StationLink(
id: 'official-site',
title: 'FANUZU 공식 사이트',
url: 'https://fanuzu.co.kr',
category: StationCategory.official,
description: '공지와 일정이 가장 먼저 올라옵니다.',
verified: true,
),
StationLink(
id: 'shop-official',
title: '공식 스토어',
url: 'https://fanuzu.co.kr',
category: StationCategory.shop,
description: '앨범과 응원봉을 파는 공식 판매처입니다.',
verified: true,
),
StationLink(
id: 'ticket',
title: '팬미팅 예매',
url: 'https://fanuzu.co.kr',
category: StationCategory.ticket,
description: '예매 일정과 대기 순서를 확인하십시오.',
),
StationLink(
id: 'media',
title: '공식 미디어 채널',
url: 'https://fanuzu.co.kr',
category: StationCategory.media,
description: '무대 영상과 비하인드가 올라옵니다.',
verified: true,
),
],
);
}
@@ -0,0 +1,63 @@
import '../../core/data/backend.dart';
import 'station_data.dart';
/// STATION 허브의 실 서버 구현 — S-S-HUB.
///
/// `stationLinks` 는 운영만 쓰고 로그인 사용자는 읽기만 한다(보안 규칙 §7).
/// 정렬은 팬덤별 `rank` 오름차순이며, 이 순서가 곧 운영이 정한 노출 순서다.
/// 앱에서 다시 정렬하지 않는다 — 그러면 운영 화면과 앱이 다르게 보인다.
class FirestoreStationRepository implements StationRepository {
FirestoreStationRepository(this._backend);
final Backend _backend;
/// 소속 팬덤. 라우터가 모르는 값이라 사용자 문서에서 한 번 읽어 들고 있는다.
String? _fandomId;
@override
Future<StationSnapshot> load() async {
final cached = _fandomId;
final String id;
if (cached != null) {
id = cached;
} else {
final snap = await _backend.db.doc('users/${_backend.requireUid}').get();
id = _fandomId = (snap.data() ?? const {}).strOr('fandomId');
}
// 팬덤이 없으면 큐레이션 대상이 정해지지 않는다. 빈 상태로 두고 화면이 안내한다.
if (id.isEmpty) {
return const StationSnapshot(links: [], canSuggest: false);
}
final snap = await _backend.db
.collection('stationLinks')
.where('fandomId', isEqualTo: id)
.orderBy('rank')
.limit(30)
.get();
return StationSnapshot(
links: [
for (final doc in snap.docs)
StationLink(
id: doc.id,
title: doc.data().strOr('title'),
url: doc.data().strOr('url'),
category: _category(doc.data().strOr('category')),
description: doc.data().strOr('description'),
verified: doc.data()['verified'] == true,
),
],
canSuggest: _backend.uid != null,
);
}
/// 모르는 값은 「공식」으로 떨어뜨리지 않고 판매처로 본다 — 공식 배지를
/// 검증하지 않은 링크에 붙이는 쪽이 더 위험하다.
StationCategory _category(String raw) => switch (raw) {
'official' => StationCategory.official,
'ticket' => StationCategory.ticket,
'media' => StationCategory.media,
_ => StationCategory.shop,
};
}
+199 -9
View File
@@ -1,15 +1,205 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/ui/tab_placeholder.dart'; import '../../core/theme/tokens.dart';
import 'station_data.dart';
import 'station_suggest_screen.dart';
import '../../core/i18n/app_locale.dart';
/// STATION 탭 루트 — S-S-HUB STATION 허브. /// STATION 탭 루트 — S-S-HUB.
class StationScreen extends StatelessWidget { ///
const StationScreen({super.key}); /// **Figma 에 이 화면의 시안이 없다.** 근거는 견적서 v2.1 의 5탭 구성과
/// Firestore 설계서의 `stationLinks` · `stationSuggestions` 다. 그래서 데이터가
/// 정의한 것까지만 그렸고, 시안이 확정되면 그때 맞춘다.
class StationScreen extends StatefulWidget {
const StationScreen({
super.key,
this.repository = const SampleStationRepository(),
});
final StationRepository repository;
@override @override
Widget build(BuildContext context) => const TabPlaceholder( State<StationScreen> createState() => _StationScreenState();
tab: 'STATION', }
screenId: 'S-S-HUB',
screenName: 'STATION 허브', class _StationScreenState extends State<StationScreen> {
); StationSnapshot? _snapshot;
bool _loading = true;
String? _error;
StationCategory? _filter;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
final snap = await widget.repository.load();
if (!mounted) return;
setState(() {
_snapshot = snap;
_loading = false;
});
} on Object {
if (!mounted) return;
setState(() {
_loading = false;
_error = context.l10n.stationLoadFailed;
});
}
}
List<StationLink> get _visible {
final links = _snapshot?.links ?? const <StationLink>[];
final filter = _filter;
if (filter == null) return links;
return links.where((l) => l.category == filter).toList();
}
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final snapshot = _snapshot;
return Scaffold(
appBar: AppBar(
title: const Text('STATION'),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(48),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
child: Row(
children: [
ChoiceChip(
label: Text(context.l10n.commonAll),
selected: _filter == null,
onSelected: (_) => setState(() => _filter = null),
),
const SizedBox(width: AppSpace.s2),
for (final c in StationCategory.values) ...[
ChoiceChip(
label: Text(c.label),
selected: _filter == c,
onSelected: (_) => setState(() => _filter = c),
),
const SizedBox(width: AppSpace.s2),
],
],
),
),
),
),
floatingActionButton: (snapshot?.canSuggest ?? false)
? FloatingActionButton.extended(
onPressed: () => Navigator.of(context, rootNavigator: true).push<void>(
MaterialPageRoute(builder: (_) => const StationSuggestScreen()),
),
icon: const Icon(Icons.add_link),
label: Text(context.l10n.stationSuggest),
)
: null,
body: switch ((_loading, _error, _visible.isEmpty)) {
(true, _, _) => const Center(child: CircularProgressIndicator()),
(_, final String message, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(message, style: t.titleMedium),
const SizedBox(height: AppSpace.s4),
FilledButton(onPressed: _load, child: Text(context.l10n.commonRetry)),
],
),
),
(_, _, true) => Center(
child: Padding(
padding: const EdgeInsets.all(AppSpace.s5),
child: Text(
snapshot == null || snapshot.links.isEmpty
? context.l10n.stationEmpty
: context.l10n.stationCategoryEmpty,
style: t.bodyMedium,
textAlign: TextAlign.center,
),
),
),
_ => RefreshIndicator(
onRefresh: _load,
child: ListView.separated(
padding: const EdgeInsets.fromLTRB(
AppSpace.s4,
AppSpace.s4,
AppSpace.s4,
AppSpace.s8,
),
itemCount: _visible.length,
separatorBuilder: (_, _) => const SizedBox(height: AppSpace.s3),
itemBuilder: (context, i) => StationLinkTile(link: _visible[i]),
),
),
},
);
}
}
/// 링크 한 줄.
class StationLinkTile extends StatelessWidget {
const StationLinkTile({super.key, required this.link, this.onTap});
final StationLink link;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Material(
color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(AppRadius.md),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.md),
child: Padding(
padding: const EdgeInsets.all(AppSpace.s4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(link.category.label, style: t.labelSmall),
if (link.verified) ...[
const SizedBox(width: AppSpace.s2),
Icon(
Icons.verified_outlined,
size: 14,
color: AppColors.neonCyan,
),
],
],
),
const SizedBox(height: AppSpace.s2),
Text(link.title, style: t.titleMedium),
if (link.description.isNotEmpty) ...[
const SizedBox(height: AppSpace.s1),
Text(link.description, style: t.bodySmall),
],
],
),
),
const SizedBox(width: AppSpace.s3),
Icon(Icons.open_in_new, size: 18, color: t.labelSmall?.color),
],
),
),
),
);
}
} }
@@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
import '../../core/theme/tokens.dart';
import 'station_data.dart';
import '../../core/i18n/app_locale.dart';
/// 판매처 제안 — S-S-SUGGEST.
///
/// 제안은 `stationSuggestions` 에 `status: 'pending'` 으로 들어가고 운영이 심사한다.
/// 보안 규칙이 생성 시 status 를 pending 으로 못박고 있으므로(§7), 화면이 다른 값을
/// 보내면 거부당한다. 등록 경로는 6주차에 잇는다.
class StationSuggestScreen extends StatefulWidget {
const StationSuggestScreen({super.key});
@override
State<StationSuggestScreen> createState() => _StationSuggestScreenState();
}
class _StationSuggestScreenState extends State<StationSuggestScreen> {
final _form = GlobalKey<FormState>();
final _title = TextEditingController();
final _url = TextEditingController();
final _reason = TextEditingController();
StationCategory _category = StationCategory.shop;
@override
void dispose() {
_title.dispose();
_url.dispose();
_reason.dispose();
super.dispose();
}
void _submit() {
if (!(_form.currentState?.validate() ?? false)) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.stationSuggestLater)),
);
}
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.stationSuggest),
actions: [TextButton(onPressed: _submit, child: Text(context.l10n.commonSubmit))],
),
body: Form(
key: _form,
child: ListView(
padding: const EdgeInsets.all(AppSpace.s4),
children: [
TextFormField(
controller: _title,
decoration: InputDecoration(labelText: context.l10n.commonName),
maxLength: 40,
validator: (v) =>
(v ?? '').trim().isEmpty ? context.l10n.commonNameRequired : null,
),
const SizedBox(height: AppSpace.s3),
TextFormField(
controller: _url,
decoration: InputDecoration(
labelText: context.l10n.commonUrl,
hintText: 'https://',
),
keyboardType: TextInputType.url,
validator: (v) {
final raw = (v ?? '').trim();
if (raw.isEmpty) return context.l10n.commonUrlRequired;
final uri = Uri.tryParse(raw);
// http 도 막는다. 검증한 판매처를 평문으로 안내할 이유가 없다.
if (uri == null || uri.scheme != 'https' || uri.host.isEmpty) {
return context.l10n.commonUrlHttps;
}
return null;
},
),
const SizedBox(height: AppSpace.s3),
DropdownButtonFormField<StationCategory>(
initialValue: _category,
decoration: InputDecoration(labelText: context.l10n.commonCategory),
items: [
for (final c in StationCategory.values)
DropdownMenuItem(value: c, child: Text(c.label)),
],
onChanged: (v) => setState(() => _category = v ?? _category),
),
const SizedBox(height: AppSpace.s3),
TextFormField(
controller: _reason,
decoration: InputDecoration(
labelText: context.l10n.stationReason,
alignLabelWithHint: true,
),
maxLines: 5,
maxLength: 500,
),
const SizedBox(height: AppSpace.s5),
Text(
context.l10n.stationSuggestNote,
style: t.bodySmall,
),
],
),
),
);
}
}
@@ -0,0 +1,570 @@
import 'package:flutter/material.dart';
import '../../core/data/backend.dart' show publicName;
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart';
import 'translation_data.dart';
/// 참여형 번역 — 「번역 댓글」 UI (FR-I18N-02 · 원장 B-63 · 해석 확인 요청서 i18n-참여형번역 2.1).
///
/// Figma
/// - `540:19136` · `527:21198` 댓글 입력 영역 — 「번역 댓글 작성 버튼(지구본) · 클릭 시 키보드
/// 노출 · 입력 필드 색 변경」 → [CommentInputBar]
/// - `201:50554` 번역 댓글 카드 — 대댓글 자리 · 지구본 표시 · 분홍 바탕 → [TranslationCommentCard]
/// - 유효/무효 버튼은 Figma 카드에 없으나 마킹을 하려면 필요한 요소라 번역 댓글 카드에만 둔다
/// (공통 기준 5항).
///
/// 쓰는 화면: INSIDE 상세 · 캠페인 응원 게시판 · 팬덤 라운지 글 상세([translationSurfaces]).
/// 트리 아래 화면(캠페인 상세 · 라운지 글 상세)이 번역 저장소를 찾는 통로. 앱 루트에 한 번 둔다.
class TranslationScope extends InheritedWidget {
const TranslationScope({
super.key,
required this.repository,
required super.child,
});
final TranslationRepository repository;
static TranslationRepository? maybeOf(BuildContext context) => context
.dependOnInheritedWidgetOfExactType<TranslationScope>()
?.repository;
@override
bool updateShouldNotify(TranslationScope old) => old.repository != repository;
}
/// 번역 댓글이 번역하는 댓글 — 입력 영역 위에 「○○ 님 댓글 번역」으로 뜬다.
typedef TranslationReplyTarget = ({String path, String name});
String _failure(AppLocalizations l10n, TranslationFailure kind) =>
switch (kind) {
TranslationFailure.selfMark => l10n.translationSelfMarkBlocked,
TranslationFailure.alreadyMarked => l10n.translationAlreadyMarked,
TranslationFailure.duplicate => l10n.translationDuplicate,
TranslationFailure.dailyCap => l10n.translationDailyCap,
TranslationFailure.invalidTarget => l10n.translationInvalidTarget,
TranslationFailure.notEligible => l10n.translationNotEligible,
TranslationFailure.other => l10n.translationFailed,
};
/// 등록 · 마킹을 돌리고 결과를 스낵바로 알린다. 성공하면 true.
Future<bool> _run(
BuildContext context,
Future<void> Function() action,
String done,
) async {
final l10n = context.l10n;
final messenger = ScaffoldMessenger.maybeOf(context);
var message = done;
var ok = true;
try {
await action();
} on TranslationException catch (e) {
message = _failure(l10n, e.kind);
ok = false;
} catch (_) {
message = l10n.translationFailed;
ok = false;
}
messenger
?..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
);
return ok;
}
TextStyle _text(double size, [Color color = AppColors.onCosmic]) => TextStyle(
fontFamily: AppFonts.body,
fontSize: size,
fontWeight: FontWeight.w500,
height: 1.3,
letterSpacing: -0.025 * size,
color: color,
);
/// 하단 댓글 입력 — 시안 높이 60 · 지구본 24 · #222225 알약 · 보내기 20.
///
/// 지구본을 누르면 번역 댓글 모드(입력칸 분홍)로 바뀌어 [rootPath] 글 본문의 번역을 쓴다.
/// [replyTo] 가 있으면 그 댓글의 번역이다. [onComment] 가 없으면 번역 댓글만 쓴다.
/// [translation] 이 없으면 지구본을 숨기고 일반 댓글 입력만 남는다.
class CommentInputBar extends StatefulWidget {
const CommentInputBar({
super.key,
required this.hint,
this.onComment,
this.translation,
this.rootPath,
this.replyTo,
this.onClearReply,
this.maxLength,
});
final String hint;
/// 일반 댓글 저장. 성공하면 true — 입력칸을 비운다.
final Future<bool> Function(String body)? onComment;
final TranslationRepository? translation;
final String? rootPath;
final TranslationReplyTarget? replyTo;
final VoidCallback? onClearReply;
/// 일반 댓글 글자 수 상한(규칙과 같은 수). 번역 댓글은 서버 상한 2,000자.
final int? maxLength;
@override
State<CommentInputBar> createState() => _CommentInputBarState();
}
class _CommentInputBarState extends State<CommentInputBar> {
final _controller = TextEditingController();
final _focus = FocusNode();
bool _translateMode = false;
bool _sending = false;
bool get _canTranslate =>
widget.translation != null && widget.rootPath != null;
bool get _translating =>
_canTranslate &&
(_translateMode || widget.replyTo != null || widget.onComment == null);
@override
void didUpdateWidget(CommentInputBar old) {
super.didUpdateWidget(old);
// 댓글을 고르면 키보드를 올린다(명세 「대댓글 버튼 — 댓글 선택 UI 변경 후 키보드 노출」).
if (widget.replyTo != null && widget.replyTo != old.replyTo) {
_focus.requestFocus();
}
}
@override
void dispose() {
_controller.dispose();
_focus.dispose();
super.dispose();
}
void _toggle() {
if (_translating && widget.replyTo != null) widget.onClearReply?.call();
setState(() => _translateMode = !_translating);
if (_translateMode) _focus.requestFocus();
}
Future<void> _send() async {
final body = _controller.text.trim();
if (body.isEmpty || _sending) return;
final onComment = widget.onComment;
if (!_translating && onComment == null) return;
setState(() => _sending = true);
final bool ok;
if (_translating) {
// 번역 언어 = 지금 앱 언어.
final tag = localeTag(
resolveAppLocale([Localizations.localeOf(context)]),
);
final target = widget.replyTo?.path ?? widget.rootPath!;
ok = await _run(
context,
() => widget.translation!.submit(target, tag, body),
context.l10n.translationSubmitted,
);
if (ok) {
_translateMode = false;
widget.onClearReply?.call();
}
} else {
ok = await onComment!(body);
}
if (!mounted) return;
if (ok) _controller.clear();
setState(() => _sending = false);
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final translating = _translating;
final reply = widget.replyTo;
final style = _text(14);
return ColoredBox(
color: AppColors.cosmicVoid,
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.all(10),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (reply != null && translating)
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
children: [
const ImageIcon(
AppIcons.cornerDownRight,
size: 16,
color: AppColors.onCosmicMuted,
),
const SizedBox(width: 6),
Expanded(
child: Text(
l10n.translationReplyTo(reply.name),
style: _text(12, AppColors.onCosmicVariant),
overflow: TextOverflow.ellipsis,
),
),
IconButton(
tooltip: l10n.commonCancel,
visualDensity: VisualDensity.compact,
onPressed: widget.onClearReply,
icon: const ImageIcon(
AppIcons.x,
size: 16,
color: AppColors.onCosmicMuted,
),
),
],
),
),
Row(
children: [
if (_canTranslate) ...[
Semantics(
button: true,
selected: translating,
label: l10n.translationWrite,
child: GestureDetector(
onTap: widget.onComment == null ? null : _toggle,
child: ImageIcon(
AppIcons.globe,
size: 24,
color: translating
? AppColors.cosmicAccent
: AppColors.onCosmic,
),
),
),
const SizedBox(width: 10),
],
Expanded(
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
height: 40,
padding: const EdgeInsets.only(left: 16, right: 6),
decoration: ShapeDecoration(
// 명세 「번역 댓글 작성 버튼 — 입력 필드 색 변경」.
color: translating
? Color.alphaBlend(
AppColors.cosmicAccent.withValues(alpha: 0.18),
const Color(0xFF222225),
)
: const Color(0xFF222225),
shape: const StadiumBorder(),
),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
focusNode: _focus,
style: style,
maxLength: translating ? 2000 : widget.maxLength,
buildCounter: _noCounter,
onSubmitted: (_) => _send(),
cursorColor: AppColors.cosmicAccent,
decoration: InputDecoration(
isCollapsed: true,
filled: false,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
hintText: translating
? l10n.translationHint
: widget.hint,
hintStyle: style.copyWith(
color: const Color(0xFF6B6B6B),
),
),
),
),
IconButton(
tooltip: l10n.commonSend,
onPressed: _sending ? null : _send,
icon: const Image(
image: AppIcons.send,
width: 20,
height: 20,
),
),
],
),
),
),
],
),
],
),
),
),
);
}
}
Widget? _noCounter(
BuildContext context, {
required int currentLength,
required bool isFocused,
required int? maxLength,
}) => null;
/// 번역 댓글 카드 — 시안 `201:50554`(분홍 10% 바탕 · 동작 칸의 지구본).
///
/// 「번역함」 표시와 번역 언어를 달고, 다른 멤버가 유효/무효를 한 번 표시한다.
/// 본인 번역도 버튼은 눌린다 — 저장소가 selfMark(E-TRN-01)로 거절하고 그 이유를 띄운다.
/// [reply] 면 댓글 아래 대댓글 자리(↳)에 붙는다.
class TranslationCommentCard extends StatefulWidget {
const TranslationCommentCard({
super.key,
required this.entry,
required this.repository,
this.reply = false,
});
final TranslationEntry entry;
final TranslationRepository repository;
final bool reply;
@override
State<TranslationCommentCard> createState() => _TranslationCommentCardState();
}
class _TranslationCommentCardState extends State<TranslationCommentCard> {
bool _busy = false;
/// 방금 성공한 마킹. 서버 수가 스트림으로 다시 올 때까지 이 모습을 보여 준다.
TranslationEntry? _marked;
TranslationEntry get _entry {
final marked = _marked;
return marked != null &&
marked.id == widget.entry.id &&
widget.entry.myMark == null
? marked
: widget.entry;
}
Future<void> _mark(bool valid) async {
final entry = _entry;
setState(() => _busy = true);
final ok = await _run(
context,
() => widget.repository.mark(entry, valid: valid),
context.l10n.translationMarked,
);
if (!mounted) return;
setState(() {
_busy = false;
if (ok) _marked = entry.marked(valid);
});
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final e = _entry;
final own = e.authorUid == widget.repository.uid;
final canMark = !_busy && !own && e.myMark == null;
final lang = parseLocaleTag(e.locale);
Widget button(bool valid, int count, String label) => Semantics(
button: true,
enabled: canMark,
child: OutlinedButton(
onPressed: !_busy && e.myMark == null ? () => _mark(valid) : null,
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
foregroundColor: e.myMark == valid
? AppColors.cosmicAccent
: canMark
? AppColors.onCosmic
: AppColors.onCosmicMuted,
),
child: Text('$label $count'),
),
);
final at = e.createdAt;
final card = Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.cosmicAccent.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(2),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
publicName(e.authorName),
style: _text(12),
overflow: TextOverflow.ellipsis,
),
),
// 동작 칸의 지구본 — 번역 댓글 표시(시안 `201:50554`).
Container(
color: const Color(0xFF1A1A1A),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
child: const ImageIcon(
AppIcons.globe,
size: 16,
color: AppColors.onCosmicVariant,
),
),
],
),
const SizedBox(height: AppSpace.s2),
Row(
children: [
TranslationMark(label: l10n.translationMark),
const SizedBox(width: 6),
Text(
lang == null ? e.locale : languageName(lang),
style: _text(11, AppColors.onCosmicMuted),
),
],
),
const SizedBox(height: AppSpace.s2),
Text(e.body, style: _text(14)),
if (at != null) ...[
const SizedBox(height: AppSpace.s2),
Text(_stamp(at), style: _text(10, AppColors.onCosmicMuted)),
],
// 정산이 끝난 번역(확정 · 제외)은 더 마킹하지 않는다.
if (e.markable) ...[
const SizedBox(height: AppSpace.s2),
Row(
children: [
button(true, e.validCount, l10n.translationValid),
const SizedBox(width: AppSpace.s2),
button(false, e.invalidCount, l10n.translationInvalid),
],
),
],
],
),
);
if (!widget.reply) return card;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ImageIcon(
AppIcons.cornerDownRight,
size: 20,
color: AppColors.onCosmicMuted,
),
const SizedBox(width: AppSpace.s3),
Expanded(child: card),
],
);
}
}
/// 시안 댓글 시각 `2026.04.27 09:00`.
String _stamp(DateTime at) {
String two(int v) => v.toString().padLeft(2, '0');
return '${at.year}.${two(at.month)}.${two(at.day)} ${two(at.hour)}:${two(at.minute)}';
}
/// 「번역함」 표시 — 사용자가 등록한 번역임을 원문과 구분한다(FR-I18N-02).
class TranslationMark extends StatelessWidget {
const TranslationMark({super.key, required this.label});
final String label;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
border: Border.all(color: AppColors.cosmicAccent),
borderRadius: BorderRadius.circular(AppRadius.xs),
),
child: Text(
label,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: AppColors.cosmicAccent),
),
);
}
/// [rootPath] 글에 달린 번역 댓글을 원문별로 묶어 [builder] 에 넘긴다.
/// 키는 원문 경로 — 글 본문이면 [rootPath], 댓글이면 그 댓글 경로.
class TranslationThreads extends StatefulWidget {
const TranslationThreads({
super.key,
required this.repository,
required this.rootPath,
required this.builder,
});
final TranslationRepository repository;
final String rootPath;
final Widget Function(
BuildContext context,
Map<String, List<TranslationEntry>> byTarget,
)
builder;
@override
State<TranslationThreads> createState() => _TranslationThreadsState();
}
class _TranslationThreadsState extends State<TranslationThreads> {
int _limit = translationPageSize;
late Stream<List<TranslationEntry>> _stream = _watch();
Stream<List<TranslationEntry>> _watch() =>
widget.repository.watch(widget.rootPath, limit: _limit);
@override
void didUpdateWidget(TranslationThreads old) {
super.didUpdateWidget(old);
if (old.rootPath != widget.rootPath ||
old.repository != widget.repository) {
_limit = translationPageSize;
_stream = _watch();
}
}
@override
Widget build(BuildContext context) => StreamBuilder<List<TranslationEntry>>(
stream: _stream,
builder: (context, snap) {
final rows = snap.data ?? const <TranslationEntry>[];
final byTarget = <String, List<TranslationEntry>>{};
for (final e in rows) {
(byTarget[e.targetPath] ??= []).add(e);
}
final list = widget.builder(context, byTarget);
// 한 페이지가 꽉 찼으면 더 있을 수 있다 — 한 페이지 더 늘려 다시 구독한다(최신순).
if (rows.length < _limit) return list;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
list,
TextButton(
onPressed: () => setState(() {
_limit += translationPageSize;
_stream = _watch();
}),
child: Text(context.l10n.commonMore),
),
],
);
},
);
}
@@ -0,0 +1,334 @@
import 'dart:async';
import 'package:cloud_firestore/cloud_firestore.dart';
import '../../core/data/backend.dart';
/// 참여형 번역 — 요구사항 FR-I18N-02 · 정책 원장 B-63 · 별첨 1 ⑧.
///
/// 데이터 관계(원장 B-63): 번역 등록 → 「번역함」 표시 → 다른 멤버 유효/무효 마킹 →
/// 24시간 pending → `fn-translation-settle` 확정 → 일반 POP 5.
///
/// 화면에서는 댓글 영역의 「번역 댓글」이다(Figma `201:50554` · `540:19136` · 해석 확인
/// 요청서 i18n-참여형번역 2.1). 하단 입력의 지구본으로 쓰면 글 본문의 번역, 댓글을 골라
/// 쓰면 그 댓글의 번역이다(🟡 Q-TR-07 권고 A안).
///
/// 저장 위치는 `translationEntries/{id}`(등록은 `fn-translation-submit` 만) 와
/// `translationEntries/{id}/markings/{uid}`(`valid` bool · 쓰기는 `fn-translation-mark` 만).
/// 번역이 붙는 콘텐츠는 [targetPath](Firestore 문서 경로)로, 그 콘텐츠가 속한 글은
/// [translationRootOf] 로 가리킨다.
class TranslationEntry {
const TranslationEntry({
required this.id,
required this.authorUid,
required this.body,
required this.locale,
this.targetPath = '',
this.authorName = '',
this.createdAt,
this.validCount = 0,
this.invalidCount = 0,
this.myMark,
this.status = 'pending',
});
final String id;
final String authorUid;
final String body;
/// 번역한 원문 — 글 본문(`posts/{id}`) 또는 댓글(`posts/{id}/comments/{cid}`).
final String targetPath;
final String authorName;
final DateTime? createdAt;
/// `ko` · `en` · `ja` · `zh-CN` · `es`.
final String locale;
/// 작성자 본인 마킹은 세지 않은 수(fn-translation-settle 과 같은 셈).
final int validCount;
final int invalidCount;
/// 내가 남긴 마킹. null 이면 아직 안 했다.
final bool? myMark;
/// `pending` 일 때만 마킹을 받는다 — 정산이 끝나면(`confirmed` · `excluded`) 버튼을 숨긴다.
final String status;
bool get markable => status == 'pending';
/// 마킹이 성공한 직후의 모습 — 스트림이 다시 오기 전에 화면이 먼저 바꾼다.
TranslationEntry marked(bool valid) => TranslationEntry(
id: id,
authorUid: authorUid,
body: body,
locale: locale,
targetPath: targetPath,
authorName: authorName,
createdAt: createdAt,
validCount: validCount + (valid ? 1 : 0),
invalidCount: invalidCount + (valid ? 0 : 1),
myMark: valid,
status: status,
);
}
/// 번역 댓글을 여는 화면 — 🟡 Q-TR-06 권고 B안(세 곳 모두). 회신이 A안이면
/// [TranslationSurface.lounge] 를, C안이면 캠페인까지 여기서 뺀다.
enum TranslationSurface { inside, campaignBoard, lounge }
const Set<TranslationSurface> translationSurfaces = {
TranslationSurface.inside,
TranslationSurface.campaignBoard,
TranslationSurface.lounge,
};
/// 콘텐츠 경로가 속한 글 — `posts/p1/comments/c1` → `posts/p1`. 서버 `rootPath` 와 같은 셈.
String translationRootOf(String targetPath) =>
targetPath.split('/').take(2).join('/');
/// 번역 댓글 한 페이지 — 「더 보기」마다 이만큼 늘린다.
const int translationPageSize = 50;
/// 화면이 잡는 번역 실패. 문구는 화면이 [kind] 로 고른다.
enum TranslationFailure {
/// E-TRN-01 — 본인 번역 마킹(정책 설계서 §P-13).
selfMark,
/// E-TRN-05 — 같은 번역에 두 번째 마킹.
alreadyMarked,
/// E-TRN-05 — 같은 콘텐츠·언어에 두 번째 등록.
duplicate,
/// E-TRN-02 — 하루 등록 한도 초과.
dailyCap,
/// E-TRN-03 — 번역할 수 없는(없거나 허용 밖) 콘텐츠.
invalidTarget,
/// E-TRN-04 — 팬덤 멤버가 아니거나 가입 24시간 미만.
notEligible,
other,
}
/// 서버 카탈로그 코드 → 화면 실패 종류. [forMark] 면 E-TRN-05 를 중복 마킹으로 읽는다.
TranslationFailure translationFailureOf(String code, {required bool forMark}) =>
switch (code) {
'E-TRN-01' => TranslationFailure.selfMark,
'E-TRN-02' => TranslationFailure.dailyCap,
'E-TRN-03' => TranslationFailure.invalidTarget,
'E-TRN-04' => TranslationFailure.notEligible,
'E-TRN-05' =>
forMark
? TranslationFailure.alreadyMarked
: TranslationFailure.duplicate,
_ => TranslationFailure.other,
};
class TranslationException implements Exception {
const TranslationException(this.kind);
final TranslationFailure kind;
@override
String toString() => 'TranslationException($kind)';
}
abstract class TranslationRepository {
/// 로그인 사용자. 자기 마킹 차단(E-TRN-01)을 화면이 먼저 판정하는 데 쓴다.
String? get uid;
/// [rootPath] 글과 그 댓글에 달린 번역 댓글(모든 언어 · 최신순) 최대 [limit] 건.
/// 더 보려면 [limit] 을 늘려 다시 구독한다.
Stream<List<TranslationEntry>> watch(
String rootPath, {
int limit = translationPageSize,
});
/// 번역 등록. 같은 콘텐츠·언어 재등록은 [TranslationFailure.duplicate].
Future<void> submit(String targetPath, String locale, String body);
/// 유효/무효 마킹. 본인 번역은 [TranslationFailure.selfMark], 두 번째는 [TranslationFailure.alreadyMarked].
Future<void> mark(TranslationEntry entry, {required bool valid});
}
class FirestoreTranslationRepository implements TranslationRepository {
FirestoreTranslationRepository(this._backend);
final Backend _backend;
@override
String? get uid => _backend.uid;
@override
Stream<List<TranslationEntry>> watch(
String rootPath, {
int limit = translationPageSize,
}) => _backend.db
.collection('translationEntries')
.where('rootPath', isEqualTo: rootPath)
.orderBy('createdAt', descending: true)
.limit(limit)
.snapshots()
.asyncMap((snap) => Future.wait(snap.docs.map(_entry)));
Future<TranslationEntry> _entry(
QueryDocumentSnapshot<Map<String, dynamic>> doc,
) async {
final data = doc.data();
final author = data.strOr('authorUid');
// 수는 번역 문서에 있다(fn-translation-mark 가 같은 트랜잭션에서 올린다). 내 마킹만 한 건
// 읽는다 — 규칙이 남의 마킹 목록을 막는다.
final me = uid;
bool? mine;
if (me != null && me != author) {
final v = (await doc.reference.collection('markings').doc(me).get())
.data()?['valid'];
if (v is bool) mine = v;
}
return TranslationEntry(
id: doc.id,
authorUid: author,
body: data.strOr('body'),
locale: data.strOr('locale'),
targetPath: data.strOr('targetPath'),
authorName: publicName(data['authorName']),
createdAt: data.dateOr('createdAt', DateTime.now()),
validCount: data.intOr('validCount'),
invalidCount: data.intOr('invalidCount'),
myMark: mine,
status: data.strOr('status', 'pending'),
);
}
@override
Future<void> submit(String targetPath, String locale, String body) async {
try {
await _backend.call('fnTranslationSubmit', {
'targetPath': targetPath,
'locale': locale,
'body': body,
});
} on BackendException catch (e) {
throw TranslationException(translationFailureOf(e.code, forMark: false));
}
}
@override
Future<void> mark(TranslationEntry entry, {required bool valid}) async {
final me = _backend.requireUid;
if (entry.authorUid == me) {
throw const TranslationException(TranslationFailure.selfMark);
}
if (entry.myMark != null) {
throw const TranslationException(TranslationFailure.alreadyMarked);
}
// 마킹은 서버 경유다 — 팬덤 멤버 · 가입 24시간 판정(E-TRN-04)을 규칙으로는 못 한다.
try {
await _backend.call('fnTranslationMark', {
'entryId': entry.id,
'valid': valid,
});
} on BackendException catch (e) {
throw TranslationException(translationFailureOf(e.code, forMark: true));
}
}
}
/// 목 모드 · 위젯 테스트용. 서버 판정(중복 등록 · 자기 마킹 · 중복 마킹)을 같은 규칙으로 흉내 낸다.
class SampleTranslationRepository implements TranslationRepository {
SampleTranslationRepository({this.uid = 'me', List<SampleTranslation>? seed})
: _rows = [...?seed];
@override
final String? uid;
final List<SampleTranslation> _rows;
final _changes = StreamController<void>.broadcast();
List<TranslationEntry> _list(String rootPath, int limit) => [
for (final r in _rows.reversed)
if (translationRootOf(r.targetPath) == rootPath)
TranslationEntry(
id: r.id,
authorUid: r.authorUid,
body: r.body,
locale: r.locale,
targetPath: r.targetPath,
authorName: r.authorName,
createdAt: r.createdAt,
validCount: r.marks.entries
.where((e) => e.key != r.authorUid && e.value)
.length,
invalidCount: r.marks.entries
.where((e) => e.key != r.authorUid && !e.value)
.length,
myMark: r.marks[uid],
),
].take(limit).toList();
@override
Stream<List<TranslationEntry>> watch(
String rootPath, {
int limit = translationPageSize,
}) => Stream.multi((out) {
out.add(_list(rootPath, limit));
final sub = _changes.stream.listen((_) => out.add(_list(rootPath, limit)));
out.onCancel = sub.cancel;
});
@override
Future<void> submit(String targetPath, String locale, String body) async {
if (_rows.any(
(r) =>
r.targetPath == targetPath &&
r.locale == locale &&
r.authorUid == uid,
)) {
throw const TranslationException(TranslationFailure.duplicate);
}
_rows.add(
SampleTranslation(
id: 's${_rows.length + 1}',
targetPath: targetPath,
locale: locale,
authorUid: uid ?? '',
body: body.trim(),
),
);
_changes.add(null);
}
@override
Future<void> mark(TranslationEntry entry, {required bool valid}) async {
if (entry.authorUid == uid) {
throw const TranslationException(TranslationFailure.selfMark);
}
final row = _rows.firstWhere((r) => r.id == entry.id);
if (row.marks.containsKey(uid)) {
throw const TranslationException(TranslationFailure.alreadyMarked);
}
row.marks[uid ?? ''] = valid;
_changes.add(null);
}
}
class SampleTranslation {
SampleTranslation({
required this.id,
required this.targetPath,
required this.locale,
required this.authorUid,
required this.body,
this.authorName = '',
DateTime? createdAt,
}) : createdAt = createdAt ?? DateTime.now();
final String id;
final String targetPath;
final String locale;
final String authorUid;
final String body;
final String authorName;
final DateTime createdAt;
final Map<String, bool> marks = {};
}