Files
fanuzu/app/lib/features/inside/inside_screen.dart
T

528 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/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 탭 루트 — 화면 대조 원장 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
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),
),
],
),
),
),
);
}
}