Files
school-attendance/lib/ui/group_detail_screen.dart
sihooandClaude Sonnet 5 f5aa832ad9 그룹 상세: 멤버를 타일 그리드로, 공부 시간순 정렬, 백품타 바로가기 추가
가로로 길게 늘어지던 멤버 목록을 학생 계정관리 화면 같은 타일
그리드로 바꾸고, 오늘 공부 시간이 많은 순으로 정렬(백엔드).
화면 폭도 다른 화면들처럼 최대 560으로 제한. "백품타 바로가기"
버튼으로 그룹 화면에서 바로 공부 타이머로 이동 가능.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 03:58:21 +00:00

528 lines
20 KiB
Dart

// 👥 백품타 그룹 상세 화면 (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<GroupDetailScreen> createState() => _GroupDetailScreenState();
}
class _GroupDetailScreenState extends State<GroupDetailScreen> {
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<void> _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<void>(
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<void> _toggleVisibility() async {
final makePublic = !_controller.isPublic;
final confirmed = await showDialog<bool>(
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<void> _respondJoinRequest(int requestId, bool accept) async {
final (_, message) = await _controller.respondJoinRequest(
requestId,
accept,
);
if (!mounted) return;
AppNotice.show(context, message);
}
Future<void> _leave() async {
final confirmed = await showDialog<bool>(
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,
),
),
],
),
);
}
}