가로로 길게 늘어지던 멤버 목록을 학생 계정관리 화면 같은 타일 그리드로 바꾸고, 오늘 공부 시간이 많은 순으로 정렬(백엔드). 화면 폭도 다른 화면들처럼 최대 560으로 제한. "백품타 바로가기" 버튼으로 그룹 화면에서 바로 공부 타이머로 이동 가능. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
449 lines
19 KiB
Dart
449 lines
19 KiB
Dart
// 👥 백품타 그룹 스터디 목록 화면 (UI 전용). "실시간 출석 현황"과 같은 패밀리룩을 따른다.
|
|
// 서버 통신/상태는 lib/function/group_controller.dart가 담당한다.
|
|
import 'package:flutter/material.dart';
|
|
import '../function/group_controller.dart';
|
|
import '../theme/app_palette.dart';
|
|
import 'app_notice.dart';
|
|
import 'group_detail_screen.dart';
|
|
import 'launchpad_transition.dart';
|
|
import 'title_pill.dart';
|
|
|
|
class GroupListScreen extends StatefulWidget {
|
|
final String studentId;
|
|
final String studentName;
|
|
final int? grade;
|
|
|
|
const GroupListScreen({
|
|
super.key,
|
|
required this.studentId,
|
|
required this.studentName,
|
|
this.grade,
|
|
});
|
|
|
|
@override
|
|
State<GroupListScreen> createState() => _GroupListScreenState();
|
|
}
|
|
|
|
class _GroupListScreenState extends State<GroupListScreen> {
|
|
late final GroupController _controller;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = GroupController(
|
|
studentId: widget.studentId,
|
|
studentName: widget.studentName,
|
|
);
|
|
_controller.init();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _showCreateGroupDialog() async {
|
|
final nameController = TextEditingController();
|
|
bool isPublic = false;
|
|
final result = await showDialog<bool>(
|
|
context: context,
|
|
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,
|
|
),
|
|
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(
|
|
context,
|
|
message,
|
|
icon: success ? Icons.check_circle_rounded : Icons.error_outline_rounded,
|
|
);
|
|
}
|
|
|
|
Future<void> _respondInvite(int inviteId, bool accept) async {
|
|
final (_, message) = await _controller.respondInvite(inviteId, accept);
|
|
if (!mounted) return;
|
|
AppNotice.show(context, message);
|
|
}
|
|
|
|
Future<void> _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<void> _openGroup(dynamic group) async {
|
|
await pushLaunchpad(
|
|
context,
|
|
(context) => GroupDetailScreen(
|
|
groupId: group['groupId'] as int,
|
|
groupName: group['name'] as String,
|
|
studentId: widget.studentId,
|
|
studentName: widget.studentName,
|
|
grade: widget.grade,
|
|
),
|
|
);
|
|
_controller.refresh();
|
|
}
|
|
|
|
Widget _sectionHeader(String title, {int? count}) {
|
|
return Row(
|
|
children: [
|
|
Container(
|
|
width: 4,
|
|
height: 16,
|
|
decoration: BoxDecoration(
|
|
color: AppPalette.ink,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
title,
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppPalette.ink,
|
|
),
|
|
),
|
|
if (count != null) ...[
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
'$count개',
|
|
style: TextStyle(fontSize: 13, color: Colors.grey[500]),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
@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,
|
|
),
|
|
floatingActionButton: FloatingActionButton.extended(
|
|
backgroundColor: AppPalette.ink,
|
|
foregroundColor: AppPalette.paper,
|
|
onPressed: _showCreateGroupDialog,
|
|
icon: const Icon(Icons.add_rounded),
|
|
label: const Text('그룹 만들기'),
|
|
),
|
|
body: _controller.isLoading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: RefreshIndicator(
|
|
onRefresh: _controller.refresh,
|
|
child: ListView(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 90),
|
|
children: [
|
|
const Center(child: TitlePill('그룹 스터디')),
|
|
const SizedBox(height: 20),
|
|
if (_controller.invites.isNotEmpty) ...[
|
|
_sectionHeader(
|
|
'받은 그룹 초대',
|
|
count: _controller.invites.length,
|
|
),
|
|
const SizedBox(height: 10),
|
|
for (final inv in _controller.invites)
|
|
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(
|
|
'${inv['groupName']}',
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: () => _respondInvite(
|
|
inv['inviteId'] as int,
|
|
false,
|
|
),
|
|
child: const Text(
|
|
'거절',
|
|
style: TextStyle(color: Colors.grey),
|
|
),
|
|
),
|
|
ElevatedButton(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppPalette.ink,
|
|
foregroundColor: AppPalette.paper,
|
|
),
|
|
onPressed: () => _respondInvite(
|
|
inv['inviteId'] as int,
|
|
true,
|
|
),
|
|
child: const Text('참여'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
],
|
|
_sectionHeader('내 그룹', count: _controller.groups.length),
|
|
const SizedBox(height: 10),
|
|
if (_controller.groups.isEmpty)
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 12),
|
|
child: Text(
|
|
'아직 그룹이 없어요. 새 그룹을 만들거나 초대를 기다려보세요.',
|
|
style: TextStyle(color: Colors.grey),
|
|
),
|
|
)
|
|
else
|
|
for (final g in _controller.groups)
|
|
GestureDetector(
|
|
onTap: () => _openGroup(g),
|
|
child: Container(
|
|
margin: const EdgeInsets.only(bottom: 8),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 14,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: AppPalette.paper,
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: AppPalette.sage),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.03),
|
|
blurRadius: 12,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: AppPalette.ink.withValues(
|
|
alpha: 0.06,
|
|
),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(
|
|
Icons.groups_rounded,
|
|
color: AppPalette.ink,
|
|
size: 18,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment:
|
|
CrossAxisAlignment.start,
|
|
children: [
|
|
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']}명'
|
|
'${g['isOwner'] == true ? ' · 그룹장' : ''}',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[500],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Icon(
|
|
Icons.chevron_right_rounded,
|
|
color: Colors.grey,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
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('참여 신청'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|