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 createState() => _InsideScreenState(); } class _InsideScreenState extends State { final _scroll = ScrollController(); List _questions = const []; InsideSort _sort = InsideSort.latest; bool _loading = true; bool _more = false; bool _end = false; bool _canWrite = true; String? _error; @override void initState() { super.initState(); _scroll.addListener(() { if (_scroll.position.pixels > _scroll.position.maxScrollExtent - 240) { _loadMore(); } }); _load(); } @override void dispose() { _scroll.dispose(); super.dispose(); } Future _load() async { setState(() { _loading = true; _error = null; }); try { final snap = await widget.repository.load(sort: _sort); if (!mounted) return; setState(() { _questions = snap.questions; _canWrite = snap.canWrite; _loading = false; // 첫 장이 한 쪽을 못 채웠으면 뒤에 더 없다. _end = snap.questions.length < 5; }); } on Object { if (!mounted) return; setState(() { _loading = false; _error = context.l10n.insideLoadFailed; }); } } Future _loadMore() async { if (_more || _end || _loading) return; setState(() => _more = true); try { final next = await widget.repository.more(sort: _sort); if (!mounted) return; setState(() { _questions = [..._questions, ...next]; _more = false; _end = next.isEmpty; }); } on Object { if (!mounted) return; // 다음 장 실패는 목록 전체를 지우지 않는다. 이미 본 것은 그대로 둔다. setState(() { _more = false; _end = true; }); } } void _changeSort(InsideSort sort) { if (sort == _sort) return; setState(() { _sort = sort; _questions = const []; _end = false; }); _load(); } Future _open(InsideQuestion q) async { await Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (_) => InsideDetailScreen( question: q, repository: widget.repository, translation: widget.translation, ), ), ); } void _search() => Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (_) => InsideSearchScreen( repository: widget.repository, translation: widget.translation, ), ), ); void _notifications() => Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (_) => NotificationScreen(repository: widget.notifications), ), ); Future _write() async { final created = await Navigator.of(context, rootNavigator: true).push( MaterialPageRoute( builder: (_) => InsideWriteScreen(repository: widget.repository), ), ); if (created == true && mounted) await _load(); } @override Widget build(BuildContext context) { final t = Theme.of(context).textTheme; return Scaffold( // 시안 Top Navigation(196:2673) — 높이 54 · 제목 ABeeZee 20 왼쪽 · 오른쪽 42 칸 아이콘 둘. appBar: AppBar( toolbarHeight: 54, titleSpacing: AppSpace.s4, centerTitle: false, title: const Text( 'INSIDE', style: TextStyle( fontFamily: AppFonts.display, fontSize: 20, height: 1.4, color: AppColors.onCosmic, ), ), actions: [ _HeaderIcon( icon: AppIcons.search, tooltip: context.l10n.commonSearch, onTap: _search, ), _HeaderIcon( icon: AppIcons.bell, tooltip: context.l10n.homeNotificationsTooltip, onTap: _notifications, ), const SizedBox(width: AppSpace.s4), ], bottom: PreferredSize( preferredSize: const Size.fromHeight(36), child: InsideSortBar(sort: _sort, onChanged: _changeSort), ), ), floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, floatingActionButton: _canWrite ? Padding( // 시안은 하단 탭 위 20 에 뜬다(기본 여백 16). padding: const EdgeInsets.only(bottom: 4), child: InsideWriteFab(onTap: _write), ) : null, body: switch ((_loading, _error, _questions.isEmpty)) { (true, _, _) => const Center(child: CircularProgressIndicator()), (_, final String message, _) => _Failed( message: message, onRetry: _load, ), (_, _, true) => Center( child: Text(context.l10n.insideEmpty, style: t.bodyMedium), ), _ => RefreshIndicator( onRefresh: _load, child: InsideQuestionList( controller: _scroll, questions: _questions, loadingMore: _more, onOpen: _open, ), ), }, ); } } class _HeaderIcon extends StatelessWidget { const _HeaderIcon({ required this.icon, required this.tooltip, required this.onTap, }); final ImageProvider icon; final String tooltip; final VoidCallback onTap; @override Widget build(BuildContext context) => SizedBox.square( dimension: 42, child: IconButton( tooltip: tooltip, padding: EdgeInsets.zero, onPressed: onTap, icon: ImageIcon(icon, size: 24, color: AppColors.onCosmic), ), ); } /// 질문 카드 목록 — 시안 안쪽 16 · 카드 사이 4 + 1px #1a1a1a 줄 + 4. class InsideQuestionList extends StatelessWidget { const InsideQuestionList({ super.key, required this.questions, required this.onOpen, this.controller, this.loadingMore = false, }); final List questions; final ValueChanged onOpen; final ScrollController? controller; final bool loadingMore; @override Widget build(BuildContext context) => ListView.separated( controller: controller, padding: const EdgeInsets.fromLTRB( AppSpace.s4, AppSpace.s4, AppSpace.s4, AppSpace.s8, ), itemCount: questions.length + (loadingMore ? 1 : 0), separatorBuilder: (_, _) => const Divider(height: 9, thickness: 1, color: InsideColors.rule), itemBuilder: (context, i) { if (i >= questions.length) { return const Padding( padding: EdgeInsets.all(AppSpace.s4), child: Center(child: CircularProgressIndicator()), ); } final q = questions[i]; return InsideQuestionCard(question: q, onTap: () => onOpen(q)); }, ); } /// 작성 버튼 — 시안 130×36 알약 · #19191c · #232324 획 · 연필 16 + 「인사이드 작성」. class InsideWriteFab extends StatelessWidget { const InsideWriteFab({super.key, required this.onTap}); final VoidCallback onTap; @override Widget build(BuildContext context) => Material( color: const Color(0xFF19191C), shape: const StadiumBorder( side: BorderSide(color: AppColors.cosmicSurfaceRaised), ), child: InkWell( onTap: onTap, customBorder: const StadiumBorder(), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Image(image: AppIcons.pencil, width: 16, height: 16), const SizedBox(width: 6), Text( context.l10n.insideWrite, style: insideText( 14, color: AppColors.onCosmicVariant, height: 20 / 14, ).copyWith(letterSpacing: 0), ), ], ), ), ), ); } /// 정렬 칩 줄 — 시안 높이 36 · 좌우 16 · 칩 사이 4. class InsideSortBar extends StatelessWidget { const InsideSortBar({super.key, required this.sort, required this.onChanged}); final InsideSort sort; final ValueChanged onChanged; @override Widget build(BuildContext context) => SizedBox( height: 36, child: ListView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4, vertical: 2), children: [ for (final s in InsideSort.values) ...[ _SortChip(sort: s, selected: sort == s, onTap: () => onChanged(s)), const SizedBox(width: AppSpace.s1), ], ], ), ); } class _Failed extends StatelessWidget { const _Failed({required this.message, required this.onRetry}); final String message; final VoidCallback onRetry; @override Widget build(BuildContext context) { final t = Theme.of(context).textTheme; return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text(message, style: t.titleMedium), const SizedBox(height: AppSpace.s2), Text(context.l10n.homeRetryLater, style: t.bodyMedium), const SizedBox(height: AppSpace.s4), FilledButton( onPressed: onRetry, child: Text(context.l10n.commonRetry), ), ], ), ); } } /// 목록의 질문 카드 한 장 — 시안 `196:32243`. /// /// 명세 2.70-2 — *생성자 닉네임 · 작성일시 · 좋아요 수 · 댓글 수*. /// 작성일시는 공용 [relativeTime] 을 쓴다 — 7일이 넘으면 `2026.01.01` 로 바뀐다. class InsideQuestionCard extends StatelessWidget { const InsideQuestionCard({ super.key, required this.question, required this.onTap, }); final InsideQuestion question; final VoidCallback onTap; @override Widget build(BuildContext context) { final text = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( question.title, style: insideText(14), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: AppSpace.s2), Text( question.body, style: insideText(12, color: AppColors.onCosmicVariant), maxLines: 2, overflow: TextOverflow.ellipsis, ), ], ); return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(AppRadius.sm), child: Padding( padding: const EdgeInsets.all(AppSpace.s4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ InsideAuthor( name: question.authorName, meta: relativeTime(question.createdAt), ), if (question.bountyPop > 0) InsideBountyChip(question: question), ], ), const SizedBox(height: AppSpace.s3), if (question.imageUrls.isEmpty) text else Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: text), const SizedBox(width: 36), ClipRRect( borderRadius: BorderRadius.circular(AppRadius.sm), child: Image( image: insideImage(question.imageUrls.first), width: 70, height: 70, fit: BoxFit.cover, ), ), ], ), const SizedBox(height: AppSpace.s4), InsideStats(question: question), ], ), ), ); } } /// 정렬 칩 — 시안 Chip(196:2673): r8 · 71×32 · 아이콘 16. /// 선택은 흰 바탕에 검정 글자, 비선택은 #1a1a1a 바탕 · #232324 획 · #d5d7d9 글자. class _SortChip extends StatelessWidget { const _SortChip({ required this.sort, required this.selected, required this.onTap, }); final InsideSort sort; final bool selected; final VoidCallback onTap; static ImageProvider _icon(InsideSort s) => switch (s) { InsideSort.latest => AppIcons.clock, InsideSort.popular => AppIcons.heart, InsideSort.bounty => AppIcons.bolt, }; @override Widget build(BuildContext context) { final fg = selected ? const Color(0xFF000000) : InsideColors.chipLabel; return Material( color: selected ? AppColors.onCosmic : const Color(0xFF1A1A1A), borderRadius: BorderRadius.circular(AppRadius.sm), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(AppRadius.sm), child: Container( height: 32, padding: const EdgeInsets.symmetric(horizontal: 10), decoration: BoxDecoration( borderRadius: BorderRadius.circular(AppRadius.sm), border: Border.all(color: AppColors.cosmicSurfaceRaised), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ // 시안 POP순 칩의 번개는 16 칸이 아니라 10×10.6 벡터다. ImageIcon( _icon(sort), size: sort == InsideSort.bounty ? 10.6 : 16, color: fg, ), const SizedBox(width: AppSpace.s1), Text( sort.label, style: insideText( 12, color: fg, height: 20 / 12, ).copyWith(letterSpacing: 0), ), ], ), ), ), ); } }