From 93104362bee7f3221e5ec59834f8fe6dc6f15de8 Mon Sep 17 00:00:00 2001 From: sihoo Date: Tue, 15 Sep 2026 00:59:04 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9D=B5=EB=AA=85=20=EC=BB=A4=EB=AE=A4?= =?UTF-8?q?=EB=8B=88=ED=8B=B0=20=EA=B2=8C=EC=8B=9C=ED=8C=90(=EB=B0=B1?= =?UTF-8?q?=ED=8C=90)=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 디시인사이드식 고정 카테고리 게시판 + 에타 스타일 익명(글 안에서만 유효한 익명 번호) 댓글. 신고는 자동 삭제 없이 운영자(교사/관리자) 검토 후 처리하도록 별도 신고 검토 화면을 대시보드에 추가. Co-Authored-By: Claude Sonnet 5 --- lib/function/board_list_controller.dart | 76 +++++ lib/function/board_post_controller.dart | 143 ++++++++ lib/function/board_report_controller.dart | 77 +++++ lib/function/board_write_controller.dart | 48 +++ lib/ui/board_home_screen.dart | 303 ++++++++++++++++ lib/ui/board_post_detail_screen.dart | 399 ++++++++++++++++++++++ lib/ui/board_report_screen.dart | 169 +++++++++ lib/ui/board_write_screen.dart | 138 ++++++++ lib/ui/main_dashboard.dart | 28 ++ 9 files changed, 1381 insertions(+) create mode 100644 lib/function/board_list_controller.dart create mode 100644 lib/function/board_post_controller.dart create mode 100644 lib/function/board_report_controller.dart create mode 100644 lib/function/board_write_controller.dart create mode 100644 lib/ui/board_home_screen.dart create mode 100644 lib/ui/board_post_detail_screen.dart create mode 100644 lib/ui/board_report_screen.dart create mode 100644 lib/ui/board_write_screen.dart diff --git a/lib/function/board_list_controller.dart b/lib/function/board_list_controller.dart new file mode 100644 index 0000000..b1d68d4 --- /dev/null +++ b/lib/function/board_list_controller.dart @@ -0,0 +1,76 @@ +// 🗣️ 커뮤니티 게시판 - 카테고리별 게시글 목록 화면의 기능(서버 통신/상태) 담당 컨트롤러. +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import '../config.dart'; + +class BoardListController extends ChangeNotifier { + final String category; + final String studentId; + + BoardListController({required this.category, required this.studentId}); + + bool _isLoading = true; + bool _isLoadingMore = false; + bool _hasMore = true; + int _page = 1; + List _posts = []; + bool _disposed = false; + + bool get isLoading => _isLoading; + bool get isLoadingMore => _isLoadingMore; + bool get hasMore => _hasMore; + List get posts => _posts; + + static const int _pageSize = 20; + + void _safeNotify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } + + Future init() => refresh(); + + Future refresh() async { + _isLoading = true; + _page = 1; + _hasMore = true; + _safeNotify(); + await _fetchPage(replace: true); + _isLoading = false; + _safeNotify(); + } + + Future loadMore() async { + if (_isLoadingMore || !_hasMore || _isLoading) return; + _isLoadingMore = true; + _safeNotify(); + _page += 1; + await _fetchPage(replace: false); + _isLoadingMore = false; + _safeNotify(); + } + + Future _fetchPage({required bool replace}) async { + try { + final response = await http.get( + Uri.parse( + '$baseUrl/api/board/posts?category=$category&studentId=$studentId&page=$_page', + ), + ); + if (response.statusCode == 200) { + final data = jsonDecode(utf8.decode(response.bodyBytes)); + final fetched = (data['posts'] as List?) ?? []; + _posts = replace ? fetched : [..._posts, ...fetched]; + _hasMore = fetched.length >= _pageSize; + } + } catch (_) { + // 조용히 무시 - 마지막으로 받아온 목록을 유지한다. + } + } +} diff --git a/lib/function/board_post_controller.dart b/lib/function/board_post_controller.dart new file mode 100644 index 0000000..065bad8 --- /dev/null +++ b/lib/function/board_post_controller.dart @@ -0,0 +1,143 @@ +// 💬 커뮤니티 게시판 - 게시글 상세(+댓글) 화면의 기능(서버 통신/상태) 담당 컨트롤러. +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import '../config.dart'; + +class BoardPostController extends ChangeNotifier { + final int postId; + final String studentId; + + BoardPostController({required this.postId, required this.studentId}); + + bool _isLoading = true; + bool _isBusy = false; + bool _postDeleted = false; + Map? _post; + List _comments = []; + bool _disposed = false; + + bool get isLoading => _isLoading; + bool get isBusy => _isBusy; + bool get postDeleted => _postDeleted; + Map? get post => _post; + List get comments => _comments; + + void _safeNotify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } + + Future init() => fetchDetail(); + + Future fetchDetail() async { + _isLoading = true; + _safeNotify(); + try { + final response = await http.get( + Uri.parse('$baseUrl/api/board/posts/$postId?studentId=$studentId'), + ); + final result = jsonDecode(utf8.decode(response.bodyBytes)); + if (response.statusCode == 200 && result['status'] == 'success') { + _post = Map.from(result['post']); + _comments = result['comments'] ?? []; + } else { + _postDeleted = true; + } + } catch (_) { + // 조용히 무시 - 마지막으로 받아온 상태를 유지한다. + } finally { + _isLoading = false; + _safeNotify(); + } + } + + Future<(bool success, String message)> addComment(String content) async { + if (content.trim().isEmpty) return (false, '댓글 내용을 입력해주세요.'); + _isBusy = true; + _safeNotify(); + try { + final response = await http.post( + Uri.parse('$baseUrl/api/board/posts/$postId/comments'), + headers: {"Content-Type": "application/json"}, + body: jsonEncode({"studentId": studentId, "content": content.trim()}), + ); + final result = jsonDecode(utf8.decode(response.bodyBytes)); + if (response.statusCode == 200 && result['status'] == 'success') { + await fetchDetail(); + return (true, '댓글이 등록되었습니다.'); + } + return (false, '${result['message'] ?? '댓글 등록 실패'}'); + } catch (e) { + return (false, '네트워크 에러: $e'); + } finally { + _isBusy = false; + _safeNotify(); + } + } + + Future<(bool success, String message)> deletePost() async { + return _delete('$baseUrl/api/board/posts/$postId'); + } + + Future<(bool success, String message)> deleteComment(int commentId) async { + final (success, message) = await _delete( + '$baseUrl/api/board/comments/$commentId', + ); + if (success) await fetchDetail(); + return (success, message); + } + + Future<(bool success, String message)> _delete(String url) async { + _isBusy = true; + _safeNotify(); + try { + final response = await http.delete( + Uri.parse(url), + headers: {"Content-Type": "application/json"}, + body: jsonEncode({"studentId": studentId}), + ); + final result = jsonDecode(utf8.decode(response.bodyBytes)); + if (response.statusCode == 200 && result['status'] == 'success') { + return (true, '${result['message'] ?? '삭제되었습니다.'}'); + } + return (false, '${result['message'] ?? '삭제 실패'}'); + } catch (e) { + return (false, '네트워크 에러: $e'); + } finally { + _isBusy = false; + _safeNotify(); + } + } + + Future<(bool success, String message)> report({ + required String targetType, + required int targetId, + String? reason, + }) async { + try { + final response = await http.post( + Uri.parse('$baseUrl/api/board/reports'), + headers: {"Content-Type": "application/json"}, + body: jsonEncode({ + "targetType": targetType, + "targetId": targetId, + "reporterStudentId": studentId, + "reason": reason, + }), + ); + final result = jsonDecode(utf8.decode(response.bodyBytes)); + if (response.statusCode == 200 && result['status'] == 'success') { + return (true, '${result['message'] ?? '신고가 접수되었습니다.'}'); + } + return (false, '${result['message'] ?? '신고 접수 실패'}'); + } catch (e) { + return (false, '네트워크 에러: $e'); + } + } +} diff --git a/lib/function/board_report_controller.dart b/lib/function/board_report_controller.dart new file mode 100644 index 0000000..c11e4ff --- /dev/null +++ b/lib/function/board_report_controller.dart @@ -0,0 +1,77 @@ +// 🚩 커뮤니티 게시판 - 신고 검토(운영자용) 화면의 기능(서버 통신/상태) 담당 컨트롤러. +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import '../config.dart'; + +class BoardReportController extends ChangeNotifier { + final String reviewerId; + + BoardReportController({required this.reviewerId}); + + bool _isLoading = true; + bool _isBusy = false; + List _reports = []; + bool _disposed = false; + + bool get isLoading => _isLoading; + bool get isBusy => _isBusy; + List get reports => _reports; + + void _safeNotify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } + + Future init() => fetchPending(); + + Future fetchPending() async { + _isLoading = true; + _safeNotify(); + try { + final response = await http.get( + Uri.parse('$baseUrl/api/board/reports?status=pending'), + ); + if (response.statusCode == 200) { + final data = jsonDecode(utf8.decode(response.bodyBytes)); + _reports = data['reports'] ?? []; + } + } catch (_) { + // 조용히 무시 - 마지막으로 받아온 목록을 유지한다. + } finally { + _isLoading = false; + _safeNotify(); + } + } + + Future<(bool success, String message)> resolve( + int reportId, + String action, + ) async { + _isBusy = true; + _safeNotify(); + try { + final response = await http.post( + Uri.parse('$baseUrl/api/board/reports/$reportId/resolve'), + headers: {"Content-Type": "application/json"}, + body: jsonEncode({"reviewerId": reviewerId, "action": action}), + ); + final result = jsonDecode(utf8.decode(response.bodyBytes)); + if (response.statusCode == 200 && result['status'] == 'success') { + await fetchPending(); + return (true, '${result['message'] ?? '처리되었습니다.'}'); + } + return (false, '${result['message'] ?? '처리 실패'}'); + } catch (e) { + return (false, '네트워크 에러: $e'); + } finally { + _isBusy = false; + _safeNotify(); + } + } +} diff --git a/lib/function/board_write_controller.dart b/lib/function/board_write_controller.dart new file mode 100644 index 0000000..0b408f3 --- /dev/null +++ b/lib/function/board_write_controller.dart @@ -0,0 +1,48 @@ +// ✍️ 커뮤니티 게시판 - 글쓰기 화면의 기능(서버 통신/상태) 담당 컨트롤러. +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import '../config.dart'; + +class BoardWriteController extends ChangeNotifier { + final String studentId; + + BoardWriteController({required this.studentId}); + + bool _isSubmitting = false; + bool get isSubmitting => _isSubmitting; + + Future<(bool success, String message)> submit({ + required String category, + required String title, + required String content, + }) async { + if (title.trim().isEmpty || content.trim().isEmpty) { + return (false, '제목과 내용을 입력해주세요.'); + } + _isSubmitting = true; + notifyListeners(); + try { + final response = await http.post( + Uri.parse('$baseUrl/api/board/posts'), + headers: {"Content-Type": "application/json"}, + body: jsonEncode({ + "studentId": studentId, + "category": category, + "title": title.trim(), + "content": content.trim(), + }), + ); + final result = jsonDecode(utf8.decode(response.bodyBytes)); + if (response.statusCode == 200 && result['status'] == 'success') { + return (true, '게시글이 등록되었습니다.'); + } + return (false, '${result['message'] ?? '게시글 등록 실패'}'); + } catch (e) { + return (false, '네트워크 에러: $e'); + } finally { + _isSubmitting = false; + notifyListeners(); + } + } +} diff --git a/lib/ui/board_home_screen.dart b/lib/ui/board_home_screen.dart new file mode 100644 index 0000000..1822247 --- /dev/null +++ b/lib/ui/board_home_screen.dart @@ -0,0 +1,303 @@ +// 🗣️ 커뮤니티 게시판(백판) 홈 화면 (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), + ), + ); + }, + ), + ); + }, + ); + } +} diff --git a/lib/ui/board_post_detail_screen.dart b/lib/ui/board_post_detail_screen.dart new file mode 100644 index 0000000..3d0f361 --- /dev/null +++ b/lib/ui/board_post_detail_screen.dart @@ -0,0 +1,399 @@ +// 💬 커뮤니티 게시판 - 게시글 상세 + 댓글 화면 (UI 전용). +// 서버 통신/상태는 lib/function/board_post_controller.dart가 담당한다. +import 'package:flutter/material.dart'; +import '../function/board_post_controller.dart'; +import '../theme/app_palette.dart'; +import 'app_notice.dart'; +import 'title_pill.dart'; + +class BoardPostDetailScreen extends StatefulWidget { + final int postId; + final String studentId; + + const BoardPostDetailScreen({ + super.key, + required this.postId, + required this.studentId, + }); + + @override + State createState() => _BoardPostDetailScreenState(); +} + +class _BoardPostDetailScreenState extends State { + late final BoardPostController _controller; + final _commentController = TextEditingController(); + bool _changed = false; + + @override + void initState() { + super.initState(); + _controller = BoardPostController( + postId: widget.postId, + studentId: widget.studentId, + ); + _controller.init(); + } + + @override + void dispose() { + _controller.dispose(); + _commentController.dispose(); + super.dispose(); + } + + Future _submitComment() async { + final text = _commentController.text; + final (success, message) = await _controller.addComment(text); + if (!mounted) return; + if (success) { + _commentController.clear(); + _changed = true; + } + AppNotice.show( + context, + message, + icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded, + ); + } + + Future _showReportSheet({ + required String targetType, + required int targetId, + }) async { + final reasonController = TextEditingController(); + final reason = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: AppPalette.paper, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) => Padding( + padding: EdgeInsets.only( + left: 20, + right: 20, + top: 20, + bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + '신고 사유 (선택)', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 15), + ), + const SizedBox(height: 10), + TextField( + controller: reasonController, + maxLines: 3, + decoration: InputDecoration( + hintText: '어떤 점이 문제인지 알려주세요.', + filled: true, + fillColor: AppPalette.mist, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 14), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppPalette.ink, + foregroundColor: AppPalette.paper, + padding: const EdgeInsets.symmetric(vertical: 14), + ), + onPressed: () => + Navigator.of(sheetContext).pop(reasonController.text), + child: const Text('신고하기'), + ), + ], + ), + ), + ); + if (reason == null || !mounted) return; + final (success, message) = await _controller.report( + targetType: targetType, + targetId: targetId, + reason: reason.trim().isEmpty ? null : reason.trim(), + ); + if (!mounted) return; + AppNotice.show( + context, + message, + icon: success ? Icons.flag_rounded : Icons.error_outline_rounded, + ); + } + + Future _deletePost() async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('게시글 삭제'), + content: const Text('이 게시글을 삭제하시겠습니까?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('취소', style: TextStyle(color: Colors.grey)), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('삭제', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + if (confirmed != true) return; + final (success, message) = await _controller.deletePost(); + if (!mounted) return; + AppNotice.show(context, message); + if (success) Navigator.of(context).pop(true); + } + + Future _deleteComment(int commentId) async { + final (success, message) = await _controller.deleteComment(commentId); + if (!mounted) return; + if (success) _changed = true; + AppNotice.show(context, message); + } + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + if (!didPop) Navigator.of(context).pop(_changed); + }, + child: ListenableBuilder( + listenable: _controller, + builder: (context, _) { + final post = _controller.post; + return Scaffold( + backgroundColor: AppPalette.mist, + appBar: AppBar( + backgroundColor: Colors.transparent, + foregroundColor: AppPalette.ink, + elevation: 0, + centerTitle: true, + title: const TitlePill('게시글'), + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 18), + onPressed: () => Navigator.of(context).pop(_changed), + ), + actions: [ + if (post != null && post['isMine'] != true) + IconButton( + icon: const Icon(Icons.flag_outlined), + tooltip: '신고', + onPressed: () => _showReportSheet( + targetType: 'post', + targetId: widget.postId, + ), + ), + if (post != null && post['isMine'] == true) + IconButton( + icon: const Icon(Icons.delete_outline_rounded), + tooltip: '삭제', + onPressed: _deletePost, + ), + ], + ), + body: _controller.isLoading + ? const Center(child: CircularProgressIndicator()) + : _controller.postDeleted || post == null + ? const Center( + child: Text( + '삭제되었거나 존재하지 않는 게시글입니다.', + style: TextStyle(color: Colors.grey), + ), + ) + : Column( + children: [ + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildPostCard(post), + const SizedBox(height: 16), + Text( + '댓글 ${_controller.comments.length}개', + style: const TextStyle( + fontWeight: FontWeight.bold, + color: AppPalette.ink, + ), + ), + const SizedBox(height: 8), + ..._controller.comments.map(_buildCommentTile), + ], + ), + ), + _buildCommentComposer(), + ], + ), + ); + }, + ), + ); + } + + Widget _buildPostCard(Map post) { + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: AppPalette.paper, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppPalette.sage), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + post['title'] ?? '', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + color: AppPalette.ink, + ), + ), + const SizedBox(height: 6), + Row( + children: [ + Text( + post['isMine'] == true ? '글쓴이(나)' : '익명', + style: const TextStyle(color: Colors.grey, fontSize: 12.5), + ), + const SizedBox(width: 8), + Text( + '${post['createdAt'] ?? ''}', + style: const TextStyle(color: Colors.grey, fontSize: 12.5), + ), + const Spacer(), + Icon(Icons.visibility_outlined, size: 14, color: Colors.grey), + const SizedBox(width: 3), + Text( + '${post['viewCount'] ?? 0}', + style: const TextStyle(color: Colors.grey, fontSize: 12.5), + ), + ], + ), + const Divider(height: 24), + Text( + post['content'] ?? '', + style: const TextStyle(fontSize: 15, height: 1.5), + ), + ], + ), + ); + } + + Widget _buildCommentTile(dynamic comment) { + final bool isMine = comment['isMine'] == true; + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: AppPalette.paper, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + '${comment['anonLabel'] ?? '익명'}', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12.5, + color: isMine ? AppPalette.ink : Colors.grey[700], + ), + ), + const SizedBox(width: 8), + Text( + '${comment['createdAt'] ?? ''}', + style: const TextStyle( + color: Colors.grey, + fontSize: 11.5, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + comment['content'] ?? '', + style: const TextStyle(fontSize: 14, height: 1.4), + ), + ], + ), + ), + IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + icon: Icon( + isMine ? Icons.delete_outline_rounded : Icons.flag_outlined, + size: 18, + color: Colors.grey, + ), + onPressed: isMine + ? () => _deleteComment(comment['id'] as int) + : () => _showReportSheet( + targetType: 'comment', + targetId: comment['id'] as int, + ), + ), + ], + ), + ); + } + + Widget _buildCommentComposer() { + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _commentController, + decoration: InputDecoration( + hintText: '익명으로 댓글 달기', + filled: true, + fillColor: AppPalette.paper, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(999), + borderSide: BorderSide.none, + ), + ), + ), + ), + const SizedBox(width: 8), + _controller.isBusy + ? const SizedBox( + width: 40, + height: 40, + child: Padding( + padding: EdgeInsets.all(8), + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : IconButton( + icon: const Icon(Icons.send_rounded, color: AppPalette.ink), + onPressed: _submitComment, + ), + ], + ), + ), + ); + } +} diff --git a/lib/ui/board_report_screen.dart b/lib/ui/board_report_screen.dart new file mode 100644 index 0000000..59fdc14 --- /dev/null +++ b/lib/ui/board_report_screen.dart @@ -0,0 +1,169 @@ +// 🚩 커뮤니티 게시판 - 신고 검토(운영자용) 화면 (UI 전용). +// 서버 통신/상태는 lib/function/board_report_controller.dart가 담당한다. +import 'package:flutter/material.dart'; +import '../function/board_report_controller.dart'; +import '../theme/app_palette.dart'; +import 'app_notice.dart'; +import 'title_pill.dart'; + +class BoardReportScreen extends StatefulWidget { + final String reviewerId; + + const BoardReportScreen({super.key, required this.reviewerId}); + + @override + State createState() => _BoardReportScreenState(); +} + +class _BoardReportScreenState extends State { + late final BoardReportController _controller; + + @override + void initState() { + super.initState(); + _controller = BoardReportController(reviewerId: widget.reviewerId); + _controller.init(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _resolve(int reportId, String action) async { + final (success, message) = await _controller.resolve(reportId, action); + if (!mounted) return; + AppNotice.show(context, message); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + final reports = _controller.reports; + return Scaffold( + backgroundColor: AppPalette.mist, + appBar: AppBar( + backgroundColor: Colors.transparent, + foregroundColor: AppPalette.ink, + elevation: 0, + centerTitle: true, + title: const TitlePill('게시판 신고 검토'), + ), + body: _controller.isLoading + ? const Center(child: CircularProgressIndicator()) + : reports.isEmpty + ? const Center( + child: Text( + '처리할 신고가 없습니다.', + style: TextStyle(color: Colors.grey), + ), + ) + : RefreshIndicator( + onRefresh: _controller.fetchPending, + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: reports.length, + itemBuilder: (context, index) { + final report = reports[index]; + final reportId = report['id'] as int; + final alreadyDeleted = report['alreadyDeleted'] == true; + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppPalette.paper, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppPalette.sage), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Chip( + label: Text( + report['targetType'] == 'post' + ? '게시글' + : '댓글', + ), + visualDensity: VisualDensity.compact, + backgroundColor: AppPalette.mist, + ), + const SizedBox(width: 8), + Text( + '${report['createdAt'] ?? ''}', + style: const TextStyle( + color: Colors.grey, + fontSize: 12, + ), + ), + ], + ), + const SizedBox(height: 10), + Text( + '${report['targetContent'] ?? ''}', + style: const TextStyle(fontSize: 14, height: 1.4), + ), + const SizedBox(height: 8), + if (report['reason'] != null && + '${report['reason']}'.isNotEmpty) + Text( + '신고 사유: ${report['reason']}', + style: const TextStyle( + color: Colors.redAccent, + fontSize: 12.5, + ), + ), + Text( + '신고자 학번: ${report['reporterStudentId'] ?? ''}', + style: const TextStyle( + color: Colors.grey, + fontSize: 12, + ), + ), + const SizedBox(height: 12), + if (alreadyDeleted) + const Text( + '이미 삭제된 항목입니다.', + style: TextStyle(color: Colors.grey), + ) + else + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _controller.isBusy + ? null + : () => _resolve(reportId, 'dismiss'), + child: const Text('유지 (문제 없음)'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red[400], + foregroundColor: Colors.white, + ), + onPressed: _controller.isBusy + ? null + : () => _resolve(reportId, 'delete'), + child: const Text('삭제'), + ), + ), + ], + ), + ], + ), + ); + }, + ), + ), + ); + }, + ); + } +} diff --git a/lib/ui/board_write_screen.dart b/lib/ui/board_write_screen.dart new file mode 100644 index 0000000..5ac3362 --- /dev/null +++ b/lib/ui/board_write_screen.dart @@ -0,0 +1,138 @@ +// ✍️ 커뮤니티 게시판 - 글쓰기 화면 (UI 전용). +// 서버 통신/상태는 lib/function/board_write_controller.dart가 담당한다. +import 'package:flutter/material.dart'; +import '../function/board_write_controller.dart'; +import '../theme/app_palette.dart'; +import 'app_notice.dart'; +import 'title_pill.dart'; + +class BoardWriteScreen extends StatefulWidget { + final String studentId; + final String category; + final String categoryName; + + const BoardWriteScreen({ + super.key, + required this.studentId, + required this.category, + required this.categoryName, + }); + + @override + State createState() => _BoardWriteScreenState(); +} + +class _BoardWriteScreenState extends State { + late final BoardWriteController _controller; + final _titleController = TextEditingController(); + final _contentController = TextEditingController(); + + @override + void initState() { + super.initState(); + _controller = BoardWriteController(studentId: widget.studentId); + } + + @override + void dispose() { + _controller.dispose(); + _titleController.dispose(); + _contentController.dispose(); + super.dispose(); + } + + Future _submit() async { + final (success, message) = await _controller.submit( + category: widget.category, + title: _titleController.text, + content: _contentController.text, + ); + if (!mounted) return; + AppNotice.show( + context, + message, + icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded, + ); + if (success) Navigator.of(context).pop(true); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: _controller, + builder: (context, _) { + return Scaffold( + backgroundColor: AppPalette.mist, + appBar: AppBar( + backgroundColor: Colors.transparent, + foregroundColor: AppPalette.ink, + elevation: 0, + centerTitle: true, + title: TitlePill('${widget.categoryName} 글쓰기'), + actions: [ + TextButton( + onPressed: _controller.isSubmitting ? null : _submit, + child: _controller.isSubmitting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text( + '등록', + style: TextStyle( + color: AppPalette.ink, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Padding( + padding: EdgeInsets.only(bottom: 4, left: 4), + child: Text( + '글쓴이는 익명으로 표시됩니다.', + style: TextStyle(color: Colors.grey, fontSize: 12.5), + ), + ), + const SizedBox(height: 8), + TextField( + controller: _titleController, + maxLength: 100, + decoration: InputDecoration( + hintText: '제목', + filled: true, + fillColor: AppPalette.paper, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: AppPalette.sage), + ), + ), + ), + TextField( + controller: _contentController, + maxLines: 12, + minLines: 8, + decoration: InputDecoration( + hintText: '내용을 입력하세요', + filled: true, + fillColor: AppPalette.paper, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide(color: AppPalette.sage), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/ui/main_dashboard.dart b/lib/ui/main_dashboard.dart index ad74a00..1777b94 100644 --- a/lib/ui/main_dashboard.dart +++ b/lib/ui/main_dashboard.dart @@ -12,6 +12,8 @@ import '../models/dashboard_tile_layout.dart'; import '../theme/app_palette.dart'; import 'admin_dashboard.dart'; import 'app_notice.dart'; +import 'board_home_screen.dart'; +import 'board_report_screen.dart'; import 'dashboard_settings_page.dart'; import 'device_checkout_ledger_page.dart'; import 'device_checkout_request_screen.dart'; @@ -332,6 +334,19 @@ class _MainDashboardState extends State { ), ), )); + tiles.add(( + id: 'community_board', + child: _buildModernCard( + icon: Icons.forum_rounded, + title: '백판', + subtitle: '익명 커뮤니티 게시판', + color: AppPalette.ink, + onTap: () => pushLaunchpad( + context, + (context) => BoardHomeScreen(studentId: _displayId), + ), + ), + )); } if (_isTeacherOrAbove) { @@ -377,6 +392,19 @@ class _MainDashboardState extends State { ), ), )); + tiles.add(( + id: 'board_moderation', + child: _buildModernCard( + icon: Icons.shield_outlined, + title: '백판 신고 검토', + subtitle: '학생 게시판 신고 처리', + color: AppPalette.ink, + onTap: () => pushLaunchpad( + context, + (context) => BoardReportScreen(reviewerId: _displayId), + ), + ), + )); } if (_isAdmin) {