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) {