// ๐Ÿ‘ฅ ๋ฐฑํ’ˆํƒ€ ๊ทธ๋ฃน ์ƒ์„ธ ํ™”๋ฉด (UI ์ „์šฉ) - ๋ฉค๋ฒ„ ์˜ค๋Š˜ ์ถœ์„ ํ˜„ํ™ฉ, ์นœ๊ตฌ ์ดˆ๋Œ€, ๊ทธ๋ฃน ๋‚˜๊ฐ€๊ธฐ. // ์„œ๋ฒ„ ํ†ต์‹ /์ƒํƒœ๋Š” lib/function/group_detail_controller.dart, friend_controller.dart๊ฐ€ ๋‹ด๋‹นํ•œ๋‹ค. import 'package:flutter/material.dart'; import '../function/friend_controller.dart'; import '../function/group_detail_controller.dart'; import '../theme/app_palette.dart'; import 'app_notice.dart'; import 'launchpad_transition.dart'; import 'study_timer_screen.dart'; import 'title_pill.dart'; class GroupDetailScreen extends StatefulWidget { final int groupId; final String groupName; final String studentId; final String studentName; final int? grade; const GroupDetailScreen({ super.key, required this.groupId, required this.groupName, required this.studentId, required this.studentName, this.grade, }); @override State createState() => _GroupDetailScreenState(); } class _GroupDetailScreenState extends State { late final GroupDetailController _controller; @override void initState() { super.initState(); _controller = GroupDetailController( groupId: widget.groupId, studentId: widget.studentId, ); _controller.init(); } @override void dispose() { _controller.dispose(); super.dispose(); } Future _showInviteSheet() async { final friendController = FriendController( studentId: widget.studentId, studentName: '', ); await friendController.init(); if (!mounted) return; final memberIds = _controller.members .map((m) => m['studentId'] as String) .toSet(); final invitable = friendController.friends .where((f) => !memberIds.contains(f['studentId'])) .toList(); await showModalBottomSheet( context: context, backgroundColor: AppPalette.paper, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (sheetContext) { return Padding( padding: const EdgeInsets.all(20), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Text( '์นœ๊ตฌ ์ดˆ๋Œ€', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), ), const SizedBox(height: 12), if (invitable.isEmpty) const Padding( padding: EdgeInsets.symmetric(vertical: 16), child: Text( '์ดˆ๋Œ€ํ•  ์ˆ˜ ์žˆ๋Š” ์นœ๊ตฌ๊ฐ€ ์—†์–ด์š”.\n(์ด๋ฏธ ๊ทธ๋ฃน์— ์žˆ๊ฑฐ๋‚˜ ์นœ๊ตฌ๊ฐ€ ์—†์–ด์š”)', textAlign: TextAlign.center, style: TextStyle(color: Colors.grey), ), ) else ConstrainedBox( constraints: const BoxConstraints(maxHeight: 320), child: ListView( shrinkWrap: true, children: [ for (final f in invitable) ListTile( leading: const Icon( Icons.person_rounded, color: AppPalette.ink, ), title: Text('${f['name']}'), subtitle: Text('${f['studentId']}'), trailing: ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: AppPalette.ink, foregroundColor: AppPalette.paper, ), onPressed: () async { final (success, message) = await _controller .invite( f['studentId'] as String, f['name'] as String, ); if (sheetContext.mounted) { Navigator.of(sheetContext).pop(); } if (!mounted) return; AppNotice.show( context, message, icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded, ); }, child: const Text('์ดˆ๋Œ€'), ), ), ], ), ), ], ), ); }, ); 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, builder: (dialogContext) => AlertDialog( title: const Text('๊ทธ๋ฃน ๋‚˜๊ฐ€๊ธฐ'), content: Text('${widget.groupName} ๊ทธ๋ฃน์—์„œ ๋‚˜๊ฐ€์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?'), 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.leave(); if (!mounted) return; AppNotice.show(context, message); if (success) Navigator.of(context).pop(); } String _formatMinutes(int seconds) { final m = seconds ~/ 60; if (m < 60) return '$m๋ถ„'; return '${m ~/ 60}์‹œ๊ฐ„ ${m % 60}๋ถ„'; } void _openTimer() { pushLaunchpad( context, (context) => StudyTimerScreen( studentId: widget.studentId, studentName: widget.studentName, grade: widget.grade, ), ); } /// ๋„“์€ ํ™”๋ฉด(์›น ๋ฐ์Šคํฌํ†ฑ)์—์„œ ๋‚ด์šฉ์ด ์–‘์˜†์œผ๋กœ ๋Š˜์–ด์ง€์ง€ ์•Š๊ฒŒ ๊ฐ€์šด๋ฐ ์ตœ๋Œ€ 560 ํญ์œผ๋กœ ๋งž์ถ˜๋‹ค. double _sidePad(BuildContext context) { final width = MediaQuery.of(context).size.width; return ((width - 560) / 2).clamp(16.0, double.infinity); } @override Widget build(BuildContext context) { return ListenableBuilder( listenable: _controller, builder: (context, _) { final members = _controller.members; final attendedCount = members .where((m) => m['attendedToday'] == true) .length; return Scaffold( backgroundColor: AppPalette.mist, appBar: AppBar( backgroundColor: Colors.transparent, 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: '๊ทธ๋ฃน ๋‚˜๊ฐ€๊ธฐ', onPressed: _leave, ), ], ), floatingActionButton: FloatingActionButton.extended( backgroundColor: AppPalette.ink, foregroundColor: AppPalette.paper, onPressed: _showInviteSheet, icon: const Icon(Icons.person_add_rounded), label: const Text('์นœ๊ตฌ ์ดˆ๋Œ€'), ), body: _controller.isLoading ? const Center(child: CircularProgressIndicator()) : RefreshIndicator( onRefresh: _controller.refresh, child: ListView( padding: EdgeInsets.fromLTRB( _sidePad(context), 0, _sidePad(context), 90, ), children: [ Center(child: TitlePill(widget.groupName)), const SizedBox(height: 16), Row( children: [ Expanded(child: _statPill('์ „์ฒด ๋ฉค๋ฒ„', members.length)), const SizedBox(width: 8), Expanded(child: _statPill('์˜ค๋Š˜ ๊ณต๋ถ€ํ•จ', attendedCount)), ], ), const SizedBox(height: 12), SizedBox( width: double.infinity, child: OutlinedButton.icon( onPressed: _openTimer, icon: const Icon(Icons.timer_rounded), label: const Text('๋ฐฑํ’ˆํƒ€ ๋ฐ”๋กœ๊ฐ€๊ธฐ'), style: OutlinedButton.styleFrom( foregroundColor: AppPalette.ink, side: const BorderSide(color: AppPalette.sage), padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), ), ), ), 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: [ 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(height: 10), GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: 150, mainAxisSpacing: 10, crossAxisSpacing: 10, mainAxisExtent: 96, ), itemCount: members.length, itemBuilder: (context, index) { final m = members[index]; final attended = m['attendedToday'] == true; return Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: attended ? AppPalette.ink : AppPalette.paper, borderRadius: BorderRadius.circular(16), border: Border.all( color: attended ? AppPalette.ink : AppPalette.sage, ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( attended ? Icons.check_circle_rounded : Icons.radio_button_unchecked_rounded, color: attended ? Colors.white : Colors.grey[350], size: 18, ), const Spacer(), Text( '${m['name']}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 13, color: attended ? Colors.white : AppPalette.ink, ), ), if (m['role'] == 'owner') Text( '๊ทธ๋ฃน์žฅ', style: TextStyle( fontSize: 10, color: attended ? Colors.white70 : Colors.grey[500], ), ), const SizedBox(height: 2), Text( _formatMinutes( (m['todaySeconds'] as num?)?.toInt() ?? 0, ), style: TextStyle( fontSize: 11, color: attended ? Colors.white70 : Colors.grey[500], ), ), ], ), ); }, ), ], ), ), ); }, ); } Widget _statPill(String label, int count) { return Container( padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), decoration: BoxDecoration( color: AppPalette.paper, borderRadius: BorderRadius.circular(28), border: Border.all(color: AppPalette.sage), ), child: Column( children: [ Text(label, style: TextStyle(fontSize: 12, color: Colors.grey[600])), const SizedBox(height: 4), Text( '$count๋ช…', style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, color: AppPalette.ink, ), ), ], ), ); } }