// ๐Ÿ—ฃ๏ธ ์ปค๋ฎค๋‹ˆํ‹ฐ ๊ฒŒ์‹œํŒ(๋ฐฑํŒ) ํ™ˆ ํ™”๋ฉด (UI ์ „์šฉ). ์นดํ…Œ๊ณ ๋ฆฌ ํƒญ + ๊ธ€ ๋ชฉ๋ก + ๊ธ€์“ฐ๊ธฐ. // ์„œ๋ฒ„ ํ†ต์‹ /์ƒํƒœ๋Š” lib/function/board_list_controller.dart, board_write_controller.dart๊ฐ€ ๋‹ด๋‹นํ•œ๋‹ค. import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import '../config.dart'; import '../function/board_list_controller.dart'; import '../theme/app_palette.dart'; import 'board_post_detail_screen.dart'; import 'board_report_screen.dart'; import 'board_write_screen.dart'; import 'launchpad_transition.dart'; import 'title_pill.dart'; class BoardHomeScreen extends StatefulWidget { final String studentId; final bool canModerate; const BoardHomeScreen({ super.key, required this.studentId, this.canModerate = false, }); @override State createState() => _BoardHomeScreenState(); } class _BoardHomeScreenState extends State with SingleTickerProviderStateMixin { List _categories = []; bool _isLoadingCategories = true; TabController? _tabController; final Map _controllers = {}; @override void initState() { super.initState(); _fetchCategories(); } @override void dispose() { _tabController?.dispose(); for (final c in _controllers.values) { c.dispose(); } super.dispose(); } Future _fetchCategories() async { try { final response = await http.get( Uri.parse('$baseUrl/api/board/categories'), ); if (response.statusCode == 200) { final data = jsonDecode(utf8.decode(response.bodyBytes)); _categories = data['categories'] ?? []; } } catch (_) { // ์กฐ์šฉํžˆ ๋ฌด์‹œ - ์•„๋ž˜์—์„œ ๋ชฉ๋ก์ด ๋น„์–ด์žˆ์œผ๋ฉด ์—๋Ÿฌ๋กœ ์ฒ˜๋ฆฌํ•œ๋‹ค. } finally { if (mounted) { setState(() { _isLoadingCategories = false; if (_categories.isNotEmpty) { _tabController = TabController( length: _categories.length, vsync: this, ); } }); } } } BoardListController _controllerFor(String category) { return _controllers.putIfAbsent( category, () => BoardListController(category: category, studentId: widget.studentId) ..init(), ); } Future _openWrite() async { final tabController = _tabController; if (tabController == null) return; final category = _categories[tabController.index]; final changed = await pushLaunchpad( context, (context) => BoardWriteScreen( studentId: widget.studentId, category: category['key'], categoryName: category['name'], ), ); if (changed == true) { _controllerFor(category['key']).refresh(); } } void _openModeration() { pushLaunchpad( context, (context) => BoardReportScreen(reviewerId: widget.studentId), ); } Future _openPost(int postId, String category) async { final changed = await pushLaunchpad( context, (context) => BoardPostDetailScreen(postId: postId, studentId: widget.studentId), ); if (changed == true) { _controllerFor(category).refresh(); } } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppPalette.mist, appBar: AppBar( backgroundColor: Colors.transparent, foregroundColor: AppPalette.ink, elevation: 0, centerTitle: true, title: const TitlePill('๋ฐฑํŒ'), actions: [ if (widget.canModerate) IconButton( icon: const Icon(Icons.shield_outlined), tooltip: '์‹ ๊ณ  ๊ฒ€ํ† ', onPressed: _openModeration, ), ], bottom: _tabController == null ? null : TabBar( controller: _tabController, labelColor: AppPalette.ink, unselectedLabelColor: Colors.grey, indicatorColor: AppPalette.ink, tabs: [for (final c in _categories) Tab(text: c['name'])], ), ), floatingActionButton: _tabController == null ? null : FloatingActionButton( backgroundColor: AppPalette.ink, foregroundColor: AppPalette.paper, onPressed: _openWrite, child: const Icon(Icons.edit_rounded), ), body: _isLoadingCategories ? const Center(child: CircularProgressIndicator()) : _tabController == null ? Center( child: TextButton( onPressed: () { setState(() => _isLoadingCategories = true); _fetchCategories(); }, child: const Text('๊ฒŒ์‹œํŒ์„ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„'), ), ) : TabBarView( controller: _tabController, children: [ for (final c in _categories) _CategoryBoardList( controller: _controllerFor(c['key']), onOpenPost: (postId) => _openPost(postId, c['key']), ), ], ), ); } } class _CategoryBoardList extends StatefulWidget { final BoardListController controller; final ValueChanged onOpenPost; const _CategoryBoardList({ required this.controller, required this.onOpenPost, }); @override State<_CategoryBoardList> createState() => _CategoryBoardListState(); } class _CategoryBoardListState extends State<_CategoryBoardList> { final _scrollController = ScrollController(); @override void initState() { super.initState(); _scrollController.addListener(() { if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { widget.controller.loadMore(); } }); } @override void dispose() { _scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return ListenableBuilder( listenable: widget.controller, builder: (context, _) { if (widget.controller.isLoading) { return const Center(child: CircularProgressIndicator()); } final posts = widget.controller.posts; if (posts.isEmpty) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Text( '์•„์ง ๊ธ€์ด ์—†์–ด์š”.\n์ฒซ ๊ธ€์„ ๋‚จ๊ฒจ๋ณด์„ธ์š”.', textAlign: TextAlign.center, style: TextStyle(color: Colors.grey), ), const SizedBox(height: 8), TextButton( onPressed: widget.controller.refresh, child: const Text('์ƒˆ๋กœ๊ณ ์นจ'), ), ], ), ); } return RefreshIndicator( onRefresh: widget.controller.refresh, child: ListView.builder( controller: _scrollController, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), itemCount: posts.length + (widget.controller.hasMore ? 1 : 0), itemBuilder: (context, index) { if (index >= posts.length) { return const Padding( padding: EdgeInsets.symmetric(vertical: 16), child: Center( child: SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ), ), ); } final post = posts[index]; return Container( margin: const EdgeInsets.only(bottom: 8), decoration: BoxDecoration( color: AppPalette.paper, borderRadius: BorderRadius.circular(14), border: Border.all(color: AppPalette.sage), ), child: ListTile( title: Text( post['title'] ?? '', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.bold), ), subtitle: Text( '${post['createdAt'] ?? ''}', style: const TextStyle(fontSize: 12), ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.mode_comment_outlined, size: 15, color: Colors.grey, ), const SizedBox(width: 3), Text('${post['commentCount'] ?? 0}'), ], ), onTap: () => widget.onOpenPost(post['id'] as int), ), ); }, ), ); }, ); } }