From 3aca6780729a2719f6dc291497e941b1d56cc1ff Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Tue, 29 Sep 2026 17:06:48 +0900 Subject: [PATCH] =?UTF-8?q?INSIDE=20=C2=B7=20STATION=20=C2=B7=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=ED=98=95=20=EB=B2=88=EC=97=AD=20=C2=B7=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=C2=B7=20MY=20=ED=99=94=EB=A9=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/features/inside/inside_data.dart | 406 +++++++ .../features/inside/inside_detail_screen.dart | 527 +++++++++ .../inside/inside_repository_firestore.dart | 173 +++ app/lib/features/inside/inside_screen.dart | 528 ++++++++- .../features/inside/inside_search_screen.dart | 289 +++++ app/lib/features/inside/inside_ui.dart | 154 +++ .../features/inside/inside_write_screen.dart | 359 ++++++ app/lib/features/my/account_link_screen.dart | 132 +++ app/lib/features/my/my_data.dart | 1033 +++++++++++++++++ app/lib/features/my/my_lists_screen.dart | 213 ++++ .../features/my/my_repository_firestore.dart | 486 ++++++++ app/lib/features/my/my_screen.dart | 535 ++++++++- app/lib/features/my/notice_screens.dart | 129 ++ app/lib/features/my/payment_screens.dart | 468 ++++++++ app/lib/features/my/photo_picker.dart | 43 + app/lib/features/my/pop_history_screen.dart | 273 +++++ app/lib/features/my/profile_edit_screen.dart | 343 ++++++ app/lib/features/my/push_settings_screen.dart | 220 ++++ app/lib/features/my/settings_screen.dart | 709 +++++++++++ app/lib/features/my/support_screens.dart | 450 +++++++ app/lib/features/my/tier_screens.dart | 226 ++++ app/lib/features/my/widgets/my_cards.dart | 435 +++++++ app/lib/features/my/widgets/profile_card.dart | 108 ++ .../notification/notification_data.dart | 241 ++++ .../notification/notification_screen.dart | 201 ++++ app/lib/features/station/station_data.dart | 105 ++ .../station/station_repository_firestore.dart | 63 + app/lib/features/station/station_screen.dart | 208 +++- .../station/station_suggest_screen.dart | 110 ++ .../translation/translation_comments.dart | 570 +++++++++ .../translation/translation_data.dart | 334 ++++++ 31 files changed, 10046 insertions(+), 25 deletions(-) create mode 100644 app/lib/features/inside/inside_data.dart create mode 100644 app/lib/features/inside/inside_detail_screen.dart create mode 100644 app/lib/features/inside/inside_repository_firestore.dart create mode 100644 app/lib/features/inside/inside_search_screen.dart create mode 100644 app/lib/features/inside/inside_ui.dart create mode 100644 app/lib/features/inside/inside_write_screen.dart create mode 100644 app/lib/features/my/account_link_screen.dart create mode 100644 app/lib/features/my/my_data.dart create mode 100644 app/lib/features/my/my_lists_screen.dart create mode 100644 app/lib/features/my/my_repository_firestore.dart create mode 100644 app/lib/features/my/notice_screens.dart create mode 100644 app/lib/features/my/payment_screens.dart create mode 100644 app/lib/features/my/photo_picker.dart create mode 100644 app/lib/features/my/pop_history_screen.dart create mode 100644 app/lib/features/my/profile_edit_screen.dart create mode 100644 app/lib/features/my/push_settings_screen.dart create mode 100644 app/lib/features/my/settings_screen.dart create mode 100644 app/lib/features/my/support_screens.dart create mode 100644 app/lib/features/my/tier_screens.dart create mode 100644 app/lib/features/my/widgets/my_cards.dart create mode 100644 app/lib/features/my/widgets/profile_card.dart create mode 100644 app/lib/features/notification/notification_data.dart create mode 100644 app/lib/features/notification/notification_screen.dart create mode 100644 app/lib/features/station/station_data.dart create mode 100644 app/lib/features/station/station_repository_firestore.dart create mode 100644 app/lib/features/station/station_suggest_screen.dart create mode 100644 app/lib/features/translation/translation_comments.dart create mode 100644 app/lib/features/translation/translation_data.dart diff --git a/app/lib/features/inside/inside_data.dart b/app/lib/features/inside/inside_data.dart new file mode 100644 index 0000000..16afa65 --- /dev/null +++ b/app/lib/features/inside/inside_data.dart @@ -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 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 replies; +} + +class InsideSnapshot { + const InsideSnapshot({ + required this.questions, + this.sort = InsideSort.latest, + this.canWrite = true, + }); + + final List questions; + final InsideSort sort; + + /// 작성 플로팅 버튼 노출 조건. 명세 「해당 팬덤에 가입된 유저에게만 노출」. + final bool canWrite; +} + +abstract interface class InsideRepository { + /// 첫 장. 명세가 「최초 5개, 스크롤 시 5개씩」이라 기본값을 5로 둔다. + Future load({InsideSort sort = InsideSort.latest}); + + /// 다음 장. 더 없으면 빈 목록을 준다. + Future> more({required InsideSort sort, int limit = 5}); + + /// 현상금을 걸 수 있는 보유 POP — 일반 + 기여. 예치는 겸용이다(B-39 · B-06, 일반 우선). + Future balance(); + + /// 현상금 하한 — 서버와 같은 `config/policy.qnaMinBountyPop`(기본 100 · 운영값). + Future minBounty(); + + /// 질문 등록 + 현상금 예치(`fn-question-create`). 새 질문 ID 를 준다. 실패는 던진다. + Future create({ + required String title, + required String body, + required int bountyPop, + required String clientSeq, + }); + + /// 답변 목록 — 명세 2.61-3 「최신순 소팅」. + Future> answers(String questionId); + + /// 답변 등록. 재화가 오가지 않아 규칙이 클라이언트 쓰기를 허용한다. + Future answer(String questionId, String body); + + /// 채택(`fn-answer-accept`). 현상금이 답변자에게 간다. 번복되지 않는다. + Future 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 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 _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 load({InsideSort sort = InsideSort.latest}) async => + InsideSnapshot(questions: _sorted(sort).take(5).toList(), sort: sort); + + /// 표본 모드에서 단 답변. 서버가 없으니 앱이 떠 있는 동안만 남는다. + static final Map> _answers = {}; + + @override + Future balance() async => 12_400; + + @override + Future minBounty() async => 100; + + /// 표본은 원장이 없다 — 화면 동선만 태운다(캠페인 표본의 `create` 와 같다). + @override + Future create({ + required String title, + required String body, + required int bountyPop, + required String clientSeq, + }) async => 'q-new'; + + @override + Future> answers(String questionId) async => + List.of(_answers[questionId] ?? const []); + + @override + Future 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 accept(String questionId, String answerId) async { + final list = _answers[questionId] ?? const []; + 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> more({ + required InsideSort sort, + int limit = 5, + }) async => _sorted(sort).skip(5).take(limit).toList(); +} + +/// 시안 「인사이드 상세(타인)」(201:49678)의 댓글 표본. 답변 목록 읽기 경로가 아직 없어 +/// 화면 대조와 시연에서만 쓴다. +final List 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), + ), + ], + ), +]; diff --git a/app/lib/features/inside/inside_detail_screen.dart b/app/lib/features/inside/inside_detail_screen.dart new file mode 100644 index 0000000..0e280cc --- /dev/null +++ b/app/lib/features/inside/inside_detail_screen.dart @@ -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 answers; + final InsideRepository? repository; + + /// 참여형 번역(FR-I18N-02 · B-63). 있으면 댓글 입력의 지구본으로 번역 댓글을 쓰고, + /// 번역 댓글을 댓글 목록에 지구본 표시와 함께 그린다. + final TranslationRepository? translation; + + @override + State createState() => _InsideDetailScreenState(); +} + +class _InsideDetailScreenState extends State { + late InsideQuestion question = widget.question; + late List 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 _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 _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 _accept(InsideAnswer a) async { + final ok = await showDialog( + 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> 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( + 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( + 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)), + ); +} diff --git a/app/lib/features/inside/inside_repository_firestore.dart b/app/lib/features/inside/inside_repository_firestore.dart new file mode 100644 index 0000000..abe18ea --- /dev/null +++ b/app/lib/features/inside/inside_repository_firestore.dart @@ -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? _user; + + Future> _me() async => _user ??= + (await _backend.db.doc('users/${_backend.requireUid}').get()).data() ?? + const {}; + + Future _resolveFandomId() async => (await _me()).strOr('fandomId'); + + DocumentSnapshot>? _cursor; + InsideSort? _cursorSort; + + static const int pageSize = 5; + + Query> _query(InsideSort sort, String fandomId) { + Query> q = _backend.db.collection('questions'); + if (fandomId.isNotEmpty) { + q = q.where('fandomId', isEqualTo: fandomId); + } + return q.orderBy(sort.field, descending: true); + } + + @override + Future 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> 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> 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 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 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 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> 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 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 accept(String questionId, String answerId) => _backend.call( + 'fnAnswerAccept', + {'questionId': questionId, 'answerId': answerId}, + ); +} diff --git a/app/lib/features/inside/inside_screen.dart b/app/lib/features/inside/inside_screen.dart index ba1ea15..4af6f38 100644 --- a/app/lib/features/inside/inside_screen.dart +++ b/app/lib/features/inside/inside_screen.dart @@ -1,15 +1,527 @@ 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 팬덤 라운지. -class InsideScreen extends StatelessWidget { - const InsideScreen({super.key}); +/// INSIDE 탭 루트 — 화면 대조 원장 F3. +/// +/// 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 - Widget build(BuildContext context) => const TabPlaceholder( - tab: 'INSIDE', - screenId: 'S-I-LOUNGE', - screenName: '팬덤 라운지', + State createState() => _InsideScreenState(); +} + +class _InsideScreenState extends State { + final _scroll = ScrollController(); + + List _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 _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 _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 _open(InsideQuestion q) async { + await Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => InsideDetailScreen( + question: q, + repository: widget.repository, + translation: widget.translation, + ), + ), + ); + } + + void _search() => Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => InsideSearchScreen( + repository: widget.repository, + translation: widget.translation, + ), + ), + ); + + void _notifications() => + Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => NotificationScreen(repository: widget.notifications), + ), + ); + + Future _write() async { + final created = await Navigator.of(context, rootNavigator: true).push( + 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 questions; + final ValueChanged 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 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), + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/features/inside/inside_search_screen.dart b/app/lib/features/inside/inside_search_screen.dart new file mode 100644 index 0000000..e5ba76a --- /dev/null +++ b/app/lib/features/inside/inside_search_screen.dart @@ -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 createState() => _InsideSearchScreenState(); +} + +class _InsideSearchScreenState extends State { + final _controller = TextEditingController(); + + /// 최근 검색어. 기기 안에만 두는 값이다(갤럭시 검색과 같다). + final List _recent = []; + + String? _term; + InsideSort _sort = InsideSort.latest; + List? _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 _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( + 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 onSubmitted; + final ValueChanged 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 terms; + final VoidCallback onClear; + final ValueChanged 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)), + ], + ), + ), + ], + ); + } +} diff --git a/app/lib/features/inside/inside_ui.dart b/app/lib/features/inside/inside_ui.dart new file mode 100644 index 0000000..e584a8c --- /dev/null +++ b/app/lib/features/inside/inside_ui.dart @@ -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), + ], + ); + } +} diff --git a/app/lib/features/inside/inside_write_screen.dart b/app/lib/features/inside/inside_write_screen.dart new file mode 100644 index 0000000..bf24500 --- /dev/null +++ b/app/lib/features/inside/inside_write_screen.dart @@ -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 createState() => _InsideWriteScreenState(); +} + +class _InsideWriteScreenState extends State { + final _form = GlobalKey(); + 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 _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( + 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), + ), + ), + ], + ), + ); +} diff --git a/app/lib/features/my/account_link_screen.dart b/app/lib/features/my/account_link_screen.dart new file mode 100644 index 0000000..b0802d5 --- /dev/null +++ b/app/lib/features/my/account_link_screen.dart @@ -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 createState() => _AccountLinkScreenState(); +} + +class _AccountLinkScreenState extends State { + 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 _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), + ); + } +} diff --git a/app/lib/features/my/my_data.dart b/app/lib/features/my/my_data.dart new file mode 100644 index 0000000..417f611 --- /dev/null +++ b/app/lib/features/my/my_data.dart @@ -0,0 +1,1033 @@ +/// MY 가 그리는 값의 형태 — 화면 대조 원장 F6. +/// +/// 근거는 Figma 명세 구간이다. +/// - 프로필 카드 `549:22058` · 사진 설정 `549:22105`~`549:22129` · 닉네임 `549:22136` +/// - 티어 정보 `549:22185` · 티어 상세 `549:22206` +/// - 설정 `554:22628`~`554:22903` · POP 내역 `554:23732` +/// - 결제 내역 `554:23841`·`554:23961` · 환불 `554:23978`·`554:24008` +/// - 푸시 알림 `554:24049`·`554:24062` · 차단 `554:24090` +library; + +import 'dart:math' as math; +import 'dart:typed_data'; + +import '../../core/data/backend.dart'; +import '../../core/i18n/app_locale.dart'; + +/// POP 종류 — 정책 원장 **B-01**. +/// +/// 확정값은 「일반 POP / 기여 POP 으로 사용자 표기 통일」이고, 검증 기준이 +/// *앱·Admin 사용자 노출 문자열에 「활동 POP」·「지원 POP」·「무료 POP」·「유료 POP」 0건* +/// 이다. 그래서 **화면에 나가는 이름은 이 두 개뿐**이다. +/// +/// 별첨 1 ② 의 「무료/유료 POP 원장 분리」는 회계 내부 표기이고(B-01 비고 +/// *회계 원장의 무료/유료 구분은 내부 표기로만 유지*), 사용자 표기로 쓰지 않는다. +enum PopKind { + general, + contribution; + + String get label => switch (this) { + general => appL10n.popKindGeneral, + contribution => appL10n.popKindContribution, + }; + + /// 원장 `popType` → 종류. + /// + /// 설계서 §2.5 는 `general`·`contribution` 으로 적었고 배포된 Functions 는 + /// `activity`·`support` 로 쓴다(`firebase/functions/src/ledger/ledger.ts`). + /// 둘은 같은 두 종류를 가리키므로(B-84 — 구매자에게 기여 POP · 퀘스트 보상은 + /// 일반 POP) 네 값을 모두 받는다. 한쪽만 받으면 원장 표기가 조용히 뒤집힌다. + static PopKind parse(String raw) => switch (raw) { + 'contribution' || 'support' => PopKind.contribution, + _ => PopKind.general, + }; +} + +/// 원장 거래 종류 — 설계서 §3 · Functions `TxType`. +enum PopTxType { + grant, + spend, + refund, + voided; + + String get label => switch (this) { + grant => appL10n.popTxGrant, + spend => appL10n.popTxSpend, + refund => appL10n.popTxRefund, + voided => appL10n.popTxVoid, + }; + + static PopTxType parse(String raw) => switch (raw) { + 'spend' => PopTxType.spend, + 'refund' => PopTxType.refund, + 'void' => PopTxType.voided, + _ => PopTxType.grant, + }; +} + +/// 원장 `reasonCode` → 화면의 활동명·활동 타입. +/// +/// 명세 `554:23732` 가 한 줄에 *활동명 · 활동 타입* 둘을 요구한다. 코드 목록은 +/// Functions 의 `ReasonCode`(`firebase/functions/src/ledger/ledger.ts`)와 짝이며, +/// 모르는 코드는 원문을 그대로 내보이지 않고 중립 문구로 접는다 — 사용자 화면에 +/// `QUEST_MUSIC_SHAKE` 같은 내부 식별자가 새면 고장으로 읽힌다. +({String title, String type}) popReasonLabel(String reasonCode) => + switch (reasonCode) { + 'QUEST_STEPS' => ( + title: appL10n.popReasonQuestSteps, + type: appL10n.popTypeQuest, + ), + 'QUEST_MUSIC_SHAKE' => ( + title: appL10n.popReasonQuestMusicShake, + type: appL10n.popTypeQuest, + ), + 'QUEST_PLACE' => ( + title: appL10n.popReasonQuestPlace, + type: appL10n.popTypeQuest, + ), + 'QUEST_ATTEND' => ( + title: appL10n.popReasonQuestAttend, + type: appL10n.popTypeQuest, + ), + 'QUEST_UGC' => ( + title: appL10n.popReasonQuestUgc, + type: appL10n.popTypeQuest, + ), + 'QUEST_FEE' => ( + title: appL10n.popReasonQuestFee, + type: appL10n.popTypeFee, + ), + 'QUEST_POOL_DEPOSIT' => ( + title: appL10n.popReasonQuestPoolDeposit, + type: appL10n.popTypeQuest, + ), + // 정상 종료 반환(C-09)과 공개 전 실패 환불은 사용자에게 같은 「반환」이다. + 'QUEST_POOL_RETURN' || 'QUEST_POOL_REFUND' => ( + title: appL10n.popReasonQuestPoolReturn, + type: appL10n.popTxRefund, + ), + 'QUEST_TIMEATTACK' => ( + title: appL10n.popReasonQuestTimeAttack, + type: appL10n.popTypeQuest, + ), + 'AD_REWARD' => ( + title: appL10n.popReasonAdReward, + type: appL10n.popTypeReward, + ), + 'HERO_DROP' => ( + title: appL10n.popReasonHeroDrop, + type: appL10n.popTypeReward, + ), + 'HERO_DROP_TRIGGER' => ( + title: appL10n.popReasonHeroDropTrigger, + type: appL10n.commonContribute, + ), + 'TRANSLATION' => ( + title: appL10n.popReasonTranslation, + type: appL10n.popTypeReward, + ), + 'IAP_PURCHASE' => ( + title: appL10n.popReasonIapPurchase, + type: appL10n.popTypePayment, + ), + 'FOCUS_DAILY' => ( + title: appL10n.popReasonFocusDaily, + type: appL10n.popTypeSubscription, + ), + 'PREREG_BONUS' => ( + title: appL10n.popReasonPreregBonus, + type: appL10n.popTypeReward, + ), + 'REFERRAL_REWARD' => ( + // 사본 POP 내역 `554:23742` 「친구 초대 · 레퍼럴」. + title: appL10n.popReasonReferral, + type: appL10n.popTypeReferral, + ), + 'ADMIN_GRANT' => ( + title: appL10n.popReasonAdminGrant, + type: appL10n.popTypeAdmin, + ), + 'ADMIN_CLAWBACK' => ( + title: appL10n.popReasonAdminClawback, + type: appL10n.popTypeAdmin, + ), + 'CAMPAIGN_FUND' => ( + title: appL10n.popReasonCampaignFund, + type: appL10n.commonContribute, + ), + 'CAMPAIGN_REFUND' => ( + title: appL10n.popReasonCampaignRefund, + type: appL10n.popTxRefund, + ), + 'CAMPAIGN_FEE_REFUND' => ( + title: appL10n.popReasonCampaignFeeRefund, + type: appL10n.popTxRefund, + ), + 'CAMPAIGN_FEE' => ( + title: appL10n.popReasonCampaignFee, + type: appL10n.popTypeFee, + ), + 'QNA_BOUNTY' => ( + title: appL10n.popReasonQnaBounty, + type: appL10n.commonContribute, + ), + 'QNA_REFUND' => ( + title: appL10n.popReasonQnaRefund, + type: appL10n.popTxRefund, + ), + 'QNA_REWARD' => ( + title: appL10n.popReasonQnaReward, + type: appL10n.popTypeReward, + ), + 'ECLIPSE_DEFENSE' => ( + title: appL10n.popReasonEclipseDefense, + type: appL10n.commonContribute, + ), + _ => (title: appL10n.popReasonOther, type: appL10n.commonEtc), + }; + +/// 레벨 칭호 — 정책 원장 **C-18 · B-66**. +/// +/// User EXP 로 오르는 `userLevel` 의 **구간 이름**일 뿐이다. 누적 기여 POP 문턱으로 +/// 승급하던 v0.3.0 의 5등급은 2026-09-16 회신으로 기각됐다(R04). 칭호별 권한·보상은 +/// 없다 — 그래서 여기에는 이름과 시작 레벨만 둔다. +enum LevelTitle { + glow('GLOW', 1), + spark('SPARK', 10), + beam('BEAM', 30), + starlight('STARLIGHT', 60), + nova('NOVA', 100); + + const LevelTitle(this.label, this.minLevel); + + final String label; + + /// 이 칭호가 시작되는 레벨. + final int minLevel; + + /// 그 레벨의 칭호. NOVA 는 끝이 없다 — Lv.100 이후에도 숫자만 오른다. + static LevelTitle of(int level) => LevelTitle.values.lastWhere( + (t) => level >= t.minLevel, + orElse: () => glow, + ); + + /// 화면 표기 「Lv.1~9」 · 「Lv.100~」. + String get rangeLabel { + final i = LevelTitle.values.indexOf(this); + return i + 1 < LevelTitle.values.length + ? 'Lv.$minLevel~${LevelTitle.values[i + 1].minLevel - 1}' + : 'Lv.$minLevel~'; + } +} + +/// 레벨 L 에 닿는 누적 User EXP. Lv.1 은 0 부터다. +/// +/// 서버 `firebase/functions/src/core/level.ts` 의 `expForLevel` 과 **같은 식**이어야 +/// 진행도가 서버 레벨과 어긋나지 않는다. 앱은 레벨을 스스로 매기지 않고 진행도에만 쓴다. +// S01 확정 전 유지 기준(S01 A안) — Lv.L→L+1 요구량 = 243 × L^1.5 를 10 단위 반올림, 누적은 그 합. +int expForLevel(int level) { + var total = 0; + for (var l = 1; l < level; l++) { + total += (243 * math.pow(l, 1.5) / 10).round() * 10; + } + return total; +} + +/// Passport Tier — 정책 원장 **B-65**. 레벨과 별개 필드이며 강등하지 않는다. +enum PassportTier { + silver('SILVER'), + gold('GOLD'), + diamond('DIAMOND'); + + const PassportTier(this.label); + + final String label; + + /// 서버 문자열(`users.passportTier`). 없거나 모르는 값은 기본 SILVER. + static PassportTier parse(String raw) => PassportTier.values.firstWhere( + (t) => t.label == raw.toUpperCase(), + orElse: () => PassportTier.silver, + ); +} + +/// 소속 행성별 내 기여 — 명세 `549:22206`. +/// +/// *갤럭시에서 선택된 기본 행성의 티어를 메인으로 상단에 위치 ·* +/// *나머지 행성 티어는 티어 높은순으로 소팅* +/// +/// 칭호는 개인 `userLevel` 에서만 나온다(C-18). 행성별로는 **기여 실적(POP)만** +/// 보인다 — 행성 기여로 칭호를 매기면 MY 카드와 칭호가 갈린다(인계 결함). +class PlanetTier { + const PlanetTier({ + required this.fandomName, + required this.contributedPop, + this.isPrimary = false, + }); + + final String fandomName; + final int contributedPop; + + /// 갤럭시에서 고른 기본 행성인가. + final bool isPrimary; +} + +/// MY 첫 화면이 쓰는 값. +class MyProfile { + const MyProfile({ + required this.nickname, + required this.email, + required this.contributedPop, + this.userExp = 0, + this.userLevel = 1, + this.passportTier = PassportTier.silver, + required this.balancePop, + required this.steps, + required this.completedQuests, + required this.planetTiers, + this.photoLabel, + this.photoUrl, + this.originNo, + }); + + final String nickname; + final String email; + + /// 누적 기여 POP. 레벨과 무관한 기여 실적이다(C-18). + final int contributedPop; + + /// 누적 User EXP — `users.userExp`. + final int userExp; + + /// 레벨 — `users.userLevel`. **서버가 준 값**이며 앱이 다시 매기지 않는다. + final int userLevel; + + /// Passport Tier — `users.passportTier`(B-65). + final PassportTier passportTier; + + /// 보유 POP(활동). + final int balancePop; + + /// 누적 걸음. 명세 `549:22058` 의 프로필 카드 항목. + final int steps; + final int completedQuests; + + final List planetTiers; + + /// 프로필 사진. 자산이 아직 없어 라벨로 둔다. + final String? photoLabel; + + /// 프로필 사진 원본 URL — `users/{uid}.photoUrl`. 없으면 시안 표본을 그린다. + final String? photoUrl; + + /// ORIGIN 100 창립 멤버 순번. 정책 §P-04. + final int? originNo; + + /// 레벨 칭호 — 개인 레벨에서만 나온다. + LevelTitle get title => LevelTitle.of(userLevel); + + /// 「Lv.N TITLE」. + String get levelLabel => 'Lv.$userLevel ${title.label}'; + + /// 다음 레벨까지 남은 EXP. + int get toNextLevel => + (expForLevel(userLevel + 1) - userExp).clamp(0, 1 << 31); + + /// 현재 레벨 구간의 진행도 0~1. + double get levelProgress { + final from = expForLevel(userLevel); + final span = expForLevel(userLevel + 1) - from; + if (span <= 0) return 1; + return ((userExp - from) / span).clamp(0.0, 1.0); + } +} + +/// 다음 장을 읽을 위치. +/// +/// 시각 하나로는 **같은 `createdAt` 을 가진 행**을 가릴 수 없다. 한 트랜잭션이 +/// 일반·기여 두 종류를 각각 원장에 쓰면 두 행의 서버 타임스탬프가 같아지는데, +/// 시각만 비교하면 경계에서 한 행이 사라지거나 두 번 나온다. 그래서 문서 ID 를 +/// 함께 들고 다니며 `(createdAt, documentId)` 두 단계로 정렬·이어읽기한다. +class PopCursor { + const PopCursor({required this.at, required this.id}); + + final DateTime at; + + /// 원장 문서 ID. 표본은 자리만 채운다. + final String id; +} + +/// POP 내역 한 줄 — 명세 `554:23732`. +/// +/// 이름에 `Ledger` 를 넣은 것은 Flutter 의 `PopEntry`(라우트 pop 처리)와 이름이 +/// 겹치기 때문이다. material 을 임포트한 화면에서 어느 쪽인지 알 수 없어진다. +/// +/// *날짜 · 활동명 · 활동 타입 · 증감 POP 값 · 누적 POP 값* +class PopLedgerEntry { + const PopLedgerEntry({ + required this.at, + required this.title, + required this.type, + required this.delta, + required this.balanceAfter, + this.kind = PopKind.general, + this.txType = PopTxType.grant, + this.repeatCount, + this.cursor, + }); + + final DateTime at; + final String title; + final String type; + + /// 일반 POP / 기여 POP — 정책 원장 B-01. 별첨 1 ② 「원장 분리 유지」를 한 줄 + /// 안에서 보이는 지점이다. + final PopKind kind; + + /// 지급 · 차감 · 환불 · 무효. 부호만으로는 환불과 지급이 구분되지 않는다. + final PopTxType txType; + + /// 부호 있는 값. 서버 원장의 `amount` 와 같은 규약이다. + final int delta; + final int balanceAfter; + + /// Zap 처럼 하루에 여러 번 일어나는 활동의 횟수. + /// + /// 명세 — *Zap 횟수에 따라 리스트 생성이 아닌 당일 기준 총기여 값 및 시간* + /// *update 하는 방식으로 노출. 예) Zap 128회* + final int? repeatCount; + + /// 이 줄 **다음**을 읽을 위치. + /// + /// 접힌 Zap 줄은 [at] 이 그룹에서 가장 최신이므로(명세가 *당일 기준 총기여 값 및 + /// 시간 update* 를 요구한다) 커서로 쓸 수 없다. 커서는 그 그룹에서 **가장 오래된** + /// 행의 위치다. 접히지 않은 줄은 자기 자신의 위치를 들고 있다. + final PopCursor? cursor; + + /// 같은 날 Zap 인가. 접기 대상 판정에 쓴다. + /// + /// 접힌 줄과 접히지 않은 Zap 줄 모두 [repeatCount] 를 들고 있으므로 이 값으로 + /// 가른다. 조건을 저장소와 화면에 두 벌로 적으면 한쪽만 고쳐진다. + bool foldsWith(PopLedgerEntry other) => + repeatCount != null && + other.repeatCount != null && + title == other.title && + at.year == other.at.year && + at.month == other.at.month && + at.day == other.at.day; + + /// [other] 를 이 줄에 접어 넣은 결과. + /// + /// 시각과 누적 POP 은 더 최신인 이쪽을 유지하고, 증감은 더하고, 커서는 더 + /// 오래된 저쪽으로 내린다. 누적 값을 저쪽 것으로 바꾸면 그 줄이 보여 주는 + /// 잔액이 그 시점의 실제 잔액과 달라진다. + PopLedgerEntry fold(PopLedgerEntry other) => PopLedgerEntry( + at: at, + title: title, + type: type, + kind: kind, + txType: txType, + delta: delta + other.delta, + balanceAfter: balanceAfter, + repeatCount: (repeatCount ?? 1) + (other.repeatCount ?? 1), + cursor: other.cursor, + ); + + String get displayTitle => + repeatCount == null ? title : appL10n.popRepeat(title, '$repeatCount'); + + /// 목록 한 줄의 부가 정보. 명세가 요구한 활동 타입에 거래 종류와 POP 종류를 함께 붙인다. + String get displaySubtitle => '$type · ${txType.label} · ${kind.label}'; +} + +/// 결제 상태 — 명세 `554:23961` 이 다섯을 못박았다. +enum PaymentState { + paid, + refundRequested, + refundApproved, + refunded, + refundRejected; + + String get label => switch (this) { + paid => appL10n.payStatePaid, + refundRequested => appL10n.payStateRefundRequested, + refundApproved => appL10n.payStateRefundApproved, + refunded => appL10n.payStateRefunded, + refundRejected => appL10n.payStateRefundRejected, + }; + + /// 환불을 신청할 수 있는가. 결제 완료 상태에서만 열린다. + bool get canRequestRefund => this == PaymentState.paid; +} + +/// 서버 결제 상태 → 화면 다섯 상태. +/// +/// `verified`(지급 전)도 결제는 끝난 상태라 「결제 완료」로 본다. 회수 대기 +/// (`clawback-pending`)는 스토어 환불이 이미 끝난 것이라 「환불 완료」다. +PaymentState paymentStateOf(String state) => switch (state) { + 'refund-requested' => PaymentState.refundRequested, + 'refund-approved' => PaymentState.refundApproved, + 'refund-rejected' => PaymentState.refundRejected, + 'refunded' || 'clawed-back' || 'clawback-pending' => PaymentState.refunded, + _ => PaymentState.paid, +}; + +/// Focus 구독 — 원장 **B-04** *광고 제거 + 일 40 기여 POP, 당일 Claim, 미수령 누적 없음*. +class FocusStatus { + const FocusStatus({required this.expiresAt, required this.claimedToday}); + + final DateTime expiresAt; + + /// 오늘(서버 일 경계 KST) 몫을 이미 받았는가. + final bool claimedToday; +} + +class PaymentEntry { + const PaymentEntry({ + required this.id, + required this.at, + required this.itemName, + required this.price, + required this.method, + required this.state, + }); + + final String id; + final DateTime at; + final String itemName; + final int price; + final String method; + final PaymentState state; +} + +/// 푸시 알림 구분 — 정책 원장 B-77 확정 **5 개 구분**. +/// +/// 별첨 B — *서비스 필수 / 팬덤 이벤트(Eclipse·Campaign·Quest·Artist Hot Time) /* +/// *AI Briefing / Community / Marketing 으로 분리합니다. Marketing 은 별도 동의* +/// *기본 OFF, 나머지 선택형 알림은 사용자 개별 OFF 가능하도록 합니다.* +/// +/// **Figma `554:24062` 의 「디폴트 알림 전부 활성화」와 토글 6 종을 대체한다.** +/// 공통 기준 2항 — Figma 의 과거 표기가 이후 확정된 정책보다 우선하지 않는다. +/// 이클립스·좋아요·댓글·퀘스트·캠페인 6 종은 확정된 5 개 구분 안으로 들어간다. +/// +/// 서버는 구분마다 FCM 토픽을 따로 두고(`fandom_{fandomId}_{name}`) 앱이 자기 +/// 설정대로 구독·해지한다. 그래서 **OFF 구분의 발송이 구조적으로 0 건**이 된다. +enum PushTopic { + /// 계정·결제·점검 등 끌 수 없는 알림. 화면에 노출하되 토글은 잠근다. + essential(locked: true), + fandomEvent(), + briefing(), + community(), + + /// 별첨 B — *Marketing 은 별도 동의 기본 OFF*. + marketing(defaultOn: false); + + const PushTopic({this.defaultOn = true, this.locked = false}); + + String get label => switch (this) { + essential => appL10n.pushTopicEssential, + fandomEvent => appL10n.pushTopicFandomEvent, + briefing => appL10n.pushTopicBriefing, + community => appL10n.pushTopicCommunity, + marketing => appL10n.pushTopicMarketing, + }; + + /// 설정 화면의 보조 문구. Marketing 의 별도 동의 문구가 여기 들어간다. + String get description => switch (this) { + essential => appL10n.pushTopicEssentialDesc, + fandomEvent => appL10n.pushTopicFandomEventDesc, + briefing => appL10n.pushTopicBriefingDesc, + community => appL10n.pushTopicCommunityDesc, + marketing => appL10n.pushTopicMarketingDesc, + }; + + /// 초기값. Marketing 만 false 다. + final bool defaultOn; + + /// 사용자가 끌 수 없는 구분인가. 서비스 필수만 true 다. + final bool locked; + + /// 서버가 쓰는 FCM 토픽 이름. `firebase/functions/src/notify/push.ts` 의 + /// `fandomTopic` 과 **같은 규약이어야 한다.** + /// + /// FCM 토픽은 `[a-zA-Z0-9-_.~%]` 만 받는다. 서버는 그 밖의 문자를 `-` 로 바꿔 + /// 보내므로 앱도 같이 바꿔야 한다. 한쪽만 치환하면 팬덤 ID 에 공백이나 슬래시가 + /// 섞인 순간 서버가 보내는 토픽과 앱이 구독한 토픽이 갈리고, 알림이 오지 않는 + /// 원인이 이름 불일치라는 것은 로그에 드러나지 않는다. + String topicFor(String fandomId) => + 'fandom_${fandomId.replaceAll(RegExp(r'[^a-zA-Z0-9\-_.~%]'), '-')}_$name'; + + /// 정책 기본값. `users/{uid}.notificationPrefs` 가 비었을 때 쓴다. + static Map get defaults => { + for (final t in PushTopic.values) t: t.defaultOn, + }; +} + +class NoticeItem { + const NoticeItem({ + required this.id, + required this.title, + required this.body, + required this.at, + }); + + final String id; + final String title; + final String body; + final DateTime at; +} + +/// 닉네임 길이 — 정책 원장 **B-52**(2~20자 · 중복 허용). +/// +/// 검증 기준이 *20자 성공 · 21자 거부* 이므로 경계를 상수로 두고 화면과 규칙이 +/// 같은 값을 본다. `firestore.rules` 의 `users` 갱신 조건에 같은 두 수가 있다. +const int nicknameMinLength = 2; +const int nicknameMaxLength = 20; + +/// 운영 사칭으로 읽힐 수 있는 이름 — 대소문자 무시 부분 일치로 막는다. +/// `firestore.rules` 의 `nicknameOk` 가 같은 목록을 본다. +const List reservedNicknameWords = [ + 'fanuzu', '운영팀', '관리자', 'admin', 'official', +]; + +/// 닉네임 없는 작성자 표기([storedFanName] 「팬」)와 그 번역 — 정확히 일치하면 막는다. +/// `firestore.rules` 의 `nicknameOk` 가 같은 목록을 본다. +const List reservedFanNames = [storedFanName, 'fan', 'ファン', '粉丝']; + +/// 예약어 판정용 정규화 — 폭 없는 문자(U+200B~200D · U+FEFF)를 지우고 전각 공백(U+3000)을 +/// 공백으로 본 뒤 앞뒤 공백을 떼고 소문자로. 규칙 `nicknameKey` 와 같은 계산. +String normalizeNickname(String nickname) => nickname + .replaceAll(RegExp('[\u200B-\u200D\uFEFF]'), '') + .replaceAll('\u3000', ' ') + .trim() + .toLowerCase(); + +bool nicknameReserved(String nickname) { + final key = normalizeNickname(nickname); + return reservedFanNames.contains(key) || reservedNicknameWords.any(key.contains); +} + +/// 차단한 계정 한 건 — 설계서 `users/{uid}/blocks/{targetUid}`. +/// +/// 문서 ID 가 차단 대상 UID 다. 이름만 들고 있으면 해제할 때 누구를 지우는지 +/// 알 수 없어(동명이인) 문서 ID 를 그대로 들고 다닌다. +class BlockedUser { + const BlockedUser({required this.uid, required this.nickname, this.at}); + + final String uid; + final String nickname; + final DateTime? at; +} + +/// 1:1 문의 처리 상태 — Figma `373:634991`·`373:634998`·`507:38223` 세 값. +enum InquiryStatus { + pending, + inProgress, + answered; + + String get label => switch (this) { + pending => appL10n.inquiryPending, + inProgress => appL10n.inquiryInProgress, + answered => appL10n.inquiryAnswered, + }; + + /// `inquiries.status` → 상태. 값은 `pending` · `in_progress` · `answered` + /// 세 개로 확정되어 있다(Firestore 설계서 v1.2 개정분). 모르는 값은 접수로 + /// 접는다 — 빈 칸보다 낫고, 상태를 올려 보이는 것보다 안전하다. + static InquiryStatus parse(String raw) => switch (raw) { + 'in_progress' => InquiryStatus.inProgress, + 'answered' => InquiryStatus.answered, + _ => InquiryStatus.pending, + }; +} + +/// 내 문의 한 건 — Figma 목록 `373:634916` · 상세 `373:635153`. +class InquiryItem { + const InquiryItem({ + required this.id, + required this.title, + required this.body, + required this.at, + required this.status, + this.answerBody, + this.answeredAt, + }); + + final String id; + final String title; + final String body; + final DateTime at; + final InquiryStatus status; + + /// 운영 답변. 명세 `376:14163`~`376:14165` — 답변 · 날짜 · 본문. + final String? answerBody; + final DateTime? answeredAt; +} + +/// 고른 사진 한 장. 업로드 직전의 바이트와 종류를 함께 들고 다닌다. +class PickedPhoto { + const PickedPhoto({ + required this.bytes, + required this.contentType, + required this.name, + }); + + final Uint8List bytes; + final String contentType; + final String name; +} + +/// 프로필 사진 상한 — 정책 원장 **B-44** 「이미지 5 장 10MB」의 1 장 상한. +/// +/// 프로필 사진 전용 상한이 따로 확정된 문서가 없어 같은 이미지 상한을 쓴다. +/// 이 값을 Storage 규칙(`firebase/storage.rules`)이 서버에서 한 번 더 본다 — +/// B-44 데이터 관계가 *클라이언트 1차 검증 → Storage 규칙 서버 검증* 이다. +const int profilePhotoMaxBytes = 10 * 1024 * 1024; + +/// 허용 형식. B-44 의 「이미지 / GIF」를 그대로 옮겼다. +const List profilePhotoContentTypes = [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', +]; + +/// 사진이 상한·형식에서 걸린 경우. 화면이 사유를 그대로 띄운다. +class PhotoRejected implements Exception { + const PhotoRejected(this.message); + + final String message; + + @override + String toString() => 'PhotoRejected: $message'; +} + +/// 사진을 1차 검증한다. 통과하지 못하면 [PhotoRejected] 를 던진다. +/// +/// 업로드 직전에 여기를 지나므로 앨범·카메라·테스트 어느 경로로 들어와도 같은 +/// 판정을 받는다. 호출부마다 조건문을 두면 한 경로만 고쳐지는 일이 생긴다. +void checkProfilePhoto(PickedPhoto photo) { + if (!profilePhotoContentTypes.contains(photo.contentType)) { + throw PhotoRejected(appL10n.photoBadType); + } + if (photo.bytes.lengthInBytes > profilePhotoMaxBytes) { + throw PhotoRejected(appL10n.photoTooBig); + } + if (photo.bytes.isEmpty) { + throw PhotoRejected(appL10n.photoUnreadable); + } +} + +abstract interface class MyRepository { + Future profile(); + + /// 명세 — *디폴트 최신순으로 20개 노출*. + /// + /// [after] 다음의 거래만 돌려준다. 화면이 마지막 줄의 + /// [PopLedgerEntry.cursor] 를 다시 넣으면 다음 장이 온다. + Future> popHistory({int limit = 20, PopCursor? after}); + + Future> payments(); + + Future payment(String id); + + Future requestRefund(String paymentId, String reason); + + /// Focus 구독 상태. 구독 중(만료 전)이 아니면 null — MY 에 카드를 그리지 않는다. + Future focus(); + + /// Focus 오늘 몫 받기(원장 B-04). 받은 기여 POP 을 돌려준다. 구독이 아니면 E-PAY-03. + Future claimFocusDaily(); + + Future> pushSettings(); + + Future setPush(PushTopic topic, bool on); + + Future> blockedUsers(); + + /// 차단 추가 — Figma `527:21092` 차단 안내 모달의 확인. + Future block(String targetUid, {String? nickname}); + + Future unblock(String targetUid); + + Future> notices(); + + Future updateNickname(String nickname); + + /// 프로필 사진 업로드. 저장된 URL 을 돌려준다. + /// + /// 상한·형식 위반은 [PhotoRejected], 그 밖의 실패는 저장소 구현이 던지는 + /// 예외가 화면까지 올라온다. + Future uploadProfilePhoto(PickedPhoto photo); + + Future submitInquiry(String title, String body); + + /// 문의 수정 — Figma `554:24330`. 접수 상태에서만 제목·본문을 고친다. + /// 상태를 올리는 것은 운영이므로 여기서 보내지 않는다. + Future updateInquiry(String id, String title, String body); + + /// 내 문의 목록 — Figma `373:634916`. + Future> myInquiries(); + + /// 앱 버전. 명세 — *최신버전 → 「최신 버전」 · 이전버전 → 「n.n」*. + Future<({String current, bool latest})> appVersion(); +} + +/// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.** +class SampleMyRepository implements MyRepository { + SampleMyRepository(); + + static final _base = DateTime(2026, 9, 1, 12); + + String _nickname = '아미보이'; + // 정책 기본값 — Marketing 만 OFF(별첨 B). 전부 true 로 두면 표본이 정책과 어긋난다. + final Map _push = PushTopic.defaults; + final Map _blocked = {'u-spam': '스팸계정', 'u-adbot': '광고봇'}; + + /// 올린 프로필 사진. 실 구현이 `users/{uid}.photoUrl` 을 고치는 자리다. + String? _photoUrl; + final List _inquiries = []; + final Map _paymentStates = {}; + + @override + Future profile() async => MyProfile( + nickname: _nickname, + email: 'fan@fanuzu.co.kr', + contributedPop: 24800, + // Admin Figma 표본 「Lv.18 · User EXP 128,450」. Lv.30 미만이라 SILVER(B-65 초기 기준). + userExp: 128450, + userLevel: 18, + balancePop: 12540, + steps: 184200, + completedQuests: 37, + photoUrl: _photoUrl, + originNo: 42, + planetTiers: const [ + PlanetTier(fandomName: 'BTS', contributedPop: 24800, isPrimary: true), + PlanetTier(fandomName: 'NewJeans', contributedPop: 4100), + PlanetTier(fandomName: 'IVE', contributedPop: 900), + ], + ); + + /// 표본 원장. **차감을 음수로 적는다** — 서버 원장이 `amount` 에 부호를 실어 + /// 쓰고(`ledger.ts` 의 `spendPop`) 설계서 §3.4 대조식이 단순 합산으로 잔액을 + /// 재현하므로, 표본이 양수를 쓰면 화면 검증이 거짓으로 통과한다. + @override + Future> popHistory({ + int limit = 20, + PopCursor? after, + }) async { + final all = [ + PopLedgerEntry( + at: _base.subtract(const Duration(minutes: 30)), + title: 'Zap 기여', + type: '기여', + kind: PopKind.general, + txType: PopTxType.spend, + delta: -1280, + balanceAfter: 12540, + repeatCount: 128, + ), + PopLedgerEntry( + at: _base.subtract(const Duration(hours: 4)), + title: '만보기 퀘스트', + type: '퀘스트', + kind: PopKind.general, + txType: PopTxType.grant, + delta: 50, + balanceAfter: 13820, + ), + PopLedgerEntry( + at: _base.subtract(const Duration(days: 1)), + title: '캠페인 후원', + type: '기여', + kind: PopKind.contribution, + txType: PopTxType.spend, + delta: -500, + balanceAfter: 13770, + ), + PopLedgerEntry( + at: _base.subtract(const Duration(days: 2)), + title: '캠페인 환불', + type: '환불', + kind: PopKind.contribution, + txType: PopTxType.refund, + delta: 500, + balanceAfter: 14270, + ), + PopLedgerEntry( + at: _base.subtract(const Duration(days: 3)), + title: '상품 구매', + type: '결제', + kind: PopKind.contribution, + txType: PopTxType.grant, + delta: 5500, + balanceAfter: 13770, + ), + ]; + // 표본은 시각이 모두 달라 시각 비교만으로 충분하다. 서버 구현은 문서 ID 까지 + // 본다([PopCursor]). + final page = after == null + ? all + : all.where((e) => e.at.isBefore(after.at)).toList(); + return [ + for (final e in page.take(limit)) + PopLedgerEntry( + at: e.at, + title: e.title, + type: e.type, + kind: e.kind, + txType: e.txType, + delta: e.delta, + balanceAfter: e.balanceAfter, + repeatCount: e.repeatCount, + cursor: PopCursor( + at: e.at, + id: 'sample-${e.at.microsecondsSinceEpoch}', + ), + ), + ]; + } + + @override + Future> payments() async => [ + // 상품명에 「지원 POP」을 쓰지 않는 것은 B-01(사용자 표기는 일반/기여 POP)과 + // B-02(스토어에 POP 자체 판매 상품 0건) 둘 다에 걸리기 때문이다. 결제 상품은 + // 혜택으로 기여 POP 이 붙는 묶음이다(B-84). + _payment('p1', '서포터 팩', 7900, const Duration(days: 2)), + _payment('p2', 'Focus 구독 1개월', 4900, const Duration(days: 32)), + _payment('p3', '부스트 앰플', 2900, const Duration(days: 61)), + ]; + + PaymentEntry _payment(String id, String name, int price, Duration ago) => + PaymentEntry( + id: id, + at: _base.subtract(ago), + itemName: name, + price: price, + method: '신용카드', + state: _paymentStates[id] ?? PaymentState.paid, + ); + + @override + Future payment(String id) async { + final all = await payments(); + return all.firstWhere((p) => p.id == id, orElse: () => all.first); + } + + @override + Future requestRefund(String paymentId, String reason) async { + _paymentStates[paymentId] = PaymentState.refundRequested; + } + + /// 표본은 구독하지 않은 사용자다 — 결제 없이 혜택 카드를 보여 주지 않는다. + @override + Future focus() async => null; + + @override + Future claimFocusDaily() async => + throw const BackendException('E-PAY-03', 'Focus 구독 중일 때만 받을 수 있습니다.'); + + @override + Future> pushSettings() async => {..._push}; + + @override + Future setPush(PushTopic topic, bool on) async => _push[topic] = on; + + @override + Future> blockedUsers() async => [ + for (final e in _blocked.entries) + BlockedUser(uid: e.key, nickname: e.value), + ]; + + @override + Future block(String targetUid, {String? nickname}) async => + _blocked[targetUid] = nickname ?? targetUid; + + @override + Future unblock(String targetUid) async => _blocked.remove(targetUid); + + @override + Future> notices() async => [ + NoticeItem( + id: 'n1', + title: '서비스 점검 안내', + body: '9월 3일 새벽 2시부터 4시까지 점검이 있습니다.', + at: _base.subtract(const Duration(days: 1)), + ), + NoticeItem( + id: 'n2', + title: '이클립스 방어 이벤트', + body: '이번 주 방어에 참여하시면 추가 POP 을 드립니다.', + at: _base.subtract(const Duration(days: 6)), + ), + ]; + + @override + Future updateNickname(String nickname) async { + // 중복 닉네임을 서버가 판정한다. 표본은 한 이름만 막아 화면 동선을 보인다. + if (nickname == '아미보이2') { + throw const NicknameTakenException(); + } + _nickname = nickname; + } + + @override + Future uploadProfilePhoto(PickedPhoto photo) async { + checkProfilePhoto(photo); + return _photoUrl = 'https://example.invalid/${photo.name}'; + } + + @override + Future submitInquiry(String title, String body) async { + _inquiries.insert( + 0, + InquiryItem( + id: 'q${_inquiries.length + 1}', + title: title, + body: body, + at: DateTime.now(), + status: InquiryStatus.pending, + ), + ); + } + + @override + Future updateInquiry(String id, String title, String body) async { + final i = _inquiries.indexWhere((e) => e.id == id); + if (i < 0) return; + final old = _inquiries[i]; + _inquiries[i] = InquiryItem( + id: old.id, + title: title, + body: body, + at: old.at, + status: old.status, + answerBody: old.answerBody, + answeredAt: old.answeredAt, + ); + } + + @override + Future> myInquiries() async => [ + ..._inquiries, + InquiryItem( + id: 'q0', + title: '구매 상품 환불 관련 문의 접수', + body: '결제한 상품이 지급되지 않았습니다.', + at: _base.subtract(const Duration(days: 4)), + status: InquiryStatus.answered, + answerBody: '확인 후 지급 처리했습니다. 이용에 불편을 드려 죄송합니다.', + answeredAt: _base.subtract(const Duration(days: 3)), + ), + ]; + + @override + Future<({String current, bool latest})> appVersion() async => + (current: '0.1.0', latest: true); +} + +/// 닉네임 중복 — 명세 `549:22136` *중복된 닉네임일 경우 닉네임 필드 아래* +/// *헬프 텍스트로 오류 메시지 노출*. +class NicknameTakenException implements Exception { + const NicknameTakenException(); +} diff --git a/app/lib/features/my/my_lists_screen.dart b/app/lib/features/my/my_lists_screen.dart new file mode 100644 index 0000000..e8e413e --- /dev/null +++ b/app/lib/features/my/my_lists_screen.dart @@ -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 createState() => _MyListsScreenState(); +} + +class _MyListsScreenState extends State { + _MySort _sort = _MySort.latest; + + /// 표본 — 시안 `370:228289` 문구. 서버가 붙으면 INSIDE 저장소의 내 글로 옮긴다. + static final _insides = () { + final now = DateTime.now(); + return [ + 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( + builder: (_) => InsideDetailScreen(question: items[i]), + ), + ), + ), + ); + } + + Widget _campaignList(BuildContext context) => + FutureBuilder>( + 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( + builder: (_) => CampaignDetailScreen( + campaignId: mine[i].id, + repository: widget.campaigns, + ), + ), + ), + ), + ); + }, + ); +} diff --git a/app/lib/features/my/my_repository_firestore.dart b/app/lib/features/my/my_repository_firestore.dart new file mode 100644 index 0000000..d0dfe12 --- /dev/null +++ b/app/lib/features/my/my_repository_firestore.dart @@ -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 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 {}; + final wallet = (await walletF).data() ?? const {}; + + final fandomId = user.strOr('fandomId'); + var tiers = const []; + 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 {}; + final member = (await memberF).data() ?? const {}; + 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> 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 {}; + return { + for (final topic in PushTopic.values) + topic: saved[topic.name] is bool + ? saved[topic.name] as bool + : topic.defaultOn, + }; + } + + @override + Future 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 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> 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> 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 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 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 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 claimFocusDaily() async => + (await _backend.call('fnFocusDailyClaim')).intOr('granted'); + + PaymentEntry _payment(String id, Map 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> 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> 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 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 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 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 updateInquiry(String id, String title, String body) async { + await _backend.db.doc('inquiries/$id').update({ + 'title': title, + 'body': body, + 'updatedAt': FieldValue.serverTimestamp(), + }); + } + + @override + Future> 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 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 _foldZap(List<_LedgerRow> rows) { + final folded = []; + 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); diff --git a/app/lib/features/my/my_screen.dart b/app/lib/features/my/my_screen.dart index 1623342..21789bc 100644 --- a/app/lib/features/my/my_screen.dart +++ b/app/lib/features/my/my_screen.dart @@ -1,15 +1,534 @@ 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 마이 페이지. -class MyScreen extends StatelessWidget { - const MyScreen({super.key}); +/// MY 탭 루트 — 화면 대조 원장 F6 · Figma `373:632962`. +/// +/// 명세 `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 - Widget build(BuildContext context) => const TabPlaceholder( - tab: 'MY', - screenId: 'S-M-HOME', - screenName: '마이 페이지', + State createState() => _MyScreenState(); +} + +class _MyScreenState extends State { + MyProfile? _profile; + FocusStatus? _focus; + bool _claiming = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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 _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 _changePhoto(MyProfile profile) async { + final photo = await Navigator.of(context, rootNavigator: true) + .push( + 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 _push(Widget screen) => Navigator.of( + context, + rootNavigator: true, + ).push(MaterialPageRoute(builder: (_) => screen)); + + /// 프로필 카드 오버레이 — 시안 `374:15738` · 명세 `549:22058`. 공유 4종이 붙는다. + Future _showCard(MyProfile profile) => showDialog( + 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 _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(), + ], + ), + ), ); } diff --git a/app/lib/features/my/notice_screens.dart b/app/lib/features/my/notice_screens.dart new file mode 100644 index 0000000..3b8272f --- /dev/null +++ b/app/lib/features/my/notice_screens.dart @@ -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 createState() => _NoticeListScreenState(); +} + +class _NoticeListScreenState extends State { + List? _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( + 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)), + ], + ), + ); +} diff --git a/app/lib/features/my/payment_screens.dart b/app/lib/features/my/payment_screens.dart new file mode 100644 index 0000000..bf85e15 --- /dev/null +++ b/app/lib/features/my/payment_screens.dart @@ -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 createState() => _PaymentHistoryScreenState(); +} + +class _PaymentHistoryScreenState extends State { + List? _payments; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final payments = await widget.repository.payments(); + if (mounted) setState(() => _payments = payments); + } + + Future _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( + 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 createState() => _PaymentDetailScreenState(); +} + +class _PaymentDetailScreenState extends State { + PaymentEntry? _payment; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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 _requestRefund(PaymentEntry payment) async { + final reason = await Navigator.of(context, rootNavigator: true) + .push( + 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 reasonsOf(AppLocalizations l10n) => [ + l10n.refundReasonMistake, + l10n.refundReasonExpectation, + l10n.refundReasonError, + l10n.refundReasonDuplicate, + l10n.commonEtc, + ]; + + static List get reasons => reasonsOf(appL10n); + + @override + State createState() => _RefundRequestScreenState(); +} + +class _RefundRequestScreenState extends State { + 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 _confirm() async { + final ok = await showCupertinoDialog( + 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(); +} diff --git a/app/lib/features/my/photo_picker.dart b/app/lib/features/my/photo_picker.dart new file mode 100644 index 0000000..0615a9e --- /dev/null +++ b/app/lib/features/my/photo_picker.dart @@ -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 Function(PhotoSource source); + +/// 기본 구현 — `image_picker`. +/// +/// 앨범·카메라 권한 문구는 iOS `Info.plist` 의 `NSPhotoLibraryUsageDescription`· +/// `NSCameraUsageDescription` 에 이미 들어가 있다(BUILD.md 「권한 사용 문구 6종」). +/// 문구가 없으면 권한을 묻는 순간 앱이 종료되므로 여기서 다시 확인해 둔다. +Future 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'; +} diff --git a/app/lib/features/my/pop_history_screen.dart b/app/lib/features/my/pop_history_screen.dart new file mode 100644 index 0000000..a221927 --- /dev/null +++ b/app/lib/features/my/pop_history_screen.dart @@ -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 createState() => _PopHistoryScreenState(); +} + +class _PopHistoryScreenState extends State { + MyProfile? _profile; + final List _entries = []; + bool _loadingMore = false; + + /// 더 읽을 것이 남았는가. + /// + /// **장의 길이로 판정하지 않는다.** Zap 을 당일 한 줄로 접으므로 원장에서 20건을 + /// 읽어도 화면 줄이 그보다 적게 나온다. 길이로 끝을 판정하면 접힌 장 뒤에 남은 + /// 내역이 조용히 사라진다. 빈 장이 올 때까지 열어 둔다. + bool _hasMore = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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 _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 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, + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/app/lib/features/my/profile_edit_screen.dart b/app/lib/features/my/profile_edit_screen.dart new file mode 100644 index 0000000..70ec737 --- /dev/null +++ b/app/lib/features/my/profile_edit_screen.dart @@ -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 createState() => _ProfileEditScreenState(); +} + +class _ProfileEditScreenState extends State { + 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 _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 createState() => _ProfilePhotoScreenState(); +} + +class _ProfilePhotoScreenState extends State { + 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 _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, + ), + ), + ); +} diff --git a/app/lib/features/my/push_settings_screen.dart b/app/lib/features/my/push_settings_screen.dart new file mode 100644 index 0000000..2cdd677 --- /dev/null +++ b/app/lib/features/my/push_settings_screen.dart @@ -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 Function()? onRequestPermission; + + /// 설정이 바뀐 직후 토픽 구독을 다시 맞추는 콜백. + /// + /// **저장만으로는 발송이 멈추지 않는다.** 서버는 토픽으로만 보내므로, 문서에 + /// `false` 를 적어도 그 토픽을 구독한 채면 알림이 계속 온다(B-77 검증 기준 + /// 「OFF 카테고리 발송 0건」). 그래서 저장과 구독 갱신을 같은 자리에서 한다. + final Future Function(Map prefs)? onSettingsChanged; + + @override + State createState() => _PushSettingsScreenState(); +} + +class _PushSettingsScreenState extends State { + Map? _settings; + bool _allowed = true; + bool _requesting = false; + + @override + void initState() { + super.initState(); + _allowed = widget.deviceAllowed; + _load(); + } + + Future _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 _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), + ); + } +} diff --git a/app/lib/features/my/settings_screen.dart b/app/lib/features/my/settings_screen.dart new file mode 100644 index 0000000..0cf3208 --- /dev/null +++ b/app/lib/features/my/settings_screen.dart @@ -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 createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + MyProfile? _profile; + Map _linked = const {}; + ({String current, bool latest})? _version; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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 _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 _push(Widget screen) async { + await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => screen)); + if (mounted) await _load(); + } + + /// 푸시 알림 설정 진입. + /// + /// 권한 상태를 **들어가기 전에** 읽는다. 화면 안에서 읽으면 로딩 한 프레임 동안 + /// 토글이 보이고 곧 「꺼져 있습니다」로 바뀌어, 방금 끈 것처럼 보인다. + /// 푸시 경계가 없으면(목 모드) 허용 상태로 두어 토글 동선은 그대로 확인된다. + Future _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 _pickLanguage() async { + final picked = await showModalBottomSheet( + 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 _notice(String message) => showDialog( + 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 _confirm({ + required String title, + String? body, + required String action, + bool withdraw = false, + Future Function()? onConfirm, + }) async { + final ok = await showDialog( + 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 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), + ), + ], + ), + ), + ); +} diff --git a/app/lib/features/my/support_screens.dart b/app/lib/features/my/support_screens.dart new file mode 100644 index 0000000..1b022cc --- /dev/null +++ b/app/lib/features/my/support_screens.dart @@ -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 createState() => _InquiryScreenState(); +} + +class _InquiryScreenState extends State { + List? _items; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final items = await widget.repository.myInquiries(); + if (mounted) setState(() => _items = items); + } + + @override + Widget build(BuildContext context) { + final items = _items; + if (items == null) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + // 시안 `373:634845` — 카드 #19191c · 모서리 16 · 안쪽 20/16 · 간격 12 · 제목 15/600 · + // 상태 칩 #222222 모서리 4 · 날짜 12/600 #777 · 아래 「문의 작성」 주 버튼. + return Scaffold( + appBar: AppBar(title: Text(context.l10n.inquiryListTitle)), + body: items.isEmpty + ? Center( + child: Text(context.l10n.inquiryEmpty, style: myText(14, FontWeight.w500)), + ) + : ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: items.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (context, i) { + final item = items[i]; + return GestureDetector( + onTap: () => Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => InquiryDetailScreen( + inquiry: item, + repository: widget.repository, + onChanged: _load, + ), + ), + ), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 20, + ), + decoration: BoxDecoration( + color: MyColors.card, + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 24, + child: Row( + children: [ + Expanded( + child: Text( + item.title, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 15, + fontWeight: FontWeight.w600, + height: 1, + color: AppColors.onCosmic, + ), + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 6, + ), + decoration: BoxDecoration( + color: const Color(0xFF222222), + borderRadius: BorderRadius.circular( + AppRadius.xs, + ), + ), + child: Text( + item.status.label, + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 12, + fontWeight: FontWeight.w500, + height: 1, + color: AppColors.onCosmic, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 12), + Text( + '${item.at.year}.${item.at.month.toString().padLeft(2, '0')}.${item.at.day.toString().padLeft(2, '0')}', + style: const TextStyle( + fontFamily: AppFonts.body, + fontSize: 12, + fontWeight: FontWeight.w600, + height: 1, + color: MyColors.muted, + ), + ), + ], + ), + ), + ); + }, + ), + // 시안 — 버튼 아래 16 을 홈 인디케이터 34 위에 둔다(minimum 은 인셋과 겹쳐 16 이 사라진다). + bottomNavigationBar: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: FilledButton( + onPressed: _compose, + style: FilledButton.styleFrom( + backgroundColor: AppColors.cosmicAccent, + foregroundColor: Colors.black, + ), + child: Text(context.l10n.inquiryWrite), + ), + ), + ), + ); + } + + Future _compose() async { + final sent = await Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => InquiryComposeScreen(repository: widget.repository), + ), + ); + if (sent == true) await _load(); + } +} + +/// 문의 상세 — Figma `373:635153`. +/// +/// 「수정」은 접수 상태에서만 뜬다. 규칙이 `pending` 인 본인 문의의 제목·본문만 +/// 허용하므로(`firestore.rules` 의 `inquiries`), 답변이 올라간 뒤에 입구를 두면 +/// 누를 수는 있고 저장은 실패하는 화면이 된다. +class InquiryDetailScreen extends StatelessWidget { + const InquiryDetailScreen({ + super.key, + required this.inquiry, + required this.repository, + this.onChanged, + }); + + final InquiryItem inquiry; + final MyRepository repository; + + /// 수정이 끝나면 목록을 다시 읽게 알린다. + final VoidCallback? onChanged; + + @override + Widget build(BuildContext context) { + final t = Theme.of(context).textTheme; + return Scaffold( + appBar: AppBar( + title: Text(context.l10n.inquiryDetail), + actions: [ + if (inquiry.status == InquiryStatus.pending) + TextButton( + onPressed: () => _edit(context), + child: Text(context.l10n.commonEdit), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(AppSpace.s4), + children: [ + Text(inquiry.title, style: t.titleMedium), + const SizedBox(height: AppSpace.s2), + Text( + '${inquiry.status.label} · ${relativeTime(inquiry.at)}', + style: t.labelSmall, + ), + const SizedBox(height: AppSpace.s4), + Text(inquiry.body, style: t.bodyMedium), + if (inquiry.answerBody case final answer?) ...[ + const Divider(height: AppSpace.s6), + Text(context.l10n.inquiryAnswer, style: t.titleSmall), + if (inquiry.answeredAt case final at?) + Text(relativeTime(at), style: t.labelSmall), + const SizedBox(height: AppSpace.s2), + Text(answer, style: t.bodyMedium), + ], + ], + ), + ); + } + + Future _edit(BuildContext context) async { + final saved = await Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute( + builder: (_) => + InquiryComposeScreen(repository: repository, edit: inquiry), + ), + ); + if (saved != true) return; + onChanged?.call(); + if (context.mounted) Navigator.of(context).pop(); + } +} + +/// 새 문의 작성 — Figma `373:635052` · 문의 수정 `554:24330`. +/// +/// 두 프레임의 구조가 제목·본문·완료로 같아서 화면을 하나만 둔다. [edit] 이 +/// 있으면 수정이고, 없으면 새 문의다. 두 벌로 적으면 열 글자 규칙 같은 것이 +/// 한쪽에서만 고쳐진다. +class InquiryComposeScreen extends StatefulWidget { + const InquiryComposeScreen({super.key, required this.repository, this.edit}); + + final MyRepository repository; + + /// 고칠 문의. 접수 상태의 본인 문의만 넘어온다. + final InquiryItem? edit; + + @override + State createState() => _InquiryComposeScreenState(); +} + +class _InquiryComposeScreenState extends State { + late final _title = TextEditingController(text: widget.edit?.title ?? ''); + late final _body = TextEditingController(text: widget.edit?.body ?? ''); + bool _sending = false; + + @override + void initState() { + super.initState(); + _title.addListener(() => setState(() {})); + _body.addListener(() => setState(() {})); + } + + @override + void dispose() { + _title.dispose(); + _body.dispose(); + super.dispose(); + } + + bool get _ready => + _title.text.trim().isNotEmpty && _body.text.trim().length >= 10; + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: Text( + widget.edit == null + ? context.l10n.inquiryNew + : context.l10n.inquiryEdit, + ), + actions: [ + TextButton( + onPressed: _ready && !_sending ? _send : null, + child: Text(context.l10n.commonDone), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(AppSpace.s4), + children: [ + TextField( + controller: _title, + decoration: InputDecoration( + labelText: context.l10n.commonTitle, + hintText: context.l10n.commonTitleHint, + ), + ), + const SizedBox(height: AppSpace.s4), + TextField( + controller: _body, + maxLines: 8, + decoration: InputDecoration( + labelText: context.l10n.inquiryBody, + hintText: context.l10n.inquiryBodyHint, + // 너무 짧으면 되물어야 해서 처리가 오래 걸린다. + helperText: context.l10n.inquiryBodyHelper, + ), + ), + ], + ), + ); + + Future _send() async { + final edit = widget.edit; + setState(() => _sending = true); + try { + if (edit == null) { + await widget.repository.submitInquiry( + _title.text.trim(), + _body.text.trim(), + ); + } else { + await widget.repository.updateInquiry( + edit.id, + _title.text.trim(), + _body.text.trim(), + ); + } + } on Exception catch (error) { + if (!mounted) return; + setState(() => _sending = false); + _toast( + context, + edit == null + ? context.l10n.inquirySubmitFailed(_reason(error)) + : context.l10n.inquiryEditFailed(_reason(error)), + ); + return; + } + if (!mounted) return; + _toast( + context, + edit == null + ? context.l10n.inquirySubmitted + : context.l10n.inquiryEdited, + ); + Navigator.of(context).pop(true); + } +} + +/// 차단 설정 — 명세 `554:24090` · Figma `357:223694` *차단한 프로필이 표시됩니다.* +/// +/// 화면 대조 원장 F6 의 21건에는 별도 행이 없으나, 설정의 「차단 설정」이 +/// 가리키는 화면이라 함께 만든다. 링크만 두고 화면이 없으면 막다른 길이 된다. +/// +/// 목록과 해제만 있는 것이 Figma 그대로다. 차단을 **추가**하는 입구는 게시물· +/// 댓글·프로필의 「신고/차단 바텀시트」(`527:18638`)이며 저장소의 +/// [MyRepository.block] 이 그 자리에서 불린다. +class BlockedScreen extends StatefulWidget { + const BlockedScreen({super.key, required this.repository}); + + final MyRepository repository; + + @override + State createState() => _BlockedScreenState(); +} + +class _BlockedScreenState extends State { + List? _blocked; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final blocked = await widget.repository.blockedUsers(); + if (mounted) setState(() => _blocked = blocked); + } + + @override + Widget build(BuildContext context) { + final blocked = _blocked; + if (blocked == null) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + final t = Theme.of(context).textTheme; + + return Scaffold( + appBar: AppBar(title: Text(context.l10n.settingsBlocked)), + body: blocked.isEmpty + ? Center(child: Text(context.l10n.blockedEmpty, style: t.bodyMedium)) + : ListView.builder( + padding: const EdgeInsets.all(AppSpace.s4), + itemCount: blocked.length, + itemBuilder: (context, i) { + final user = blocked[i]; + return ListTile( + contentPadding: EdgeInsets.zero, + leading: CircleAvatar( + backgroundColor: AppColors.cosmicSurfaceRaised, + child: Text(user.nickname.characters.first), + ), + title: Text(user.nickname), + trailing: TextButton( + onPressed: () => _unblock(user), + child: Text(context.l10n.unblock), + ), + ); + }, + ), + ); + } + + Future _unblock(BlockedUser user) async { + try { + await widget.repository.unblock(user.uid); + } on Exception catch (error) { + if (!mounted) return; + _toast(context, context.l10n.unblockFailed(_reason(error))); + return; + } + await _load(); + } +} + +/// 실패 사유 한 줄. 저장소가 던지는 예외를 화면 문구로 바꾼다. +/// +/// 내부 예외 문자열을 그대로 띄우지 않는 것은 경로·스택이 새기 때문이다 +/// (README 「목 모드에서의 로그인」과 같은 이유). +String _reason(Object error) => switch (error) { + PhotoRejected(:final message) => message, + _ => appL10n.commonTryLater, +}; + +void _toast(BuildContext context, String message) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), + ); +} diff --git a/app/lib/features/my/tier_screens.dart b/app/lib/features/my/tier_screens.dart new file mode 100644 index 0000000..f46ebeb --- /dev/null +++ b/app/lib/features/my/tier_screens.dart @@ -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 tiers; + + /// 기본 행성이 맨 앞, 나머지는 기여 POP 높은 순. + List 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( + 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, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); +} diff --git a/app/lib/features/my/widgets/my_cards.dart b/app/lib/features/my/widgets/my_cards.dart new file mode 100644 index 0000000..ea672e3 --- /dev/null +++ b/app/lib/features/my/widgets/my_cards.dart @@ -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 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 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(), + ], + ), + ), + ); +} diff --git a/app/lib/features/my/widgets/profile_card.dart b/app/lib/features/my/widgets/profile_card.dart new file mode 100644 index 0000000..06bbf9e --- /dev/null +++ b/app/lib/features/my/widgets/profile_card.dart @@ -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 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 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, + ), + ), + ), + ), + ), + ), + ], + ], + ), + ], + ), + ); +} diff --git a/app/lib/features/notification/notification_data.dart b/app/lib/features/notification/notification_data.dart new file mode 100644 index 0000000..0d78c2b --- /dev/null +++ b/app/lib/features/notification/notification_data.dart @@ -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 items})> groupNotifications( + List items, { + DateTime? now, +}) { + final sorted = [...items]..sort((a, b) => b.at.compareTo(a.at)); + final groups = >{}; + 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> load({int limit = 50}); + + /// 읽음 처리. 명세가 뒤로가기에서 읽음 상태를 돌려주라 했으므로 화면을 떠날 + /// 때가 아니라 **누른 그 순간** 서버에 남긴다. 떠날 때 한꺼번에 보내면 앱이 + /// 죽거나 통신이 끊긴 경우에 읽음이 사라진다. + Future markRead(String id); +} + +/// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.** +class SampleNotificationRepository implements NotificationRepository { + /// 목록을 뒤늦게 만드는 이유는 `const` 다. HOME·GALAXY 가 기존 표본 저장소와 + /// 같은 모양으로 기본값에 쓸 수 있어야 한다. + const SampleNotificationRepository({List? items}) + : _items = items; + + final List? _items; + + static List _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> load({int limit = 50}) async => + (_items ?? _defaults()).take(limit).toList(); + + @override + Future markRead(String id) async {} +} + +/// 실 서버 구현 — `users/{uid}/notifications`. +/// +/// 규칙이 본인 읽기와 `read`·`readAt` 두 필드 수정만 허용한다. 생성과 삭제는 +/// Functions(FCM) 경유라 앱에서 부를 경로 자체가 없다. +class FirestoreNotificationRepository implements NotificationRepository { + const FirestoreNotificationRepository(this._backend); + + final Backend _backend; + + @override + Future> 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 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); diff --git a/app/lib/features/notification/notification_screen.dart b/app/lib/features/notification/notification_screen.dart new file mode 100644 index 0000000..4a90d82 --- /dev/null +++ b/app/lib/features/notification/notification_screen.dart @@ -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 createState() => _NotificationScreenState(); +} + +class _NotificationScreenState extends State { + List? _items; + final Set _read = {}; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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 _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'); diff --git a/app/lib/features/station/station_data.dart b/app/lib/features/station/station_data.dart new file mode 100644 index 0000000..86d9fc3 --- /dev/null +++ b/app/lib/features/station/station_data.dart @@ -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 links; + + /// 제안 버튼 노출. 로그인 사용자만 제안할 수 있다(규칙 `authorIsSelf()`). + final bool canSuggest; +} + +abstract interface class StationRepository { + Future load(); +} + +/// 서버가 붙기 전에 쓰는 표본. +class SampleStationRepository implements StationRepository { + const SampleStationRepository(); + + @override + Future 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, + ), + ], + ); +} diff --git a/app/lib/features/station/station_repository_firestore.dart b/app/lib/features/station/station_repository_firestore.dart new file mode 100644 index 0000000..80da1f1 --- /dev/null +++ b/app/lib/features/station/station_repository_firestore.dart @@ -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 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, + }; +} diff --git a/app/lib/features/station/station_screen.dart b/app/lib/features/station/station_screen.dart index 021dd1e..a6cbce2 100644 --- a/app/lib/features/station/station_screen.dart +++ b/app/lib/features/station/station_screen.dart @@ -1,15 +1,205 @@ 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 허브. -class StationScreen extends StatelessWidget { - const StationScreen({super.key}); +/// STATION 탭 루트 — S-S-HUB. +/// +/// **Figma 에 이 화면의 시안이 없다.** 근거는 견적서 v2.1 의 5탭 구성과 +/// Firestore 설계서의 `stationLinks` · `stationSuggestions` 다. 그래서 데이터가 +/// 정의한 것까지만 그렸고, 시안이 확정되면 그때 맞춘다. +class StationScreen extends StatefulWidget { + const StationScreen({ + super.key, + this.repository = const SampleStationRepository(), + }); + + final StationRepository repository; @override - Widget build(BuildContext context) => const TabPlaceholder( - tab: 'STATION', - screenId: 'S-S-HUB', - screenName: 'STATION 허브', - ); + State createState() => _StationScreenState(); +} + +class _StationScreenState extends State { + StationSnapshot? _snapshot; + bool _loading = true; + String? _error; + StationCategory? _filter; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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 get _visible { + final links = _snapshot?.links ?? const []; + 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( + 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), + ], + ), + ), + ), + ); + } } diff --git a/app/lib/features/station/station_suggest_screen.dart b/app/lib/features/station/station_suggest_screen.dart new file mode 100644 index 0000000..62afa22 --- /dev/null +++ b/app/lib/features/station/station_suggest_screen.dart @@ -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 createState() => _StationSuggestScreenState(); +} + +class _StationSuggestScreenState extends State { + final _form = GlobalKey(); + 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( + 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, + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/translation/translation_comments.dart b/app/lib/features/translation/translation_comments.dart new file mode 100644 index 0000000..617aa81 --- /dev/null +++ b/app/lib/features/translation/translation_comments.dart @@ -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() + ?.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 _run( + BuildContext context, + Future 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 Function(String body)? onComment; + final TranslationRepository? translation; + final String? rootPath; + final TranslationReplyTarget? replyTo; + final VoidCallback? onClearReply; + + /// 일반 댓글 글자 수 상한(규칙과 같은 수). 번역 댓글은 서버 상한 2,000자. + final int? maxLength; + + @override + State createState() => _CommentInputBarState(); +} + +class _CommentInputBarState extends State { + 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 _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 createState() => _TranslationCommentCardState(); +} + +class _TranslationCommentCardState extends State { + 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 _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> byTarget, + ) + builder; + + @override + State createState() => _TranslationThreadsState(); +} + +class _TranslationThreadsState extends State { + int _limit = translationPageSize; + late Stream> _stream = _watch(); + + Stream> _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>( + stream: _stream, + builder: (context, snap) { + final rows = snap.data ?? const []; + final byTarget = >{}; + 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), + ), + ], + ); + }, + ); +} diff --git a/app/lib/features/translation/translation_data.dart b/app/lib/features/translation/translation_data.dart new file mode 100644 index 0000000..5f91850 --- /dev/null +++ b/app/lib/features/translation/translation_data.dart @@ -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 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> watch( + String rootPath, { + int limit = translationPageSize, + }); + + /// 번역 등록. 같은 콘텐츠·언어 재등록은 [TranslationFailure.duplicate]. + Future submit(String targetPath, String locale, String body); + + /// 유효/무효 마킹. 본인 번역은 [TranslationFailure.selfMark], 두 번째는 [TranslationFailure.alreadyMarked]. + Future mark(TranslationEntry entry, {required bool valid}); +} + +class FirestoreTranslationRepository implements TranslationRepository { + FirestoreTranslationRepository(this._backend); + + final Backend _backend; + + @override + String? get uid => _backend.uid; + + @override + Stream> 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 _entry( + QueryDocumentSnapshot> 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 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 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? seed}) + : _rows = [...?seed]; + + @override + final String? uid; + + final List _rows; + final _changes = StreamController.broadcast(); + + List _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> 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 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 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 marks = {}; +}