INSIDE · STATION · 참여형 번역 · 알림 · MY 화면
This commit is contained in:
@@ -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},
|
||||
);
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user