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