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

This commit is contained in:
Sn-Kinos
2026-09-29 17:06:48 +09:00
parent 18afc9cb98
commit 3aca678072
31 changed files with 10046 additions and 25 deletions
+406
View File
@@ -0,0 +1,406 @@
/// INSIDE 탭이 그리는 값의 형태.
///
/// 근거는 Figma `03_Wireframe` 의 INSIDE 화면 10종이다. 목록 화면이 정렬 기준마다
/// 따로 그려져 있어(`Inside_최신순` · `Inside_인기순` · `Inside_POP순`) 정렬을
/// 화면이 아니라 질의 조건으로 둔다 — 같은 리스트에 정렬만 다르다.
///
/// 데이터 원장은 `questions` 컬렉션이며 답변은 `answers` 하위 컬렉션이다
/// (Firestore 설계서 §5). 현상금은 POP 원장을 지나므로 화면은 금액만 읽는다.
library;
import '../../core/data/backend.dart';
import '../../core/i18n/app_locale.dart';
/// 목록 정렬 기준. Figma 가 화면을 셋으로 나눠 둔 그 축이다.
enum InsideSort {
/// `Inside_최신순` — createdAt 내림차순.
latest,
/// `Inside_인기순` — 답변 수 내림차순.
popular,
/// `Inside_POP순` — 걸린 현상금 내림차순.
bounty,
}
extension InsideSortLabel on InsideSort {
String get label => switch (this) {
InsideSort.latest => appL10n.sortLatest,
InsideSort.popular => appL10n.sortPopular,
InsideSort.bounty => appL10n.sortPop,
};
/// Firestore 정렬 필드. 화면이 필드 이름을 알 필요는 없지만, 저장소 구현이
/// 세 곳으로 갈리는 것보다 여기 한 줄로 두는 편이 어긋날 여지가 적다.
String get field => switch (this) {
InsideSort.latest => 'createdAt',
InsideSort.popular => 'answerCount',
InsideSort.bounty => 'bountyPop',
};
}
/// 질문 하나. 목록 카드와 상세가 같은 형을 쓴다 — 상세가 더 읽을 뿐이다.
class InsideQuestion {
const InsideQuestion({
required this.id,
required this.title,
required this.body,
required this.authorName,
required this.createdAt,
this.bountyPop = 0,
this.answerCount = 0,
this.viewCount = 0,
this.likeCount = 0,
this.imageUrls = const [],
this.isMine = false,
this.isSolved = false,
this.refund = InsideRefund.none,
});
final String id;
final String title;
final String body;
final String authorName;
final DateTime createdAt;
/// 걸린 현상금. 0 이면 카드에 칩을 그리지 않는다.
final int bountyPop;
final int answerCount;
final int viewCount;
final int likeCount;
/// 첨부 이미지. 목록 카드는 첫 장을 70×70 썸네일로, 상세는 전부 그린다.
/// `assets/` 로 시작하면 번들 자산(표본), 아니면 네트워크 주소다.
final List<String> imageUrls;
/// 본인 글. Figma 가 상세를 「본인」·「타인」으로 나눠 둔 그 구분이며,
/// 수정·삭제와 신고·차단이 갈린다.
final bool isMine;
/// 답변이 채택되어 현상금이 지급된 상태.
final bool isSolved;
/// 채택 기한(원장 B-39 · 7일)이 지난 뒤의 환불 단계.
final InsideRefund refund;
/// 채택을 받을 수 있는 상태 — 채택도 만료도 아니다.
bool get isOpen => !isSolved && refund == InsideRefund.none;
InsideQuestion solvedCopy() => InsideQuestion(
id: id,
title: title,
body: body,
authorName: authorName,
createdAt: createdAt,
bountyPop: bountyPop,
answerCount: answerCount,
viewCount: viewCount,
likeCount: likeCount,
imageUrls: imageUrls,
isMine: isMine,
isSolved: true,
);
}
/// 만료 환불 단계 — 서버 `status` 의 `refunding` · `expired` · `refund_failed`.
enum InsideRefund {
none,
pending,
done,
/// 자동 환불이 멈춰 운영 확인 중. 사용자에게는 원인을 드러내지 않는다.
checking;
String get notice => switch (this) {
none => '',
pending => appL10n.insideRefundPending,
done => appL10n.insideRefundDone,
checking => appL10n.insideRefundChecking,
};
static InsideRefund fromStatus(String status) => switch (status) {
'refunding' => pending,
'expired' => done,
'refund_failed' => checking,
_ => none,
};
}
/// 답변(댓글) 하나. 시안 상세의 댓글 · 대댓글이다(`answers` 하위 컬렉션).
class InsideAnswer {
const InsideAnswer({
required this.authorName,
required this.body,
required this.createdAt,
this.id = '',
this.isMine = false,
this.isAccepted = false,
this.replies = const [],
});
/// `answers` 문서 ID. 채택 호출이 이 값을 보낸다.
final String id;
final String authorName;
final String body;
final DateTime createdAt;
/// 본인 댓글. 시안은 분홍 바탕(#ff7ddd 10%)이다.
final bool isMine;
/// 채택된 답변(`accepted`). 서버(`fn-answer-accept`)만 켠다.
final bool isAccepted;
/// 대댓글. 한 단계만 있다 — 대댓글에는 답글 버튼이 없다.
final List<InsideAnswer> replies;
}
class InsideSnapshot {
const InsideSnapshot({
required this.questions,
this.sort = InsideSort.latest,
this.canWrite = true,
});
final List<InsideQuestion> questions;
final InsideSort sort;
/// 작성 플로팅 버튼 노출 조건. 명세 「해당 팬덤에 가입된 유저에게만 노출」.
final bool canWrite;
}
abstract interface class InsideRepository {
/// 첫 장. 명세가 「최초 5개, 스크롤 시 5개씩」이라 기본값을 5로 둔다.
Future<InsideSnapshot> load({InsideSort sort = InsideSort.latest});
/// 다음 장. 더 없으면 빈 목록을 준다.
Future<List<InsideQuestion>> more({required InsideSort sort, int limit = 5});
/// 현상금을 걸 수 있는 보유 POP — 일반 + 기여. 예치는 겸용이다(B-39 · B-06, 일반 우선).
Future<int> balance();
/// 현상금 하한 — 서버와 같은 `config/policy.qnaMinBountyPop`(기본 100 · 운영값).
Future<int> minBounty();
/// 질문 등록 + 현상금 예치(`fn-question-create`). 새 질문 ID 를 준다. 실패는 던진다.
Future<String> create({
required String title,
required String body,
required int bountyPop,
required String clientSeq,
});
/// 답변 목록 — 명세 2.61-3 「최신순 소팅」.
Future<List<InsideAnswer>> answers(String questionId);
/// 답변 등록. 재화가 오가지 않아 규칙이 클라이언트 쓰기를 허용한다.
Future<void> answer(String questionId, String body);
/// 채택(`fn-answer-accept`). 현상금이 답변자에게 간다. 번복되지 않는다.
Future<void> accept(String questionId, String answerId);
}
/// 현상금 Q&A 실패를 화면 문구로 — 서버 카탈로그 코드(`E-QNA-*` 등)로 가른다.
String insideErrorMessage(Object error) => switch (error) {
BackendException(code: 'E-POP-01') => appL10n.insideErrPopShort,
BackendException(code: 'E-QNA-01') => appL10n.insideErrSelfAccept,
BackendException(code: 'E-QNA-03') => appL10n.insideErrAlreadyAccepted,
BackendException(code: 'E-PERM-01') => appL10n.insideErrNotOwner,
BackendException(code: 'E-AUTH-03') => appL10n.authLoginRequired,
BackendException(:final message) => message,
_ => appL10n.commonCheckConnection,
};
/// 서버가 붙기 전에 쓰는 표본. 문구는 Figma INSIDE 시안(199:43083 등)의 표본 그대로이며 정책 수치가 아니다.
///
/// 작성 시각은 지금을 기준으로 잡는다 — 시안의 「10분 전」·「1시간 전」이 그대로 나오게.
class SampleInsideRepository implements InsideRepository {
const SampleInsideRepository();
static List<InsideQuestion> get _all {
final now = DateTime.now();
return [
InsideQuestion(
id: 'q-0',
title: '화양연화 세계관 해석해주실 분?',
body: '이번 뮤비랑 연결되는 부분이 헷갈리네요. 타임라인 정리해주실 천재 아미 구합니다!',
authorName: '아미보이',
createdAt: now.subtract(const Duration(minutes: 10)),
bountyPop: 500,
viewCount: 3,
),
InsideQuestion(
id: 'q-1',
title: '공방 신청 꿀팁 공유합니다.',
body: '공방 신청할 때 폼 가장 빨리 제출하는 손크로 팁 알려드립! 궁금하신 분 질문 남겨주세요',
authorName: '다이브짱',
createdAt: now.subtract(const Duration(hours: 1)),
bountyPop: 200,
likeCount: 8,
answerCount: 24,
viewCount: 82,
imageUrls: const ['assets/figma/inside-sample-3.png'],
isSolved: true,
),
InsideQuestion(
id: 'q-2',
title: 'BTS 이 포즈 뭔가요?',
body: '아미 분들 이 포즈 뭔지 알려주실 수 있나요?',
authorName: '네누',
createdAt: now.subtract(const Duration(hours: 3)),
bountyPop: 100,
likeCount: 48,
answerCount: 24,
viewCount: 82,
imageUrls: const ['assets/figma/inside-sample-2.png'],
),
InsideQuestion(
id: 'q-3',
title: 'BTS 광화문 봉사 활동',
body: '광화문 봉사 활동 어떻게 신청하셨나요?\n다음 봉사 활동이 있으면 참가하고 싶어요!',
authorName: '아우라',
createdAt: now.subtract(const Duration(days: 30)),
bountyPop: 800,
likeCount: 3,
answerCount: 2,
viewCount: 32,
isSolved: true,
),
InsideQuestion(
id: 'q-4',
title: 'BTS 컴백 알려주세요!',
body: 'BTS 한국 컴백 시기가 궁금합니다!\n아시는 분들 댓글 부탁드려요ㅠㅠ',
authorName: 'BTS_Hong',
createdAt: now.subtract(const Duration(days: 40)),
bountyPop: 100,
imageUrls: const [
'assets/figma/inside-sample-4.png',
'assets/figma/inside-sample-5.png',
],
isMine: true,
),
];
}
List<InsideQuestion> _sorted(InsideSort sort) {
final list = _all;
switch (sort) {
case InsideSort.latest:
list.sort((a, b) => b.createdAt.compareTo(a.createdAt));
case InsideSort.popular:
// 기능 명세 2008 — 인기순은 좋아요 > 댓글 > 조회수.
list.sort(
(a, b) => b.likeCount != a.likeCount
? b.likeCount.compareTo(a.likeCount)
: b.answerCount != a.answerCount
? b.answerCount.compareTo(a.answerCount)
: b.viewCount.compareTo(a.viewCount),
);
case InsideSort.bounty:
list.sort((a, b) => b.bountyPop.compareTo(a.bountyPop));
}
return list;
}
@override
Future<InsideSnapshot> load({InsideSort sort = InsideSort.latest}) async =>
InsideSnapshot(questions: _sorted(sort).take(5).toList(), sort: sort);
/// 표본 모드에서 단 답변. 서버가 없으니 앱이 떠 있는 동안만 남는다.
static final Map<String, List<InsideAnswer>> _answers = {};
@override
Future<int> balance() async => 12_400;
@override
Future<int> minBounty() async => 100;
/// 표본은 원장이 없다 — 화면 동선만 태운다(캠페인 표본의 `create` 와 같다).
@override
Future<String> create({
required String title,
required String body,
required int bountyPop,
required String clientSeq,
}) async => 'q-new';
@override
Future<List<InsideAnswer>> answers(String questionId) async =>
List.of(_answers[questionId] ?? const []);
@override
Future<void> answer(String questionId, String body) async {
(_answers[questionId] ??= []).insert(
0,
InsideAnswer(
id: 'a-${DateTime.now().microsecondsSinceEpoch}',
authorName: '나',
body: body,
createdAt: DateTime.now(),
isMine: true,
),
);
}
@override
Future<void> accept(String questionId, String answerId) async {
final list = _answers[questionId] ?? const <InsideAnswer>[];
for (final (i, a) in list.indexed) {
if (a.id != answerId) continue;
if (a.isMine) {
throw BackendException('E-QNA-01', appL10n.insideErrSelfAccept);
}
_answers[questionId]![i] = InsideAnswer(
id: a.id,
authorName: a.authorName,
body: a.body,
createdAt: a.createdAt,
isAccepted: true,
);
}
}
/// 표본은 5건뿐이라 둘째 장이 비어 나온다. 화면의 「더 없음」 경로를 그대로 태운다.
@override
Future<List<InsideQuestion>> more({
required InsideSort sort,
int limit = 5,
}) async => _sorted(sort).skip(5).take(limit).toList();
}
/// 시안 「인사이드 상세(타인)」(201:49678)의 댓글 표본. 답변 목록 읽기 경로가 아직 없어
/// 화면 대조와 시연에서만 쓴다.
final List<InsideAnswer> sampleInsideAnswers = [
InsideAnswer(
authorName: 'pro_따봉',
body: '세상이 이런 꿀팁이 다 있나요,,,\n덕분에 좋은 정보 알고 갑니다 ㅎㅎㅎ',
createdAt: DateTime(2026, 4, 27, 9),
replies: [
InsideAnswer(
authorName: '뚜루뚭',
body: '이 내용 말고도 다른 내용 더 있어요!',
createdAt: DateTime(2026, 4, 27, 9),
),
InsideAnswer(
authorName: '호떡이',
body:
'위의 내용 번역해드릴게요!\nIs there really no such great tip in the world...\n'
'Thanks for the good information hahaha',
createdAt: DateTime(2026, 4, 27, 9),
isMine: true,
),
],
),
InsideAnswer(
authorName: '참참참',
body: '내가 찾던 내용인데 이제 찾았네 ㅠㅠ',
createdAt: DateTime(2026, 4, 27, 9),
replies: [
InsideAnswer(
authorName: '뚜루뚭',
body: '이 내용 말고도 다른 내용 더 있어요!',
createdAt: DateTime(2026, 4, 27, 9),
),
],
),
];
@@ -0,0 +1,527 @@
import 'package:flutter/material.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart';
import '../planet/moderation.dart';
import '../translation/translation_data.dart';
import '../translation/translation_comments.dart';
import 'inside_data.dart';
import 'inside_ui.dart';
/// 인사이드 상세 — 화면 대조 원장 F9.
///
/// Figma 노드
/// - `207:53130` Inside_인사이드 상세(본인)
/// - `201:49678` Inside/인사이드 상세(타인)
/// - `201:51871` 신고하기 · `207:52152` 차단하기
///
/// 본인 글과 타인 글이 Figma 에서 별도 화면인 이유는 더보기 동작이 갈리기 때문이다 —
/// 본인은 수정·삭제, 타인은 신고·차단. 화면 골격은 같으므로 하나로 두고 동작만 나눈다.
///
/// 댓글(답변)은 [repository] 가 있으면 `answers` 하위 컬렉션에서 읽고, 없으면 [answers]
/// 를 그대로 그린다(화면 대조 · 시연). 질문자는 남의 답변 하나를 채택할 수 있고
/// (명세 2.64 · `fn-answer-accept`), 채택은 되돌릴 수 없다.
class InsideDetailScreen extends StatefulWidget {
const InsideDetailScreen({
super.key,
required this.question,
this.answers = const [],
this.repository,
this.translation,
});
final InsideQuestion question;
final List<InsideAnswer> answers;
final InsideRepository? repository;
/// 참여형 번역(FR-I18N-02 · B-63). 있으면 댓글 입력의 지구본으로 번역 댓글을 쓰고,
/// 번역 댓글을 댓글 목록에 지구본 표시와 함께 그린다.
final TranslationRepository? translation;
@override
State<InsideDetailScreen> createState() => _InsideDetailScreenState();
}
class _InsideDetailScreenState extends State<InsideDetailScreen> {
late InsideQuestion question = widget.question;
late List<InsideAnswer> answers = widget.answers;
bool _busy = false;
/// 번역할 댓글(대댓글 버튼으로 고른다). 없으면 번역 댓글은 질문 본문의 번역이다.
TranslationReplyTarget? _replyTo;
TranslationRepository? get _translation =>
translationSurfaces.contains(TranslationSurface.inside)
? widget.translation
: null;
String get _root => 'questions/${question.id}';
@override
void initState() {
super.initState();
_reload();
}
Future<void> _reload() async {
final repo = widget.repository;
if (repo == null) return;
try {
final next = await repo.answers(question.id);
if (mounted) setState(() => answers = next);
} catch (error) {
if (mounted) _notice(insideErrorMessage(error));
}
}
void _notice(String message) => ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(message)));
/// 답변 등록. 저장이 끝난 뒤에만 목록을 다시 읽는다 — 실패를 성공처럼 그리지 않게.
Future<bool> _send(String body) async {
final repo = widget.repository;
if (repo == null) {
_notice(context.l10n.insideAnswerLater);
return false;
}
try {
await repo.answer(question.id, body);
} catch (error) {
if (mounted) _notice(insideErrorMessage(error));
return false;
}
await _reload();
return true;
}
/// 채택 가능한 답변 — 본인 질문 · 아직 열림 · 남의 답변.
bool _canAccept(InsideAnswer a) =>
widget.repository != null &&
question.isMine &&
question.isOpen &&
!a.isMine &&
a.id.isNotEmpty;
Future<void> _accept(InsideAnswer a) async {
final ok = await showDialog<bool>(
context: context,
builder: (dialog) => AlertDialog(
content: Text(
context.l10n.insideAcceptConfirm('${question.bountyPop}'),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialog).pop(false),
child: Text(context.l10n.commonCancel),
),
TextButton(
onPressed: () => Navigator.of(dialog).pop(true),
child: Text(context.l10n.insideAccept),
),
],
),
);
if (ok != true || _busy || !mounted) return;
setState(() => _busy = true);
try {
await widget.repository!.accept(question.id, a.id);
if (mounted) setState(() => question = question.solvedCopy());
} catch (error) {
if (mounted) _notice(insideErrorMessage(error));
} finally {
if (mounted) setState(() => _busy = false);
}
await _reload();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
actions: [
IconButton(
tooltip: context.l10n.insideMore,
icon: const ImageIcon(AppIcons.ellipsisVertical, size: 24),
onPressed: () => _showMore(context),
),
const SizedBox(width: AppSpace.s1),
],
),
body: SafeArea(
top: false,
child: Column(
children: [
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: [
_Post(question: question),
// 시안은 본문과 댓글 사이를 검정 8 로 끊는다.
const ColoredBox(
color: Colors.black,
child: SizedBox(
height: AppSpace.s2,
width: double.infinity,
),
),
Padding(
padding: const EdgeInsets.all(AppSpace.s4),
child: switch (_translation) {
final t? => TranslationThreads(
repository: t,
rootPath: _root,
builder: (_, byTarget) => _comments(t, byTarget),
),
null => _comments(null, const {}),
},
),
],
),
),
CommentInputBar(
hint: context.l10n.insideAnswerHint,
onComment: _send,
translation: _translation,
rootPath: _root,
replyTo: _replyTo,
onClearReply: () => setState(() => _replyTo = null),
),
],
),
),
);
}
/// 댓글 목록 — 댓글마다 그 댓글의 번역 댓글을 대댓글 자리에, 질문 본문의 번역 댓글은
/// 댓글 뒤에 붙인다(시안 `201:50554`).
Widget _comments(
TranslationRepository? t,
Map<String, List<TranslationEntry>> byTarget,
) {
final ownTranslations = byTarget[_root] ?? const [];
if (answers.isEmpty && ownTranslations.isEmpty) return const _NoAnswer();
const divider = Divider(height: 33, thickness: 1, color: InsideColors.rule);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (i, a) in answers.indexed) ...[
if (i > 0) divider,
_AnswerThread(
answer: a,
onAccept: _canAccept(a) ? () => _accept(a) : null,
onTranslate: t != null && a.id.isNotEmpty
? () => setState(
() => _replyTo = (
path: '$_root/answers/${a.id}',
name: a.authorName,
),
)
: null,
),
for (final e in byTarget['$_root/answers/${a.id}'] ?? const []) ...[
const SizedBox(height: AppSpace.s5),
TranslationCommentCard(entry: e, repository: t!, reply: true),
],
],
for (final (i, e) in ownTranslations.indexed) ...[
if (answers.isNotEmpty || i > 0) divider,
TranslationCommentCard(entry: e, repository: t!),
],
],
);
}
/// 명세대로 본인 글과 타인 글의 선택지를 갈라 준다.
void _showMore(BuildContext context) {
showModalBottomSheet<void>(
context: context,
builder: (sheet) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: question.isMine
? [
ListTile(
title: Text(context.l10n.commonEditAction),
onTap: () => Navigator.of(sheet).pop(),
),
ListTile(
title: Text(context.l10n.commonDeleteAction),
onTap: () => Navigator.of(sheet).pop(),
),
]
: [
ListTile(
title: Text(context.l10n.reportTitle),
onTap: () {
Navigator.of(sheet).pop();
Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (_) => ReportScreen(
target: ModerationTarget(
authorName: question.authorName,
title: question.title,
),
),
),
);
},
),
ListTile(
title: Text(context.l10n.commonBlockAction),
onTap: () {
Navigator.of(sheet).pop();
confirmBlock(context, question.authorName);
},
),
],
),
),
);
}
}
/// 상세 시각 — 시안 `2026.04.27 09:00`.
String insideStamp(DateTime at) {
String two(int v) => v.toString().padLeft(2, '0');
return '${at.year}.${two(at.month)}.${two(at.day)} ${two(at.hour)}:${two(at.minute)}';
}
class _Post extends StatelessWidget {
const _Post({required this.question});
final InsideQuestion question;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.all(AppSpace.s4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
InsideAuthor(
name: question.authorName,
meta: insideStamp(question.createdAt),
),
if (question.bountyPop > 0) InsideBountyChip(question: question),
],
),
if (question.refund != InsideRefund.none) ...[
const SizedBox(height: AppSpace.s2),
Text(
question.refund.notice,
style: insideText(12, color: AppColors.onCosmicMuted),
),
],
const SizedBox(height: AppSpace.s3),
Text(question.title, style: insideText(22)),
const SizedBox(height: AppSpace.s3),
Text(
question.body,
style: insideText(16, color: AppColors.onCosmicVariant),
),
for (final (i, url) in question.imageUrls.indexed) ...[
SizedBox(height: i == 0 ? AppSpace.s5 : AppSpace.s3),
ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.sm),
child: AspectRatio(
aspectRatio: 1,
child: Image(image: insideImage(url), fit: BoxFit.cover),
),
),
],
const SizedBox(height: AppSpace.s4),
InsideStats(
question: question,
size: 18,
textSize: 14,
color: InsideColors.chipLabel,
),
],
),
);
}
class _NoAnswer extends StatelessWidget {
const _NoAnswer();
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpace.s4),
child: Column(
children: [
const ImageIcon(
AppIcons.messageCircle,
size: 48,
color: InsideColors.stat,
),
const SizedBox(height: AppSpace.s4),
Text(
context.l10n.insideFirstAnswer,
style: insideText(12, color: AppColors.onCosmicVariant),
),
],
),
);
}
/// 댓글 하나와 그 대댓글 — 댓글과 대댓글 사이 24.
class _AnswerThread extends StatelessWidget {
const _AnswerThread({required this.answer, this.onAccept, this.onTranslate});
final InsideAnswer answer;
final VoidCallback? onAccept;
final VoidCallback? onTranslate;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Answer(
answer: answer,
reply: false,
onAccept: onAccept,
onTranslate: onTranslate,
),
for (final r in answer.replies) ...[
const SizedBox(height: AppSpace.s5),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ImageIcon(
AppIcons.cornerDownRight,
size: 20,
color: AppColors.onCosmicMuted,
),
const SizedBox(width: AppSpace.s3),
Expanded(
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
// 본인 댓글은 분홍 10%, 나머지는 #1a1a1c.
color: r.isMine
? AppColors.cosmicAccent.withValues(alpha: 0.1)
: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(2),
),
child: _Answer(answer: r, reply: true),
),
),
],
),
],
],
);
}
class _Answer extends StatelessWidget {
const _Answer({
required this.answer,
required this.reply,
this.onAccept,
this.onTranslate,
});
final InsideAnswer answer;
final bool reply;
/// 대댓글 버튼 — 이 댓글을 골라 번역 댓글을 단다(명세 「댓글 선택 UI 변경 후 키보드 노출」).
final VoidCallback? onTranslate;
/// 「채택하기」 — 질문자에게만, 채택 전에만 붙는다(명세 2.64-3).
final VoidCallback? onAccept;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
InsideAuthor(name: answer.authorName),
const Spacer(),
if (answer.isAccepted)
_AcceptChip(
label: context.l10n.insideAcceptDone,
color: AppColors.cosmicBlue,
)
else if (onAccept != null)
GestureDetector(
onTap: onAccept,
child: _AcceptChip(
label: context.l10n.insideAcceptAction,
color: AppColors.onCosmic,
),
),
if (answer.isAccepted || onAccept != null)
const SizedBox(width: AppSpace.s2),
// 댓글 동작 칸 — #1a1a1a · 아이콘 16 · 사이 12. 대댓글에는 답글이 없다.
Container(
color: InsideColors.rule,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
child: Row(
children: [
const ImageIcon(
AppIcons.heart,
size: 16,
color: InsideColors.stat,
),
if (!reply) ...[
const SizedBox(width: AppSpace.s3),
Semantics(
button: onTranslate != null,
label: onTranslate == null
? null
: context.l10n.translationWrite,
child: GestureDetector(
onTap: onTranslate,
child: const ImageIcon(
AppIcons.messageCircle,
size: 16,
color: InsideColors.stat,
),
),
),
],
const SizedBox(width: AppSpace.s3),
const ImageIcon(
AppIcons.ellipsisVertical,
size: 16,
color: InsideColors.stat,
),
],
),
),
],
),
const SizedBox(height: AppSpace.s3),
Text(answer.body, style: insideText(14)),
const SizedBox(height: AppSpace.s3),
Text(
insideStamp(answer.createdAt),
style: insideText(10, color: AppColors.onCosmicMuted),
),
],
);
}
/// 채택 칩 — 「채택하기」 버튼과 「채택 완료」 표시가 같은 모양을 쓴다.
class _AcceptChip extends StatelessWidget {
const _AcceptChip({required this.label, required this.color});
final String label;
final Color color;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
border: Border.all(color: color),
borderRadius: BorderRadius.circular(2),
),
child: Text(label, style: insideText(12, color: color, height: 1)),
);
}
@@ -0,0 +1,173 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import '../../core/data/backend.dart';
import 'inside_data.dart';
/// INSIDE 목록의 실 서버 구현 — 화면 대조 원장 F3.
///
/// 정렬 셋(`최신순` · `인기순` · `POP순`)이 Figma 에서는 별도 화면이지만 여기서는
/// orderBy 필드만 다른 같은 질의다. 세 화면을 따로 두면 페이지네이션 커서도 셋이
/// 되고, 한쪽만 고치는 사고가 난다.
///
/// 커서는 `startAfterDocument` 로 잡는다. `limit` 을 커서보다 먼저 걸면 둘째 장이
/// 항상 비어 돌아온다 — 라운지에서 한 번 겪은 결함이라 순서를 지킨다.
class FirestoreInsideRepository implements InsideRepository {
FirestoreInsideRepository(this._backend);
final Backend _backend;
/// 사용자 문서(소속 팬덤 · 표시명). 라우터는 이 값을 모르므로 한 번 읽어 들고
/// 있는다. 탭을 옮길 때마다 다시 읽으면 왕복이 그대로 쌓인다.
Map<String, dynamic>? _user;
Future<Map<String, dynamic>> _me() async => _user ??=
(await _backend.db.doc('users/${_backend.requireUid}').get()).data() ??
const {};
Future<String> _resolveFandomId() async => (await _me()).strOr('fandomId');
DocumentSnapshot<Map<String, dynamic>>? _cursor;
InsideSort? _cursorSort;
static const int pageSize = 5;
Query<Map<String, dynamic>> _query(InsideSort sort, String fandomId) {
Query<Map<String, dynamic>> q = _backend.db.collection('questions');
if (fandomId.isNotEmpty) {
q = q.where('fandomId', isEqualTo: fandomId);
}
return q.orderBy(sort.field, descending: true);
}
@override
Future<InsideSnapshot> load({InsideSort sort = InsideSort.latest}) async {
final fandomId = await _resolveFandomId();
final snap = await _query(sort, fandomId).limit(pageSize).get();
_cursor = snap.docs.isEmpty ? null : snap.docs.last;
_cursorSort = sort;
return InsideSnapshot(
questions: [for (final doc in snap.docs) _question(doc)],
sort: sort,
// 작성 버튼은 팬덤에 가입한 사용자에게만 보인다(명세 2.70-3).
canWrite: fandomId.isNotEmpty,
);
}
@override
Future<List<InsideQuestion>> more({
required InsideSort sort,
int limit = pageSize,
}) async {
// 정렬이 바뀌면 앞 장의 커서는 다른 순서의 위치라 의미가 없다. 처음부터 읽는다.
if (_cursorSort != sort) _cursor = null;
var query = _query(sort, await _resolveFandomId());
final cursor = _cursor;
if (cursor != null) query = query.startAfterDocument(cursor);
final snap = await query.limit(limit).get();
if (snap.docs.isNotEmpty) {
_cursor = snap.docs.last;
_cursorSort = sort;
}
return [for (final doc in snap.docs) _question(doc)];
}
InsideQuestion _question(QueryDocumentSnapshot<Map<String, dynamic>> doc) {
final data = doc.data();
return InsideQuestion(
id: doc.id,
title: data.strOr('title'),
body: data.strOr('body'),
authorName: publicName(data['authorName']),
createdAt: data.dateOr('createdAt', DateTime.now()),
bountyPop: data.intOr('bountyPop'),
answerCount: data.intOr('answerCount'),
viewCount: data.intOr('viewCount'),
// 질문 문서에 아직 없는 필드다(fn-question-create 가 쓰지 않음). 없으면 0 · 빈 목록.
likeCount: data.intOr('likeCount'),
imageUrls: [
for (final u in (data['imageUrls'] as List?) ?? const []) '$u',
],
isMine: data.strOr('authorUid') == _backend.uid,
isSolved: data.strOr('status') == 'accepted',
refund: InsideRefund.fromStatus(data.strOr('status')),
);
}
@override
Future<int> balance() async {
final wallet =
(await _backend.db
.doc('users/${_backend.requireUid}/wallet/main')
.get())
.data() ??
const {};
return wallet.intOr('activityBalance') + wallet.intOr('supportBalance');
}
@override
Future<int> minBounty() async {
final policy = await _backend.db.doc('config/policy').get();
final value = (policy.data() ?? const {}).intOr('qnaMinBountyPop');
return value > 0 ? value : 100;
}
/// 실패는 [BackendException] 으로 그대로 올린다 — 화면이 성공처럼 그리지 않게.
@override
Future<String> create({
required String title,
required String body,
required int bountyPop,
required String clientSeq,
}) async {
final result = await _backend.call('fnQuestionCreate', {
'title': title,
'body': body,
'bountyPop': bountyPop,
'clientSeq': clientSeq,
});
return result.strOr('questionId');
}
@override
Future<List<InsideAnswer>> answers(String questionId) async {
final snap = await _backend.db
.collection('questions/$questionId/answers')
.orderBy('createdAt', descending: true)
.get();
return [
for (final doc in snap.docs)
if (doc.data().strOr('visibility', 'visible') == 'visible')
InsideAnswer(
id: doc.id,
authorName: publicName(doc.data()['authorName']),
body: doc.data().strOr('body'),
createdAt: doc.data().dateOr('createdAt', DateTime.now()),
isMine: doc.data().strOr('authorUid') == _backend.uid,
isAccepted: doc.data().boolOr('accepted'),
),
];
}
/// 표시명 = 공개 닉네임(`publicName`, 없으면 「팬」). 공급자 실명(`displayName`)은
/// 쓰지 않는다(M-7). 규칙 `authorNameIsPublic` 이 같은 계산으로 대조한다.
@override
Future<void> answer(String questionId, String body) async {
final me = await _me();
await _backend.db.collection('questions/$questionId/answers').add({
'authorUid': _backend.requireUid,
'authorName': storedPublicName(me['nickname']),
'body': body,
'accepted': false,
'visibility': 'visible',
'createdAt': FieldValue.serverTimestamp(),
});
}
@override
Future<void> accept(String questionId, String answerId) => _backend.call(
'fnAnswerAccept',
{'questionId': questionId, 'answerId': answerId},
);
}
+520 -8
View File
@@ -1,15 +1,527 @@
import 'package:flutter/material.dart';
import '../../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<InsideScreen> createState() => _InsideScreenState();
}
class _InsideScreenState extends State<InsideScreen> {
final _scroll = ScrollController();
List<InsideQuestion> _questions = const [];
InsideSort _sort = InsideSort.latest;
bool _loading = true;
bool _more = false;
bool _end = false;
bool _canWrite = true;
String? _error;
@override
void initState() {
super.initState();
_scroll.addListener(() {
if (_scroll.position.pixels > _scroll.position.maxScrollExtent - 240) {
_loadMore();
}
});
_load();
}
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
final snap = await widget.repository.load(sort: _sort);
if (!mounted) return;
setState(() {
_questions = snap.questions;
_canWrite = snap.canWrite;
_loading = false;
// 첫 장이 한 쪽을 못 채웠으면 뒤에 더 없다.
_end = snap.questions.length < 5;
});
} on Object {
if (!mounted) return;
setState(() {
_loading = false;
_error = context.l10n.insideLoadFailed;
});
}
}
Future<void> _loadMore() async {
if (_more || _end || _loading) return;
setState(() => _more = true);
try {
final next = await widget.repository.more(sort: _sort);
if (!mounted) return;
setState(() {
_questions = [..._questions, ...next];
_more = false;
_end = next.isEmpty;
});
} on Object {
if (!mounted) return;
// 다음 장 실패는 목록 전체를 지우지 않는다. 이미 본 것은 그대로 둔다.
setState(() {
_more = false;
_end = true;
});
}
}
void _changeSort(InsideSort sort) {
if (sort == _sort) return;
setState(() {
_sort = sort;
_questions = const [];
_end = false;
});
_load();
}
Future<void> _open(InsideQuestion q) async {
await Navigator.of(context, rootNavigator: true).push<void>(
MaterialPageRoute(
builder: (_) => InsideDetailScreen(
question: q,
repository: widget.repository,
translation: widget.translation,
),
),
);
}
void _search() => Navigator.of(context, rootNavigator: true).push<void>(
MaterialPageRoute(
builder: (_) => InsideSearchScreen(
repository: widget.repository,
translation: widget.translation,
),
),
);
void _notifications() =>
Navigator.of(context, rootNavigator: true).push<void>(
MaterialPageRoute(
builder: (_) => NotificationScreen(repository: widget.notifications),
),
);
Future<void> _write() async {
final created = await Navigator.of(context, rootNavigator: true).push<bool>(
MaterialPageRoute(
builder: (_) => InsideWriteScreen(repository: widget.repository),
),
);
if (created == true && mounted) await _load();
}
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Scaffold(
// 시안 Top Navigation(196:2673) — 높이 54 · 제목 ABeeZee 20 왼쪽 · 오른쪽 42 칸 아이콘 둘.
appBar: AppBar(
toolbarHeight: 54,
titleSpacing: AppSpace.s4,
centerTitle: false,
title: const Text(
'INSIDE',
style: TextStyle(
fontFamily: AppFonts.display,
fontSize: 20,
height: 1.4,
color: AppColors.onCosmic,
),
),
actions: [
_HeaderIcon(
icon: AppIcons.search,
tooltip: context.l10n.commonSearch,
onTap: _search,
),
_HeaderIcon(
icon: AppIcons.bell,
tooltip: context.l10n.homeNotificationsTooltip,
onTap: _notifications,
),
const SizedBox(width: AppSpace.s4),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(36),
child: InsideSortBar(sort: _sort, onChanged: _changeSort),
),
),
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
floatingActionButton: _canWrite
? Padding(
// 시안은 하단 탭 위 20 에 뜬다(기본 여백 16).
padding: const EdgeInsets.only(bottom: 4),
child: InsideWriteFab(onTap: _write),
)
: null,
body: switch ((_loading, _error, _questions.isEmpty)) {
(true, _, _) => const Center(child: CircularProgressIndicator()),
(_, final String message, _) => _Failed(
message: message,
onRetry: _load,
),
(_, _, true) => Center(
child: Text(context.l10n.insideEmpty, style: t.bodyMedium),
),
_ => RefreshIndicator(
onRefresh: _load,
child: InsideQuestionList(
controller: _scroll,
questions: _questions,
loadingMore: _more,
onOpen: _open,
),
),
},
);
}
}
class _HeaderIcon extends StatelessWidget {
const _HeaderIcon({
required this.icon,
required this.tooltip,
required this.onTap,
});
final ImageProvider icon;
final String tooltip;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => SizedBox.square(
dimension: 42,
child: IconButton(
tooltip: tooltip,
padding: EdgeInsets.zero,
onPressed: onTap,
icon: ImageIcon(icon, size: 24, color: AppColors.onCosmic),
),
);
}
/// 질문 카드 목록 — 시안 안쪽 16 · 카드 사이 4 + 1px #1a1a1a 줄 + 4.
class InsideQuestionList extends StatelessWidget {
const InsideQuestionList({
super.key,
required this.questions,
required this.onOpen,
this.controller,
this.loadingMore = false,
});
final List<InsideQuestion> questions;
final ValueChanged<InsideQuestion> onOpen;
final ScrollController? controller;
final bool loadingMore;
@override
Widget build(BuildContext context) => ListView.separated(
controller: controller,
padding: const EdgeInsets.fromLTRB(
AppSpace.s4,
AppSpace.s4,
AppSpace.s4,
AppSpace.s8,
),
itemCount: questions.length + (loadingMore ? 1 : 0),
separatorBuilder: (_, _) =>
const Divider(height: 9, thickness: 1, color: InsideColors.rule),
itemBuilder: (context, i) {
if (i >= questions.length) {
return const Padding(
padding: EdgeInsets.all(AppSpace.s4),
child: Center(child: CircularProgressIndicator()),
);
}
final q = questions[i];
return InsideQuestionCard(question: q, onTap: () => onOpen(q));
},
);
}
/// 작성 버튼 — 시안 130×36 알약 · #19191c · #232324 획 · 연필 16 + 「인사이드 작성」.
class InsideWriteFab extends StatelessWidget {
const InsideWriteFab({super.key, required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) => Material(
color: const Color(0xFF19191C),
shape: const StadiumBorder(
side: BorderSide(color: AppColors.cosmicSurfaceRaised),
),
child: InkWell(
onTap: onTap,
customBorder: const StadiumBorder(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Image(image: AppIcons.pencil, width: 16, height: 16),
const SizedBox(width: 6),
Text(
context.l10n.insideWrite,
style: insideText(
14,
color: AppColors.onCosmicVariant,
height: 20 / 14,
).copyWith(letterSpacing: 0),
),
],
),
),
),
);
}
/// 정렬 칩 줄 — 시안 높이 36 · 좌우 16 · 칩 사이 4.
class InsideSortBar extends StatelessWidget {
const InsideSortBar({super.key, required this.sort, required this.onChanged});
final InsideSort sort;
final ValueChanged<InsideSort> onChanged;
@override
Widget build(BuildContext context) => SizedBox(
height: 36,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4, vertical: 2),
children: [
for (final s in InsideSort.values) ...[
_SortChip(sort: s, selected: sort == s, onTap: () => onChanged(s)),
const SizedBox(width: AppSpace.s1),
],
],
),
);
}
class _Failed extends StatelessWidget {
const _Failed({required this.message, required this.onRetry});
final String message;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(message, style: t.titleMedium),
const SizedBox(height: AppSpace.s2),
Text(context.l10n.homeRetryLater, style: t.bodyMedium),
const SizedBox(height: AppSpace.s4),
FilledButton(
onPressed: onRetry,
child: Text(context.l10n.commonRetry),
),
],
),
);
}
}
/// 목록의 질문 카드 한 장 — 시안 `196:32243`.
///
/// 명세 2.70-2 — *생성자 닉네임 · 작성일시 · 좋아요 수 · 댓글 수*.
/// 작성일시는 공용 [relativeTime] 을 쓴다 — 7일이 넘으면 `2026.01.01` 로 바뀐다.
class InsideQuestionCard extends StatelessWidget {
const InsideQuestionCard({
super.key,
required this.question,
required this.onTap,
});
final InsideQuestion question;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final text = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
question.title,
style: insideText(14),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: AppSpace.s2),
Text(
question.body,
style: insideText(12, color: AppColors.onCosmicVariant),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
);
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Padding(
padding: const EdgeInsets.all(AppSpace.s4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
InsideAuthor(
name: question.authorName,
meta: relativeTime(question.createdAt),
),
if (question.bountyPop > 0)
InsideBountyChip(question: question),
],
),
const SizedBox(height: AppSpace.s3),
if (question.imageUrls.isEmpty)
text
else
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: text),
const SizedBox(width: 36),
ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Image(
image: insideImage(question.imageUrls.first),
width: 70,
height: 70,
fit: BoxFit.cover,
),
),
],
),
const SizedBox(height: AppSpace.s4),
InsideStats(question: question),
],
),
),
);
}
}
/// 정렬 칩 — 시안 Chip(196:2673): r8 · 71×32 · 아이콘 16.
/// 선택은 흰 바탕에 검정 글자, 비선택은 #1a1a1a 바탕 · #232324 획 · #d5d7d9 글자.
class _SortChip extends StatelessWidget {
const _SortChip({
required this.sort,
required this.selected,
required this.onTap,
});
final InsideSort sort;
final bool selected;
final VoidCallback onTap;
static ImageProvider _icon(InsideSort s) => switch (s) {
InsideSort.latest => AppIcons.clock,
InsideSort.popular => AppIcons.heart,
InsideSort.bounty => AppIcons.bolt,
};
@override
Widget build(BuildContext context) {
final fg = selected ? const Color(0xFF000000) : InsideColors.chipLabel;
return Material(
color: selected ? AppColors.onCosmic : const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(AppRadius.sm),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.sm),
child: Container(
height: 32,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.sm),
border: Border.all(color: AppColors.cosmicSurfaceRaised),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 시안 POP순 칩의 번개는 16 칸이 아니라 10×10.6 벡터다.
ImageIcon(
_icon(sort),
size: sort == InsideSort.bounty ? 10.6 : 16,
color: fg,
),
const SizedBox(width: AppSpace.s1),
Text(
sort.label,
style: insideText(
12,
color: fg,
height: 20 / 12,
).copyWith(letterSpacing: 0),
),
],
),
),
),
);
}
}
@@ -0,0 +1,289 @@
import 'package:flutter/material.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart';
import '../../core/ui/play_banner.dart';
import '../translation/translation_data.dart';
import 'inside_data.dart';
import 'inside_detail_screen.dart';
import 'inside_screen.dart';
import 'inside_ui.dart';
/// INSIDE 검색 — Figma `200:48573` Inside/검색 · `201:49535` 검색 결과.
///
/// 검색어를 넣기 전에는 배너와 최근 검색어, 넣은 뒤에는 정렬 칩과 같은 카드 목록이다.
///
/// **서버 검색 경로가 없다.** `questions` 에 제목·본문 전문 검색 색인이 없어서, 지금은
/// 정렬별 첫 장을 불러와 제목·본문에 검색어가 든 것만 거른다.
/// ponytail: 첫 장 안에서만 찾는다 — 서버 검색(fn 또는 색인)이 생기면 [_run] 만 바꾼다.
class InsideSearchScreen extends StatefulWidget {
const InsideSearchScreen({
super.key,
this.repository = const SampleInsideRepository(),
this.translation,
});
final InsideRepository repository;
final TranslationRepository? translation;
@override
State<InsideSearchScreen> createState() => _InsideSearchScreenState();
}
class _InsideSearchScreenState extends State<InsideSearchScreen> {
final _controller = TextEditingController();
/// 최근 검색어. 기기 안에만 두는 값이다(갤럭시 검색과 같다).
final List<String> _recent = [];
String? _term;
InsideSort _sort = InsideSort.latest;
List<InsideQuestion>? _hits;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _submit(String q) {
final term = q.trim();
if (term.isEmpty) return;
setState(() {
_term = term;
_recent
..remove(term)
..insert(0, term);
if (_recent.length > 10) _recent.removeLast();
});
_run();
}
Future<void> _run() async {
final term = _term;
if (term == null) return;
setState(() => _hits = null);
final snap = await widget.repository.load(sort: _sort);
if (!mounted || term != _term) return;
final k = term.toLowerCase();
setState(
() => _hits = [
for (final q in snap.questions)
if (q.title.toLowerCase().contains(k) ||
q.body.toLowerCase().contains(k))
q,
],
);
}
void _clear() {
if (_controller.text.isEmpty) return;
_controller.clear();
setState(() {
_term = null;
_hits = null;
});
}
@override
Widget build(BuildContext context) {
final term = _term;
final hits = _hits;
return Scaffold(
appBar: AppBar(
toolbarHeight: 54,
leadingWidth: 58,
leading: Padding(
padding: const EdgeInsets.only(left: AppSpace.s4),
child: BackButton(onPressed: () => Navigator.of(context).maybePop()),
),
titleSpacing: 0,
title: _SearchField(
controller: _controller,
onSubmitted: _submit,
onChanged: (v) {
if (v.isEmpty) _clear();
},
),
actions: const [SizedBox(width: AppSpace.s4)],
bottom: term == null
? null
: PreferredSize(
preferredSize: const Size.fromHeight(36),
child: InsideSortBar(
sort: _sort,
onChanged: (s) {
setState(() => _sort = s);
_run();
},
),
),
),
body: term == null
? _Recent(
terms: _recent,
onClear: () => setState(_recent.clear),
onPick: (t) {
_controller.text = t;
_submit(t);
},
)
: hits == null
? const Center(child: CircularProgressIndicator())
: hits.isEmpty
? Center(
child: Text(
context.l10n.commonNoResults,
style: insideText(14, color: const Color(0xFF6E6E6E)),
),
)
: InsideQuestionList(
questions: hits,
onOpen: (q) => Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => InsideDetailScreen(
question: q,
repository: widget.repository,
translation: widget.translation,
),
),
),
),
);
}
}
/// 검색 칸 — 시안 301×42 · #282828 · 모서리 10 · 돋보기 20 · 16/500.
class _SearchField extends StatelessWidget {
const _SearchField({
required this.controller,
required this.onSubmitted,
required this.onChanged,
});
final TextEditingController controller;
final ValueChanged<String> onSubmitted;
final ValueChanged<String> onChanged;
static const _hint = Color(0xFF6E6E6E);
@override
Widget build(BuildContext context) {
final style = insideText(16, height: 1.4).copyWith(letterSpacing: 0);
return Container(
height: 42,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: const Color(0xFF282828),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
const ImageIcon(AppIcons.search, size: 20, color: _hint),
const SizedBox(width: 6),
Expanded(
child: TextField(
controller: controller,
autofocus: true,
textInputAction: TextInputAction.search,
onSubmitted: onSubmitted,
onChanged: onChanged,
style: style,
cursorColor: AppColors.cosmicAccent,
decoration: InputDecoration(
isCollapsed: true,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
hintText: context.l10n.insideSearchHint,
hintStyle: style.copyWith(color: _hint),
),
),
),
],
),
);
}
}
class _Recent extends StatelessWidget {
const _Recent({
required this.terms,
required this.onClear,
required this.onPick,
});
final List<String> terms;
final VoidCallback onClear;
final ValueChanged<String> onPick;
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.symmetric(vertical: AppSpace.s4),
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: 9),
child: PlayBanner(),
),
const SizedBox(height: AppSpace.s5),
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
context.l10n.commonRecentSearches,
style: insideText(
16,
weight: FontWeight.w600,
height: 20 / 16,
).copyWith(letterSpacing: 0),
),
GestureDetector(
onTap: onClear,
child: Text(
context.l10n.commonClearAll,
style: insideText(
14,
color: const Color(0xFFC2C2C2),
height: 20 / 14,
).copyWith(letterSpacing: 0),
),
),
],
),
),
const SizedBox(height: AppSpace.s4),
if (terms.isEmpty)
SizedBox(
height: 140,
child: Center(
child: Text(
context.l10n.commonNoRecentSearches,
style: insideText(
14,
weight: FontWeight.w600,
color: const Color(0xFF6E6E6E),
height: 20 / 14,
).copyWith(letterSpacing: 0),
),
),
)
else
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
child: Wrap(
spacing: AppSpace.s2,
runSpacing: AppSpace.s2,
children: [
for (final term in terms)
ActionChip(label: Text(term), onPressed: () => onPick(term)),
],
),
),
],
);
}
}
+154
View File
@@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart';
import '../../core/ui/relative_time.dart';
import '../home/home_screen.dart' show formatPop;
import 'inside_data.dart';
/// INSIDE 화면군이 같이 쓰는 시안 조각 — 아이콘 · 글자 · 작성자 줄 · 현상금 칩 · 반응 줄.
///
/// 아이콘은 Figma Iconset(Lucide) 을 3배 PNG 로 둔 것이다. 공용 [AppIcons] 로 올릴 때까지
/// 여기 둔다(디자인 일치 2단계 「공유 변경 요청」).
/// 시안 INSIDE 의 회색 단계.
abstract final class InsideColors {
static const stat = Color(0xFF4F4F4F);
static const avatar = Color(0xFFD9D9D9);
static const rule = Color(0xFF1A1A1A);
static const hint = Color(0xFF6B6B6B);
static const chipLabel = Color(0xFFD5D7D9);
}
/// 42dot Sans · 자간 -2.5% · 행간 130%(시안 INSIDE 본문 기본값).
TextStyle insideText(
double size, {
FontWeight weight = FontWeight.w500,
Color color = AppColors.onCosmic,
double height = 1.3,
}) => TextStyle(
fontFamily: AppFonts.body,
fontSize: size,
fontWeight: weight,
height: height,
letterSpacing: -0.025 * size,
color: color,
);
/// 작성자 닉네임 — ABeeZee 12(한글은 42dot 대체).
const insideNameStyle = TextStyle(
fontFamily: AppFonts.display,
fontFamilyFallback: [AppFonts.body],
fontSize: 12,
height: 1.3,
letterSpacing: -0.3,
color: AppColors.onCosmic,
);
/// 첨부 이미지. `assets/` 로 시작하면 번들 표본이다.
ImageProvider insideImage(String url) =>
url.startsWith('assets/') ? AssetImage(url) : NetworkImage(url);
/// 아바타 26 · 이름 · (있으면) 시각 줄.
class InsideAuthor extends StatelessWidget {
const InsideAuthor({super.key, required this.name, this.meta});
final String name;
final String? meta;
@override
Widget build(BuildContext context) => Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 26,
height: 26,
decoration: const BoxDecoration(
color: InsideColors.avatar,
shape: BoxShape.circle,
),
),
const SizedBox(width: 9),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(name, style: insideNameStyle),
if (meta != null)
Text(meta!, style: insideText(10, color: AppColors.onCosmicMuted)),
],
),
],
);
}
/// 현상금 칩 — 시안 58×24 · 모서리 2 · 번개 + 「500 P」. 채택이 끝난 질문은 회색이다.
class InsideBountyChip extends StatelessWidget {
const InsideBountyChip({super.key, required this.question});
final InsideQuestion question;
@override
Widget build(BuildContext context) {
final c = question.isSolved ? InsideColors.stat : AppColors.cosmicBlue;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: c.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(2),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ImageIcon(AppIcons.bolt, size: 10, color: c),
const SizedBox(width: 4),
Text(
'${formatPop(question.bountyPop)} P',
style: insideText(12, color: c),
),
],
),
);
}
}
/// 좋아요 · 댓글 · 조회 줄. 0 이면 숫자를 빼고 아이콘만 둔다(시안 「아미보이」 카드).
class InsideStats extends StatelessWidget {
const InsideStats({
super.key,
required this.question,
this.size = 14,
this.textSize = 12,
this.color = InsideColors.stat,
});
final InsideQuestion question;
final double size;
final double textSize;
final Color color;
@override
Widget build(BuildContext context) {
Widget stat(ImageProvider icon, int n) => Row(
mainAxisSize: MainAxisSize.min,
children: [
ImageIcon(icon, size: size, color: color),
if (n > 0) ...[
const SizedBox(width: 4),
Text(
formatReaction(n),
style: insideText(textSize, color: color, height: 1),
),
],
],
);
return Row(
children: [
stat(AppIcons.heart, question.likeCount),
const SizedBox(width: 12),
stat(AppIcons.messageCircle, question.answerCount),
const SizedBox(width: 12),
stat(AppIcons.eye, question.viewCount),
],
);
}
}
@@ -0,0 +1,359 @@
import 'package:flutter/material.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart';
import '../home/home_screen.dart' show formatPop;
import 'inside_data.dart';
import 'inside_ui.dart';
/// 인사이드 작성 — Figma `199:44733`(빈 상태) · `200:47747` 등(입력 상태).
///
/// 현상금을 걸면 POP 이 예치된다. **예치는 서버가 한다** — 별첨 1 ② 「클라이언트 단독
/// 지급 0건」이라 화면은 금액만 받아 `fn-question-create` 로 보낸다. 등록이 끝나면
/// `true` 로 닫혀 목록이 다시 읽는다.
class InsideWriteScreen extends StatefulWidget {
const InsideWriteScreen({
super.key,
this.repository = const SampleInsideRepository(),
this.myPop,
});
final InsideRepository repository;
/// 보유 POP(일반 + 기여). 현상금 상한을 여기에 맞춘다. 비우면 [repository] 에서 읽는다.
final int? myPop;
@override
State<InsideWriteScreen> createState() => _InsideWriteScreenState();
}
class _InsideWriteScreenState extends State<InsideWriteScreen> {
final _form = GlobalKey<FormState>();
final _title = TextEditingController();
final _body = TextEditingController();
final _bounty = TextEditingController();
late int _myPop = widget.myPop ?? 0;
/// 현상금 하한. 서버 운영값을 읽기 전에는 기본값 100 이다.
int _minBounty = 100;
bool _saving = false;
/// 등록 재시도 번호. 이 화면이 열려 있는 동안 하나를 유지해 실패 뒤 재시도가 같은
/// 번호로 나간다 — 서버가 두 번 예치하지 않는다. 성공한 뒤에만 새로 뽑는다.
String _clientSeq = _newSeq();
static String _newSeq() => '${DateTime.now().microsecondsSinceEpoch}';
@override
void initState() {
super.initState();
if (widget.myPop == null) {
widget.repository.balance().then((v) {
if (mounted) setState(() => _myPop = v);
}, onError: (_) {});
}
widget.repository.minBounty().then((v) {
if (mounted) setState(() => _minBounty = v);
}, onError: (_) {});
// 「완료」 색이 입력에 따라 바뀐다(시안: 비었을 때 #474747, 채우면 흰색).
for (final c in [_title, _body, _bounty]) {
c.addListener(() => setState(() {}));
}
}
@override
void dispose() {
_title.dispose();
_body.dispose();
_bounty.dispose();
super.dispose();
}
bool get _filled =>
_title.text.trim().isNotEmpty &&
_body.text.trim().isNotEmpty &&
_bounty.text.trim().isNotEmpty;
Future<void> _submit() async {
if (_saving || !(_form.currentState?.validate() ?? false)) return;
setState(() => _saving = true);
try {
await widget.repository.create(
title: _title.text.trim(),
body: _body.text.trim(),
bountyPop: int.parse(_bounty.text.trim()),
clientSeq: _clientSeq,
);
_clientSeq = _newSeq();
} catch (error) {
if (!mounted) return;
setState(() => _saving = false);
_notice(insideErrorMessage(error));
return;
}
if (mounted) Navigator.of(context).pop(true);
}
void _notice(String message) => ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
InputDecoration _plain(String hint, TextStyle style) => InputDecoration(
isCollapsed: true,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
hintText: hint,
hintStyle: style.copyWith(color: InsideColors.hint),
);
@override
Widget build(BuildContext context) {
final titleStyle = insideText(20, weight: FontWeight.w600, height: 1);
final bodyStyle = insideText(15, height: 1.3);
return Scaffold(
appBar: AppBar(
leading: const CloseButton(),
title: Text(context.l10n.insideWrite),
actions: [
GestureDetector(
onTap: _submit,
behavior: HitTestBehavior.opaque,
child: SizedBox(
width: 56,
child: Center(
child: Text(
context.l10n.commonDone,
style: insideText(
14,
weight: FontWeight.w600,
height: 1,
color: _filled
? AppColors.onCosmic
: const Color(0xFF474747),
),
),
),
),
),
],
),
body: SafeArea(
top: false,
child: Form(
key: _form,
child: Column(
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.all(AppSpace.s4),
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: TextFormField(
controller: _title,
maxLength: 60,
style: titleStyle,
decoration: _plain(
context.l10n.commonTitleHint,
titleStyle,
).copyWith(counterText: ''),
validator: (v) => (v ?? '').trim().isEmpty
? context.l10n.insideTitleRequired
: null,
),
),
const SizedBox(height: AppSpace.s3),
const Divider(
height: 1,
thickness: 1,
color: Color(0xFF313131),
),
const SizedBox(height: AppSpace.s3),
SizedBox(
height: 180,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: TextFormField(
controller: _body,
maxLines: null,
expands: true,
maxLength: 2000,
textAlignVertical: TextAlignVertical.top,
style: bodyStyle,
decoration: _plain(
context.l10n.insideBodyHint,
bodyStyle,
).copyWith(counterText: ''),
validator: (v) => (v ?? '').trim().isEmpty
? context.l10n.insideBodyRequired
: null,
),
),
),
const SizedBox(height: AppSpace.s5),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Text(
context.l10n.insideBountySet,
style: insideText(
16,
weight: FontWeight.w600,
height: 1,
),
),
),
const SizedBox(height: AppSpace.s3),
_BountyField(
controller: _bounty,
style: bodyStyle,
decoration: _plain(
context.l10n.insideBountyHint('$_minBounty'),
bodyStyle,
),
myPop: _myPop,
minBounty: _minBounty,
),
const SizedBox(height: AppSpace.s2),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Text.rich(
TextSpan(
text: context.l10n.commonMyPop,
children: [
TextSpan(
text: formatPop(_myPop),
style: const TextStyle(
color: AppColors.cosmicBlue,
),
),
],
),
style: insideText(
14,
color: AppColors.onCosmicVariant,
height: 1,
),
),
),
],
),
),
// 사진 · 앨범 — 시안 28 칸 아이콘 둘. 첨부 업로드 경로가 아직 없다.
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpace.s4,
vertical: AppSpace.s2,
),
child: Row(
children: [
// 두 칸 모두 28. image.png 는 여백이 커서(84 중 46) 28 로 그려야 시안 글리프 15 가 된다.
for (final (icon, size) in [
(AppIcons.camera, 20.0),
(AppIcons.image, 28.0),
])
Padding(
padding: const EdgeInsets.only(right: 20),
child: GestureDetector(
onTap: () => _notice(context.l10n.insideAttachLater),
child: SizedBox.square(
dimension: 28,
child: Center(
child: ImageIcon(
icon,
size: size,
color: AppColors.onCosmic,
),
),
),
),
),
],
),
),
],
),
),
),
);
}
}
/// 현상금 칸 — 시안 343×48 · #545454 획 · 모서리 8 · 오른쪽 「POP」.
class _BountyField extends StatelessWidget {
const _BountyField({
required this.controller,
required this.style,
required this.decoration,
required this.myPop,
required this.minBounty,
});
final TextEditingController controller;
final TextStyle style;
final InputDecoration decoration;
final int myPop;
final int minBounty;
@override
Widget build(BuildContext context) => FormField<String>(
validator: (_) {
final raw = controller.text.trim();
// 명세 2.60-1 — 완료는 제목 · 글 · 현상금이 모두 채워져야 한다.
if (raw.isEmpty) return context.l10n.insideBountyRequired;
final amount = int.tryParse(raw);
if (amount == null || amount < 0) return context.l10n.insideNumberOnly;
// 기능 명세 2.60-3 「100POP 이상」. 운영값이라 서버(fn-question-create)와 같은
// `config/policy.qnaMinBountyPop` 을 읽는다.
if (amount < minBounty) return context.l10n.insideBountyMin('$minBounty');
if (amount > myPop) return context.l10n.insideBountyOverBalance;
return null;
},
builder: (field) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
foregroundDecoration: BoxDecoration(
border: Border.all(color: const Color(0xFF545454)),
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Row(
children: [
Expanded(
child: TextField(
controller: controller,
keyboardType: TextInputType.number,
style: style,
decoration: decoration,
),
),
const SizedBox(width: AppSpace.s4),
const Text(
'POP',
style: TextStyle(
fontFamily: AppFonts.display,
fontSize: 16,
height: 1.5,
color: AppColors.cosmicBlue,
),
),
],
),
),
if (field.errorText != null)
Padding(
padding: const EdgeInsets.only(top: AppSpace.s1, left: 2),
child: Text(
field.errorText!,
style: insideText(12, color: AppColors.error),
),
),
],
),
);
}