From 4f643151972a839dc3e094e4a66c63704ed046d1 Mon Sep 17 00:00:00 2001 From: sihoo Date: Tue, 22 Sep 2026 03:49:35 +0000 Subject: [PATCH] =?UTF-8?q?=EA=B7=B8=EB=A3=B9=20=EC=8A=A4=ED=84=B0?= =?UTF-8?q?=EB=94=94=20=EA=B3=B5=EA=B0=9C/=EB=B9=84=EA=B3=B5=EA=B0=9C=20?= =?UTF-8?q?=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/group_controller.dart | 47 +++++- lib/function/group_detail_controller.dart | 74 +++++++- lib/ui/group_detail_screen.dart | 132 +++++++++++++++ lib/ui/group_list_screen.dart | 196 ++++++++++++++++++---- 4 files changed, 418 insertions(+), 31 deletions(-) diff --git a/lib/function/group_controller.dart b/lib/function/group_controller.dart index 95146c3..b05a692 100644 --- a/lib/function/group_controller.dart +++ b/lib/function/group_controller.dart @@ -14,12 +14,14 @@ class GroupController extends ChangeNotifier { bool _isBusy = false; List _groups = []; List _invites = []; + List _publicGroups = []; bool _disposed = false; bool get isLoading => _isLoading; bool get isBusy => _isBusy; List get groups => _groups; List get invites => _invites; + List get publicGroups => _publicGroups; void _safeNotify() { if (!_disposed) notifyListeners(); @@ -36,7 +38,7 @@ class GroupController extends ChangeNotifier { Future refresh() async { _isLoading = true; _safeNotify(); - await Future.wait([_fetchGroups(), _fetchInvites()]); + await Future.wait([_fetchGroups(), _fetchInvites(), _fetchPublicGroups()]); _isLoading = false; _safeNotify(); } @@ -69,7 +71,24 @@ class GroupController extends ChangeNotifier { } } - Future<(bool success, String message)> createGroup(String name) async { + Future _fetchPublicGroups() async { + try { + final response = await http.get( + Uri.parse('$baseUrl/api/groups/public?studentId=$studentId'), + ); + if (response.statusCode == 200) { + final data = jsonDecode(utf8.decode(response.bodyBytes)); + _publicGroups = data['groups'] ?? []; + } + } catch (_) { + // 조용히 무시 + } + } + + Future<(bool success, String message)> createGroup( + String name, { + bool isPublic = false, + }) async { _isBusy = true; _safeNotify(); try { @@ -80,6 +99,7 @@ class GroupController extends ChangeNotifier { "ownerId": studentId, "ownerName": studentName, "name": name.trim(), + "isPublic": isPublic, }), ); final result = jsonDecode(utf8.decode(response.bodyBytes)); @@ -121,4 +141,27 @@ class GroupController extends ChangeNotifier { _safeNotify(); } } + + Future<(bool success, String message)> requestJoin(int groupId) async { + _isBusy = true; + _safeNotify(); + try { + final response = await http.post( + Uri.parse('$baseUrl/api/groups/$groupId/join-request'), + headers: {"Content-Type": "application/json"}, + body: jsonEncode({"studentId": studentId, "studentName": studentName}), + ); + final result = jsonDecode(utf8.decode(response.bodyBytes)); + if (response.statusCode == 200 && result['status'] == 'success') { + await _fetchPublicGroups(); + return (true, '${result['message'] ?? '참여 신청을 보냈습니다.'}'); + } + return (false, '${result['message'] ?? '참여 신청 실패'}'); + } catch (e) { + return (false, '네트워크 에러: $e'); + } finally { + _isBusy = false; + _safeNotify(); + } + } } diff --git a/lib/function/group_detail_controller.dart b/lib/function/group_detail_controller.dart index fe7ff78..6b3addd 100644 --- a/lib/function/group_detail_controller.dart +++ b/lib/function/group_detail_controller.dart @@ -13,11 +13,17 @@ class GroupDetailController extends ChangeNotifier { bool _isLoading = true; bool _isBusy = false; List _members = []; + List _joinRequests = []; + bool _isPublic = false; bool _disposed = false; bool get isLoading => _isLoading; bool get isBusy => _isBusy; List get members => _members; + List get joinRequests => _joinRequests; + bool get isPublic => _isPublic; + bool get isOwner => + _members.any((m) => m['studentId'] == studentId && m['role'] == 'owner'); void _safeNotify() { if (!_disposed) notifyListeners(); @@ -41,11 +47,77 @@ class GroupDetailController extends ChangeNotifier { if (response.statusCode == 200) { final data = jsonDecode(utf8.decode(response.bodyBytes)); _members = data['members'] ?? []; + _isPublic = data['isPublic'] == true; } } catch (_) { // 조용히 무시 + } + if (isOwner) await _fetchJoinRequests(); + _isLoading = false; + _safeNotify(); + } + + Future _fetchJoinRequests() async { + try { + final response = await http.get( + Uri.parse( + '$baseUrl/api/groups/$groupId/join-requests?ownerId=$studentId', + ), + ); + if (response.statusCode == 200) { + final data = jsonDecode(utf8.decode(response.bodyBytes)); + _joinRequests = data['requests'] ?? []; + } + } catch (_) { + // 조용히 무시 + } + } + + Future<(bool success, String message)> respondJoinRequest( + int requestId, + bool accept, + ) async { + _isBusy = true; + _safeNotify(); + try { + final response = await http.post( + Uri.parse('$baseUrl/api/groups/join-requests/$requestId/respond'), + headers: {"Content-Type": "application/json"}, + body: jsonEncode({"responderId": studentId, "accept": accept}), + ); + final result = jsonDecode(utf8.decode(response.bodyBytes)); + if (response.statusCode == 200 && result['status'] == 'success') { + await refresh(); + return (true, '${result['message'] ?? '처리했습니다.'}'); + } + return (false, '${result['message'] ?? '처리 실패'}'); + } catch (e) { + return (false, '네트워크 에러: $e'); } finally { - _isLoading = false; + _isBusy = false; + _safeNotify(); + } + } + + Future<(bool success, String message)> setVisibility(bool isPublic) async { + _isBusy = true; + _safeNotify(); + try { + final response = await http.post( + Uri.parse('$baseUrl/api/groups/$groupId/visibility'), + headers: {"Content-Type": "application/json"}, + body: jsonEncode({"ownerId": studentId, "isPublic": isPublic}), + ); + final result = jsonDecode(utf8.decode(response.bodyBytes)); + if (response.statusCode == 200 && result['status'] == 'success') { + await refresh(); + return (true, '${result['message'] ?? '변경했습니다.'}'); + } + return (false, '${result['message'] ?? '변경 실패'}'); + } catch (e) { + return (false, '네트워크 에러: $e'); + } finally { + _isBusy = false; _safeNotify(); } } diff --git a/lib/ui/group_detail_screen.dart b/lib/ui/group_detail_screen.dart index 803573d..77e4577 100644 --- a/lib/ui/group_detail_screen.dart +++ b/lib/ui/group_detail_screen.dart @@ -135,6 +135,48 @@ class _GroupDetailScreenState extends State { friendController.dispose(); } + Future _toggleVisibility() async { + final makePublic = !_controller.isPublic; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(makePublic ? '공개 그룹으로 전환' : '비공개 그룹으로 전환'), + content: Text( + makePublic + ? '이제 누구나 이 그룹을 찾아서 참여 신청을 보낼 수 있어요. 참여는 그룹장이 허용해야 완료됩니다.' + : '더 이상 공개 목록에 뜨지 않고, 대기 중인 참여 신청은 모두 거절 처리됩니다.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('취소', style: TextStyle(color: Colors.grey)), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppPalette.ink, + foregroundColor: AppPalette.paper, + ), + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('전환'), + ), + ], + ), + ); + if (confirmed != true) return; + final (_, message) = await _controller.setVisibility(makePublic); + if (!mounted) return; + AppNotice.show(context, message); + } + + Future _respondJoinRequest(int requestId, bool accept) async { + final (_, message) = await _controller.respondJoinRequest( + requestId, + accept, + ); + if (!mounted) return; + AppNotice.show(context, message); + } + Future _leave() async { final confirmed = await showDialog( context: context, @@ -183,6 +225,16 @@ class _GroupDetailScreenState extends State { foregroundColor: AppPalette.ink, elevation: 0, actions: [ + if (_controller.isOwner) + IconButton( + icon: Icon( + _controller.isPublic + ? Icons.public_rounded + : Icons.lock_rounded, + ), + tooltip: _controller.isPublic ? '공개 그룹' : '비공개 그룹', + onPressed: _toggleVisibility, + ), IconButton( icon: const Icon(Icons.exit_to_app_rounded), tooltip: '그룹 나가기', @@ -213,6 +265,86 @@ class _GroupDetailScreenState extends State { Expanded(child: _statPill('오늘 공부함', attendedCount)), ], ), + if (_controller.isOwner && + _controller.joinRequests.isNotEmpty) ...[ + const SizedBox(height: 20), + Row( + children: [ + Container( + width: 4, + height: 16, + decoration: BoxDecoration( + color: AppPalette.ink, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + const Text( + '대기 중인 참여 신청', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppPalette.ink, + ), + ), + const SizedBox(width: 6), + Text( + '${_controller.joinRequests.length}명', + style: TextStyle( + fontSize: 13, + color: Colors.grey[500], + ), + ), + ], + ), + const SizedBox(height: 10), + for (final r in _controller.joinRequests) + Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + color: AppPalette.paper, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppPalette.sage), + ), + child: Row( + children: [ + Expanded( + child: Text( + '${r['name']} (${r['studentId']})', + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + ), + TextButton( + onPressed: () => _respondJoinRequest( + r['requestId'] as int, + false, + ), + child: const Text( + '거절', + style: TextStyle(color: Colors.grey), + ), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppPalette.ink, + foregroundColor: AppPalette.paper, + ), + onPressed: () => _respondJoinRequest( + r['requestId'] as int, + true, + ), + child: const Text('허용'), + ), + ], + ), + ), + ], const SizedBox(height: 20), Row( children: [ diff --git a/lib/ui/group_list_screen.dart b/lib/ui/group_list_screen.dart index 0f46923..979a016 100644 --- a/lib/ui/group_list_screen.dart +++ b/lib/ui/group_list_screen.dart @@ -43,37 +43,58 @@ class _GroupListScreenState extends State { Future _showCreateGroupDialog() async { final nameController = TextEditingController(); + bool isPublic = false; final result = await showDialog( context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('그룹 만들기'), - content: TextField( - controller: nameController, - maxLength: 20, - decoration: const InputDecoration( - labelText: '그룹 이름', - border: OutlineInputBorder(), + builder: (dialogContext) => StatefulBuilder( + builder: (dialogContext, setDialogState) => AlertDialog( + title: const Text('그룹 만들기'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: nameController, + maxLength: 20, + decoration: const InputDecoration( + labelText: '그룹 이름', + border: OutlineInputBorder(), + ), + ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('공개 그룹으로 만들기'), + subtitle: const Text( + '누구나 목록에서 보고 참여 신청할 수 있어요\n(그룹장이 허용해야 들어옵니다)', + style: TextStyle(fontSize: 12), + ), + value: isPublic, + activeThumbColor: AppPalette.ink, + onChanged: (v) => setDialogState(() => isPublic = v), + ), + ], ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext, false), - child: const Text('취소', style: TextStyle(color: Colors.grey)), - ), - ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: AppPalette.ink, - foregroundColor: AppPalette.paper, + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('취소', style: TextStyle(color: Colors.grey)), ), - onPressed: () => Navigator.pop(dialogContext, true), - child: const Text('만들기'), - ), - ], + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppPalette.ink, + foregroundColor: AppPalette.paper, + ), + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('만들기'), + ), + ], + ), ), ); if (result != true || nameController.text.trim().isEmpty) return; final (success, message) = await _controller.createGroup( nameController.text.trim(), + isPublic: isPublic, ); if (!mounted) return; AppNotice.show( @@ -89,6 +110,16 @@ class _GroupListScreenState extends State { AppNotice.show(context, message); } + Future _requestJoin(int groupId) async { + final (success, message) = await _controller.requestJoin(groupId); + if (!mounted) return; + AppNotice.show( + context, + message, + icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded, + ); + } + Future _openGroup(dynamic group) async { await pushLaunchpad( context, @@ -268,12 +299,54 @@ class _GroupListScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - '${g['name']}', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 15, - ), + Row( + children: [ + Flexible( + child: Text( + '${g['name']}', + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + ), + ), + ), + if (g['isPublic'] == true) ...[ + const SizedBox(width: 6), + const Icon( + Icons.public_rounded, + size: 14, + color: Colors.grey, + ), + ], + if ((g['pendingRequestCount'] ?? + 0) > + 0) ...[ + const SizedBox(width: 6), + Container( + padding: + const EdgeInsets.symmetric( + horizontal: 6, + vertical: 1, + ), + decoration: BoxDecoration( + color: Colors.red[50], + borderRadius: + BorderRadius.circular(20), + border: Border.all( + color: Colors.red, + ), + ), + child: Text( + '대기 ${g['pendingRequestCount']}', + style: const TextStyle( + fontSize: 10, + color: Colors.red, + ), + ), + ), + ], + ], ), Text( '멤버 ${g['memberCount']}명' @@ -294,6 +367,73 @@ class _GroupListScreenState extends State { ), ), ), + if (_controller.publicGroups.isNotEmpty) ...[ + const SizedBox(height: 20), + _sectionHeader( + '공개 그룹', + count: _controller.publicGroups.length, + ), + const SizedBox(height: 10), + for (final g in _controller.publicGroups) + Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + color: AppPalette.paper, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppPalette.sage), + ), + child: Row( + children: [ + const Icon( + Icons.public_rounded, + color: AppPalette.ink, + size: 18, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + '${g['name']}', + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + Text( + '${g['ownerName']} · 멤버 ${g['memberCount']}명', + style: TextStyle( + fontSize: 12, + color: Colors.grey[500], + ), + ), + ], + ), + ), + if (g['requested'] == true) + Text( + '신청됨', + style: TextStyle(color: Colors.grey[500]), + ) + else + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppPalette.ink, + foregroundColor: AppPalette.paper, + ), + onPressed: () => + _requestJoin(g['groupId'] as int), + child: const Text('참여 신청'), + ), + ], + ), + ), + ], ], ), ),